Compare commits
23 Commits
81d8be08b2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c2f811640b | |||
| 6921c70df7 | |||
| bd11cd80da | |||
| 2e7478f5ea | |||
| e2aba856b5 | |||
| 17a3afd5e9 | |||
| 8c260f8715 | |||
| b23649059f | |||
| 46996ebf31 | |||
| 37cf34a587 | |||
| 659ba00ebf | |||
| 969c0480ba | |||
| 4ffcac00d0 | |||
| a9adbad7ef | |||
| ce8e869731 | |||
| 2d17760a5e | |||
| 070fdc0ee5 | |||
| e98e6bda73 | |||
| 2ca47eb4df | |||
| bbdcc36e05 | |||
| 5b07bb4e14 | |||
| 5a2a977dc6 | |||
| c58027c034 |
@@ -1 +1,5 @@
|
|||||||
*.wasm binary
|
*.wasm binary
|
||||||
|
*.ts linguist-language=TypeScript
|
||||||
|
*.ts linguist-detectable=true
|
||||||
|
*.ts linguist-vendored=false
|
||||||
|
*.ts linguist-generated=false
|
||||||
+30
-3
@@ -333,15 +333,42 @@ cannot guarantee.
|
|||||||
| `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` |
|
| `runtime.image_pull_failed` | admin email | `game_id`, `image_ref` |
|
||||||
| `runtime.container_start_failed` | admin email | `game_id` |
|
| `runtime.container_start_failed` | admin email | `game_id` |
|
||||||
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` |
|
| `runtime.start_config_invalid` | admin email | `game_id`, `reason` |
|
||||||
|
| `game.turn.ready` | push | `game_id`, `turn` |
|
||||||
|
| `game.paused` | push | `game_id`, `turn`, `reason` |
|
||||||
|
|
||||||
Admin-channel kinds (`runtime.*`) deliver email to
|
Admin-channel kinds (`runtime.*`) deliver email to
|
||||||
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
|
`BACKEND_NOTIFICATION_ADMIN_EMAIL`; when the variable is empty, those
|
||||||
routes land in `notification_routes` with `status='skipped'` and the
|
routes land in `notification_routes` with `status='skipped'` and the
|
||||||
operator log line records the configuration miss.
|
operator log line records the configuration miss.
|
||||||
|
|
||||||
`game.*` (`game.started`, `game.turn.ready`, `game.generation.failed`,
|
`game.turn.ready` and `game.paused` are emitted by
|
||||||
`game.finished`) and `mail.dead_lettered` are reserved kinds without a
|
`lobby.Service.OnRuntimeSnapshot`
|
||||||
producer in the catalog; adding them is an additive change to the
|
(`backend/internal/lobby/runtime_hooks.go`):
|
||||||
|
|
||||||
|
- `game.turn.ready` fires whenever the engine's `current_turn`
|
||||||
|
advances. Idempotency key `turn-ready:<game_id>:<turn>`, JSON
|
||||||
|
payload `{game_id, turn}`.
|
||||||
|
- `game.paused` fires whenever the same hook flips the game
|
||||||
|
`running → paused` because a runtime snapshot landed with
|
||||||
|
`engine_unreachable` / `generation_failed`. Idempotency key
|
||||||
|
`paused:<game_id>:<turn>`, JSON payload
|
||||||
|
`{game_id, turn, reason}` (reason carries the runtime status
|
||||||
|
that triggered the transition). The runtime scheduler
|
||||||
|
(`backend/internal/runtime/scheduler.go`) forwards the failing
|
||||||
|
snapshot through `Service.publishFailureSnapshot` so a single
|
||||||
|
failing tick reliably reaches lobby.
|
||||||
|
|
||||||
|
Both kinds target every active membership and route through the
|
||||||
|
push channel only — per-turn / per-pause email would be spam — so
|
||||||
|
the UI's signed `SubscribeEvents` stream
|
||||||
|
(`ui/frontend/src/api/events.svelte.ts`) is the sole delivery
|
||||||
|
path. The order tab consumes them via
|
||||||
|
`OrderDraftStore.resetForNewTurn` / `markPaused`
|
||||||
|
(`ui/docs/sync-protocol.md`).
|
||||||
|
|
||||||
|
The remaining `game.*` (`game.started`, `game.generation.failed`,
|
||||||
|
`game.finished`) and `mail.dead_lettered` are reserved kinds without
|
||||||
|
a producer in the catalog; adding them is an additive change to the
|
||||||
catalog vocabulary and the migration CHECK constraint.
|
catalog vocabulary and the migration CHECK constraint.
|
||||||
|
|
||||||
Templates ship in English only; localisation belongs to clients that
|
Templates ship in English only; localisation belongs to clients that
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
pathPlayerCommand = "/api/v1/command"
|
pathPlayerCommand = "/api/v1/command"
|
||||||
pathPlayerOrder = "/api/v1/order"
|
pathPlayerOrder = "/api/v1/order"
|
||||||
pathPlayerReport = "/api/v1/report"
|
pathPlayerReport = "/api/v1/report"
|
||||||
|
pathPlayerBattle = "/api/v1/battle"
|
||||||
pathHealthz = "/healthz"
|
pathHealthz = "/healthz"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -269,6 +270,41 @@ func (c *Client) GetReport(ctx context.Context, baseURL, raceName string, turn i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchBattle calls `GET /api/v1/battle/<turn>/<battleID>` and returns
|
||||||
|
// the engine response body verbatim alongside the engine status code.
|
||||||
|
// 200 carries the BattleReport JSON; 404 means the battle is unknown
|
||||||
|
// and the body may be empty. Other 4xx statuses come back wrapped in
|
||||||
|
// ErrEngineValidation, everything else in ErrEngineUnreachable.
|
||||||
|
func (c *Client) FetchBattle(ctx context.Context, baseURL string, turn int, battleID string) (json.RawMessage, int, error) {
|
||||||
|
if err := validateBaseURL(baseURL); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if turn < 0 {
|
||||||
|
return nil, 0, fmt.Errorf("engineclient battle get: turn must not be negative, got %d", turn)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(battleID) == "" {
|
||||||
|
return nil, 0, errors.New("engineclient battle get: battle id must not be empty")
|
||||||
|
}
|
||||||
|
target := baseURL + pathPlayerBattle + "/" + strconv.Itoa(turn) + "/" + url.PathEscape(battleID)
|
||||||
|
body, status, doErr := c.doRequest(ctx, http.MethodGet, target, nil, c.probeTimeout)
|
||||||
|
if doErr != nil {
|
||||||
|
return nil, 0, fmt.Errorf("%w: engine battle get: %w", ErrEngineUnreachable, doErr)
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case http.StatusOK:
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil, status, fmt.Errorf("%w: engine battle get: empty response body", ErrEngineProtocolViolation)
|
||||||
|
}
|
||||||
|
return json.RawMessage(body), status, nil
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return nil, status, nil
|
||||||
|
case http.StatusBadRequest, http.StatusConflict:
|
||||||
|
return json.RawMessage(body), status, fmt.Errorf("%w: engine battle get: %s", ErrEngineValidation, summariseEngineError(body, status))
|
||||||
|
default:
|
||||||
|
return nil, status, fmt.Errorf("%w: engine battle get: %s", ErrEngineUnreachable, summariseEngineError(body, status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Healthz calls `GET /healthz`. Returns nil on 2xx.
|
// Healthz calls `GET /healthz`. Returns nil on 2xx.
|
||||||
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
|
func (c *Client) Healthz(ctx context.Context, baseURL string) error {
|
||||||
if err := validateBaseURL(baseURL); err != nil {
|
if err := validateBaseURL(baseURL); err != nil {
|
||||||
|
|||||||
@@ -257,6 +257,63 @@ func TestClientGetOrderRejectsBadInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClientFetchBattleForwardsPath(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("unexpected method: %s", r.Method)
|
||||||
|
}
|
||||||
|
want := pathPlayerBattle + "/3/" + "11111111-1111-1111-1111-111111111111"
|
||||||
|
if r.URL.Path != want {
|
||||||
|
t.Fatalf("path = %q, want %q", r.URL.Path, want)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"id":"11111111-1111-1111-1111-111111111111","planet":4}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cli := newTestClient(t, srv)
|
||||||
|
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 3, "11111111-1111-1111-1111-111111111111")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBattle: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", status)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), `"planet":4`) {
|
||||||
|
t.Fatalf("body = %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFetchBattleNotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
cli := newTestClient(t, srv)
|
||||||
|
body, status, err := cli.FetchBattle(context.Background(), srv.URL, 0, "11111111-1111-1111-1111-111111111111")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchBattle: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d", status)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
t.Fatalf("expected nil body on 404, got %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientFetchBattleRejectsBadInput(t *testing.T) {
|
||||||
|
cli := newTestClient(t, httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatal("server must not be hit on bad input")
|
||||||
|
})))
|
||||||
|
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", -1, "11111111-1111-1111-1111-111111111111"); err == nil {
|
||||||
|
t.Fatal("expected error on negative turn")
|
||||||
|
}
|
||||||
|
if _, _, err := cli.FetchBattle(context.Background(), "http://example.com", 0, ""); err == nil {
|
||||||
|
t.Fatal("expected error on empty battle id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestClientHealthzSuccess(t *testing.T) {
|
func TestClientHealthzSuccess(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != pathHealthz {
|
if r.URL.Path != pathHealthz {
|
||||||
|
|||||||
@@ -109,6 +109,8 @@ const (
|
|||||||
NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
|
NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
|
||||||
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
|
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
|
||||||
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
|
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
|
||||||
|
NotificationGameTurnReady = "game.turn.ready"
|
||||||
|
NotificationGamePaused = "game.paused"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deps aggregates every collaborator the lobby Service depends on.
|
// Deps aggregates every collaborator the lobby Service depends on.
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
prevTurn := game.RuntimeSnapshot.CurrentTurn
|
||||||
merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot)
|
merged := mergeRuntimeSnapshot(game.RuntimeSnapshot, snapshot)
|
||||||
now := s.deps.Now().UTC()
|
now := s.deps.Now().UTC()
|
||||||
updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now)
|
updated, err := s.deps.Store.UpdateGameRuntimeSnapshot(ctx, gameID, merged, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
transitionedToPaused := false
|
||||||
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
|
if next, transition := nextStatusFromSnapshot(updated.Status, snapshot); transition {
|
||||||
switch next {
|
switch next {
|
||||||
case GameStatusFinished:
|
case GameStatusFinished:
|
||||||
@@ -52,12 +54,115 @@ func (s *Service) OnRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snaps
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
updated = rec
|
updated = rec
|
||||||
|
if next == GameStatusPaused {
|
||||||
|
transitionedToPaused = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.deps.Cache.PutGame(updated)
|
s.deps.Cache.PutGame(updated)
|
||||||
|
if merged.CurrentTurn > prevTurn {
|
||||||
|
s.publishTurnReady(ctx, gameID, merged.CurrentTurn)
|
||||||
|
}
|
||||||
|
if transitionedToPaused {
|
||||||
|
s.publishGamePaused(ctx, gameID, merged.CurrentTurn, snapshot.RuntimeStatus)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishTurnReady fans out a `game.turn.ready` notification to every
|
||||||
|
// active member of the game once the engine reports a new
|
||||||
|
// `current_turn`. The intent is best-effort: a publisher failure is
|
||||||
|
// logged at warn level (matching the rest of OnRuntimeSnapshot's
|
||||||
|
// notification calls) and does not abort the snapshot bookkeeping.
|
||||||
|
// Idempotency is anchored on (game_id, turn), so a duplicate snapshot
|
||||||
|
// for the same turn collapses into a single notification at the
|
||||||
|
// notification.Submit boundary.
|
||||||
|
func (s *Service) publishTurnReady(ctx context.Context, gameID uuid.UUID, turn int32) {
|
||||||
|
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Logger.Warn("turn-ready notification: list memberships failed",
|
||||||
|
zap.String("game_id", gameID.String()),
|
||||||
|
zap.Int32("turn", turn),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipients := make([]uuid.UUID, 0, len(memberships))
|
||||||
|
for _, m := range memberships {
|
||||||
|
if m.Status != MembershipStatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recipients = append(recipients, m.UserID)
|
||||||
|
}
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
intent := LobbyNotification{
|
||||||
|
Kind: NotificationGameTurnReady,
|
||||||
|
IdempotencyKey: fmt.Sprintf("turn-ready:%s:%d", gameID, turn),
|
||||||
|
Recipients: recipients,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"turn": turn,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
|
||||||
|
s.deps.Logger.Warn("turn-ready notification failed",
|
||||||
|
zap.String("game_id", gameID.String()),
|
||||||
|
zap.Int32("turn", turn),
|
||||||
|
zap.Error(pubErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishGamePaused fans out a `game.paused` notification to every
|
||||||
|
// active member of the game when the lobby flips the game to
|
||||||
|
// `paused` in reaction to a runtime snapshot (typically a failed
|
||||||
|
// turn generation). The intent is best-effort: a publisher failure
|
||||||
|
// is logged at warn level and does not abort the snapshot
|
||||||
|
// bookkeeping. Idempotency is anchored on (game_id, turn) so a
|
||||||
|
// repeated `generation_failed` snapshot for the same turn collapses
|
||||||
|
// into a single notification at the notification.Submit boundary.
|
||||||
|
//
|
||||||
|
// reason carries the raw runtime status that triggered the pause
|
||||||
|
// (`engine_unreachable` / `generation_failed`); the UI displays a
|
||||||
|
// status-agnostic banner today but the payload is preserved so a
|
||||||
|
// future revision of the order tab can differentiate.
|
||||||
|
func (s *Service) publishGamePaused(ctx context.Context, gameID uuid.UUID, turn int32, reason string) {
|
||||||
|
memberships, err := s.deps.Store.ListMembershipsForGame(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
s.deps.Logger.Warn("game-paused notification: list memberships failed",
|
||||||
|
zap.String("game_id", gameID.String()),
|
||||||
|
zap.Int32("turn", turn),
|
||||||
|
zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recipients := make([]uuid.UUID, 0, len(memberships))
|
||||||
|
for _, m := range memberships {
|
||||||
|
if m.Status != MembershipStatusActive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
recipients = append(recipients, m.UserID)
|
||||||
|
}
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
intent := LobbyNotification{
|
||||||
|
Kind: NotificationGamePaused,
|
||||||
|
IdempotencyKey: fmt.Sprintf("paused:%s:%d", gameID, turn),
|
||||||
|
Recipients: recipients,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"turn": turn,
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if pubErr := s.deps.Notification.PublishLobbyEvent(ctx, intent); pubErr != nil {
|
||||||
|
s.deps.Logger.Warn("game-paused notification failed",
|
||||||
|
zap.String("game_id", gameID.String()),
|
||||||
|
zap.Int32("turn", turn),
|
||||||
|
zap.Error(pubErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OnGameFinished completes the game lifecycle: marks the game as
|
// OnGameFinished completes the game lifecycle: marks the game as
|
||||||
// `finished`, evaluates capable-finish per active member, and
|
// `finished`, evaluates capable-finish per active member, and
|
||||||
// transitions reservation rows to either `pending_registration`
|
// transitions reservation rows to either `pending_registration`
|
||||||
@@ -230,13 +335,28 @@ func mergeRuntimeSnapshot(prev, next RuntimeSnapshot) RuntimeSnapshot {
|
|||||||
// nextStatusFromSnapshot maps the runtime-reported runtime status into
|
// nextStatusFromSnapshot maps the runtime-reported runtime status into
|
||||||
// a lobby status transition. Returns (next, true) when the lobby
|
// a lobby status transition. Returns (next, true) when the lobby
|
||||||
// status must change; (current, false) otherwise.
|
// status must change; (current, false) otherwise.
|
||||||
|
//
|
||||||
|
// The map intentionally distinguishes the pre-running boot path
|
||||||
|
// (`starting → start_failed`) from the in-flight failure path
|
||||||
|
// (`running → paused`). Paused games can be resumed by the admin via
|
||||||
|
// the explicit `/resume` transition; the runtime keeps the engine
|
||||||
|
// container alive, the scheduler short-circuits ticks while paused,
|
||||||
|
// and any user-games command/order is rejected by the order handler
|
||||||
|
// with `turn_already_closed` until the game resumes.
|
||||||
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
|
func nextStatusFromSnapshot(currentStatus string, snapshot RuntimeSnapshot) (string, bool) {
|
||||||
switch snapshot.RuntimeStatus {
|
switch snapshot.RuntimeStatus {
|
||||||
case "running":
|
case "running":
|
||||||
if currentStatus == GameStatusStarting {
|
if currentStatus == GameStatusStarting {
|
||||||
return GameStatusRunning, true
|
return GameStatusRunning, true
|
||||||
}
|
}
|
||||||
case "engine_unreachable", "start_failed", "generation_failed":
|
case "engine_unreachable", "generation_failed":
|
||||||
|
if currentStatus == GameStatusStarting {
|
||||||
|
return GameStatusStartFailed, true
|
||||||
|
}
|
||||||
|
if currentStatus == GameStatusRunning {
|
||||||
|
return GameStatusPaused, true
|
||||||
|
}
|
||||||
|
case "start_failed":
|
||||||
if currentStatus == GameStatusStarting {
|
if currentStatus == GameStatusStarting {
|
||||||
return GameStatusStartFailed, true
|
return GameStatusStartFailed, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package lobby_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galaxy/backend/internal/config"
|
||||||
|
"galaxy/backend/internal/lobby"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// capturingPublisher records every `LobbyNotification` intent that the
|
||||||
|
// lobby service emits, so a test can assert the producer side without
|
||||||
|
// running the real notification.Submit pipeline.
|
||||||
|
type capturingPublisher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items []lobby.LobbyNotification
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *capturingPublisher) PublishLobbyEvent(_ context.Context, ev lobby.LobbyNotification) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.items = append(p.items, ev)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *capturingPublisher) byKind(kind string) []lobby.LobbyNotification {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := make([]lobby.LobbyNotification, 0, len(p.items))
|
||||||
|
for _, ev := range p.items {
|
||||||
|
if ev.Kind == kind {
|
||||||
|
out = append(out, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// newServiceWithPublisher mirrors `newServiceForTest` but lets the
|
||||||
|
// caller inject a custom NotificationPublisher; the runtime-hooks
|
||||||
|
// emit path needs to observe intents directly.
|
||||||
|
func newServiceWithPublisher(t *testing.T, db *sql.DB, now func() time.Time, max int32, publisher lobby.NotificationPublisher) *lobby.Service {
|
||||||
|
t.Helper()
|
||||||
|
store := lobby.NewStore(db)
|
||||||
|
cache := lobby.NewCache()
|
||||||
|
if err := cache.Warm(context.Background(), store); err != nil {
|
||||||
|
t.Fatalf("warm cache: %v", err)
|
||||||
|
}
|
||||||
|
svc, err := lobby.NewService(lobby.Deps{
|
||||||
|
Store: store,
|
||||||
|
Cache: cache,
|
||||||
|
Notification: publisher,
|
||||||
|
Entitlement: stubEntitlement{max: max},
|
||||||
|
Config: config.LobbyConfig{
|
||||||
|
SweeperInterval: time.Second,
|
||||||
|
PendingRegistrationTTL: time.Hour,
|
||||||
|
InviteDefaultTTL: time.Hour,
|
||||||
|
},
|
||||||
|
Now: now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new service: %v", err)
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOnRuntimeSnapshotEmitsTurnReady verifies that an engine snapshot
|
||||||
|
// advancing `current_turn` fans out a `game.turn.ready` intent to every
|
||||||
|
// active member, that the idempotency key is anchored on (game_id, turn),
|
||||||
|
// and that a snapshot with the same turn does not re-emit.
|
||||||
|
func TestOnRuntimeSnapshotEmitsTurnReady(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
clock := func() time.Time { return now }
|
||||||
|
publisher := &capturingPublisher{}
|
||||||
|
svc := newServiceWithPublisher(t, db, clock, 5, publisher)
|
||||||
|
|
||||||
|
owner := uuid.New()
|
||||||
|
seedAccount(t, db, owner)
|
||||||
|
|
||||||
|
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
|
||||||
|
OwnerUserID: &owner,
|
||||||
|
Visibility: lobby.VisibilityPrivate,
|
||||||
|
GameName: "Turn-Ready Fan-Out",
|
||||||
|
MinPlayers: 1,
|
||||||
|
MaxPlayers: 4,
|
||||||
|
StartGapHours: 1,
|
||||||
|
StartGapPlayers: 1,
|
||||||
|
EnrollmentEndsAt: now.Add(time.Hour),
|
||||||
|
TurnSchedule: "0 0 * * *",
|
||||||
|
TargetEngineVersion: "1.0.0",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create game: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
|
||||||
|
t.Fatalf("open enrollment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed two active members through the store so the test focuses on
|
||||||
|
// the runtime hook, not the membership state machine.
|
||||||
|
store := lobby.NewStore(db)
|
||||||
|
canonicalPolicy, err := lobby.NewPolicy()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new policy: %v", err)
|
||||||
|
}
|
||||||
|
memberA := uuid.New()
|
||||||
|
memberB := uuid.New()
|
||||||
|
seedAccount(t, db, memberA)
|
||||||
|
seedAccount(t, db, memberB)
|
||||||
|
for i, m := range []uuid.UUID{memberA, memberB} {
|
||||||
|
race := fmt.Sprintf("Race%d", i+1)
|
||||||
|
canonical, err := canonicalPolicy.Canonical(race)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonical %q: %v", race, err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO backend.memberships (
|
||||||
|
membership_id, game_id, user_id, race_name, canonical_key, status
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, 'active')
|
||||||
|
`, uuid.New(), game.GameID, m, race, string(canonical)); err != nil {
|
||||||
|
t.Fatalf("seed membership %s: %v", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := svc.Cache().Warm(context.Background(), store); err != nil {
|
||||||
|
t.Fatalf("re-warm cache: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ReadyToStart(context.Background(), &owner, false, game.GameID); err != nil {
|
||||||
|
t.Fatalf("ready-to-start: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Start(context.Background(), &owner, false, game.GameID); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First snapshot: prev=0, current_turn=1 → emit on the very first
|
||||||
|
// turn after the engine starts producing.
|
||||||
|
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||||
|
CurrentTurn: 1,
|
||||||
|
RuntimeStatus: "running",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("on-runtime-snapshot 1: %v", err)
|
||||||
|
}
|
||||||
|
intents := publisher.byKind(lobby.NotificationGameTurnReady)
|
||||||
|
if len(intents) != 1 {
|
||||||
|
t.Fatalf("after turn 1 want 1 turn-ready intent, got %d", len(intents))
|
||||||
|
}
|
||||||
|
first := intents[0]
|
||||||
|
wantKey := fmt.Sprintf("turn-ready:%s:1", game.GameID)
|
||||||
|
if first.IdempotencyKey != wantKey {
|
||||||
|
t.Errorf("turn 1 idempotency key = %q, want %q", first.IdempotencyKey, wantKey)
|
||||||
|
}
|
||||||
|
if got := first.Payload["turn"]; got != int32(1) {
|
||||||
|
t.Errorf("turn 1 payload turn = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got := first.Payload["game_id"]; got != game.GameID.String() {
|
||||||
|
t.Errorf("turn 1 payload game_id = %v, want %s", got, game.GameID)
|
||||||
|
}
|
||||||
|
if len(first.Recipients) != 2 {
|
||||||
|
t.Errorf("turn 1 recipients = %d, want 2", len(first.Recipients))
|
||||||
|
}
|
||||||
|
recipientSet := map[uuid.UUID]struct{}{}
|
||||||
|
for _, r := range first.Recipients {
|
||||||
|
recipientSet[r] = struct{}{}
|
||||||
|
}
|
||||||
|
if _, ok := recipientSet[memberA]; !ok {
|
||||||
|
t.Errorf("turn 1 missing memberA in recipients")
|
||||||
|
}
|
||||||
|
if _, ok := recipientSet[memberB]; !ok {
|
||||||
|
t.Errorf("turn 1 missing memberB in recipients")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same turn re-delivered (duplicate snapshot, gateway replay) must
|
||||||
|
// not re-emit at the lobby layer: prev catches up to merged.
|
||||||
|
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||||
|
CurrentTurn: 1,
|
||||||
|
RuntimeStatus: "running",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("on-runtime-snapshot 1 replay: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(publisher.byKind(lobby.NotificationGameTurnReady)); got != 1 {
|
||||||
|
t.Fatalf("after duplicate turn 1 want 1 intent, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next turn advances → second emit with key anchored on turn 2.
|
||||||
|
if err := svc.OnRuntimeSnapshot(context.Background(), game.GameID, lobby.RuntimeSnapshot{
|
||||||
|
CurrentTurn: 2,
|
||||||
|
RuntimeStatus: "running",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("on-runtime-snapshot 2: %v", err)
|
||||||
|
}
|
||||||
|
intents = publisher.byKind(lobby.NotificationGameTurnReady)
|
||||||
|
if len(intents) != 2 {
|
||||||
|
t.Fatalf("after turn 2 want 2 turn-ready intents, got %d", len(intents))
|
||||||
|
}
|
||||||
|
wantKey2 := fmt.Sprintf("turn-ready:%s:2", game.GameID)
|
||||||
|
if intents[1].IdempotencyKey != wantKey2 {
|
||||||
|
t.Errorf("turn 2 idempotency key = %q, want %q", intents[1].IdempotencyKey, wantKey2)
|
||||||
|
}
|
||||||
|
if got := intents[1].Payload["turn"]; got != int32(2) {
|
||||||
|
t.Errorf("turn 2 payload turn = %v, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package lobby
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestNextStatusFromSnapshot covers the pure status-mapping function
|
||||||
|
// that drives `OnRuntimeSnapshot`'s lifecycle transitions. The Phase
|
||||||
|
// 25 contribution is the `running → paused` branch on
|
||||||
|
// `engine_unreachable` / `generation_failed`: the order handler relies
|
||||||
|
// on the `paused` game status to reject late submits with
|
||||||
|
// `turn_already_closed`.
|
||||||
|
func TestNextStatusFromSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
currentStatus string
|
||||||
|
runtimeStatus string
|
||||||
|
wantStatus string
|
||||||
|
wantTransit bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "starting then running flips to running",
|
||||||
|
currentStatus: GameStatusStarting,
|
||||||
|
runtimeStatus: "running",
|
||||||
|
wantStatus: GameStatusRunning,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running on running snapshot does not transit",
|
||||||
|
currentStatus: GameStatusRunning,
|
||||||
|
runtimeStatus: "running",
|
||||||
|
wantStatus: GameStatusRunning,
|
||||||
|
wantTransit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starting then engine_unreachable flips to start_failed",
|
||||||
|
currentStatus: GameStatusStarting,
|
||||||
|
runtimeStatus: "engine_unreachable",
|
||||||
|
wantStatus: GameStatusStartFailed,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starting then generation_failed flips to start_failed",
|
||||||
|
currentStatus: GameStatusStarting,
|
||||||
|
runtimeStatus: "generation_failed",
|
||||||
|
wantStatus: GameStatusStartFailed,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running then engine_unreachable flips to paused",
|
||||||
|
currentStatus: GameStatusRunning,
|
||||||
|
runtimeStatus: "engine_unreachable",
|
||||||
|
wantStatus: GameStatusPaused,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running then generation_failed flips to paused",
|
||||||
|
currentStatus: GameStatusRunning,
|
||||||
|
runtimeStatus: "generation_failed",
|
||||||
|
wantStatus: GameStatusPaused,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paused stays paused on repeated failed snapshot",
|
||||||
|
currentStatus: GameStatusPaused,
|
||||||
|
runtimeStatus: "generation_failed",
|
||||||
|
wantStatus: GameStatusPaused,
|
||||||
|
wantTransit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starting then start_failed flips to start_failed",
|
||||||
|
currentStatus: GameStatusStarting,
|
||||||
|
runtimeStatus: "start_failed",
|
||||||
|
wantStatus: GameStatusStartFailed,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running ignores start_failed",
|
||||||
|
currentStatus: GameStatusRunning,
|
||||||
|
runtimeStatus: "start_failed",
|
||||||
|
wantStatus: GameStatusRunning,
|
||||||
|
wantTransit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running on finished flips to finished",
|
||||||
|
currentStatus: GameStatusRunning,
|
||||||
|
runtimeStatus: "finished",
|
||||||
|
wantStatus: GameStatusFinished,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "finished stays finished on finished snapshot",
|
||||||
|
currentStatus: GameStatusFinished,
|
||||||
|
runtimeStatus: "finished",
|
||||||
|
wantStatus: GameStatusFinished,
|
||||||
|
wantTransit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled stays cancelled on finished snapshot",
|
||||||
|
currentStatus: GameStatusCancelled,
|
||||||
|
runtimeStatus: "finished",
|
||||||
|
wantStatus: GameStatusCancelled,
|
||||||
|
wantTransit: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paused on stopped snapshot flips to finished",
|
||||||
|
currentStatus: GameStatusPaused,
|
||||||
|
runtimeStatus: "stopped",
|
||||||
|
wantStatus: GameStatusFinished,
|
||||||
|
wantTransit: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, transit := nextStatusFromSnapshot(tt.currentStatus, RuntimeSnapshot{
|
||||||
|
RuntimeStatus: tt.runtimeStatus,
|
||||||
|
})
|
||||||
|
if got != tt.wantStatus {
|
||||||
|
t.Errorf("status = %q, want %q", got, tt.wantStatus)
|
||||||
|
}
|
||||||
|
if transit != tt.wantTransit {
|
||||||
|
t.Errorf("transit = %v, want %v", transit, tt.wantTransit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const (
|
|||||||
KindRuntimeImagePullFailed = "runtime.image_pull_failed"
|
KindRuntimeImagePullFailed = "runtime.image_pull_failed"
|
||||||
KindRuntimeContainerStartFailed = "runtime.container_start_failed"
|
KindRuntimeContainerStartFailed = "runtime.container_start_failed"
|
||||||
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
KindRuntimeStartConfigInvalid = "runtime.start_config_invalid"
|
||||||
|
KindGameTurnReady = "game.turn.ready"
|
||||||
|
KindGamePaused = "game.paused"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CatalogEntry describes the per-kind delivery policy: which channels
|
// CatalogEntry describes the per-kind delivery policy: which channels
|
||||||
@@ -95,6 +97,12 @@ var catalog = map[string]CatalogEntry{
|
|||||||
Admin: true,
|
Admin: true,
|
||||||
MailTemplateID: KindRuntimeStartConfigInvalid,
|
MailTemplateID: KindRuntimeStartConfigInvalid,
|
||||||
},
|
},
|
||||||
|
KindGameTurnReady: {
|
||||||
|
Channels: []string{ChannelPush},
|
||||||
|
},
|
||||||
|
KindGamePaused: {
|
||||||
|
Channels: []string{ChannelPush},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupCatalog returns the per-kind policy and a boolean reporting
|
// LookupCatalog returns the per-kind policy and a boolean reporting
|
||||||
@@ -123,5 +131,7 @@ func SupportedKinds() []string {
|
|||||||
KindRuntimeImagePullFailed,
|
KindRuntimeImagePullFailed,
|
||||||
KindRuntimeContainerStartFailed,
|
KindRuntimeContainerStartFailed,
|
||||||
KindRuntimeStartConfigInvalid,
|
KindRuntimeStartConfigInvalid,
|
||||||
|
KindGameTurnReady,
|
||||||
|
KindGamePaused,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ func TestCatalogChannels(t *testing.T) {
|
|||||||
KindRuntimeImagePullFailed: {ChannelEmail},
|
KindRuntimeImagePullFailed: {ChannelEmail},
|
||||||
KindRuntimeContainerStartFailed: {ChannelEmail},
|
KindRuntimeContainerStartFailed: {ChannelEmail},
|
||||||
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
KindRuntimeStartConfigInvalid: {ChannelEmail},
|
||||||
|
KindGameTurnReady: {ChannelPush},
|
||||||
|
KindGamePaused: {ChannelPush},
|
||||||
}
|
}
|
||||||
for kind, want := range expect {
|
for kind, want := range expect {
|
||||||
entry, ok := LookupCatalog(kind)
|
entry, ok := LookupCatalog(kind)
|
||||||
|
|||||||
@@ -9,9 +9,31 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// jsonFriendlyKinds lists catalog kinds whose payload is small and
|
||||||
|
// stable enough that the gateway-bound encoding stays JSON instead of
|
||||||
|
// FlatBuffers. The default for new producers is still FB; declaring a
|
||||||
|
// kind here is a deliberate decision baked into the build target's
|
||||||
|
// payload contract.
|
||||||
|
//
|
||||||
|
// `game.turn.ready` ships `{game_id, turn}` only, the UI parses it
|
||||||
|
// inline in `routes/games/[id]/+layout.svelte` (Phase 24), and no
|
||||||
|
// other consumer reads the payload — adopting the FB encoder would
|
||||||
|
// require a new TS notification stub set and the regen tooling for
|
||||||
|
// `pkg/schema/fbs/notification.fbs` without buying anything.
|
||||||
|
//
|
||||||
|
// `game.paused` (Phase 25) follows the same JSON-friendly contract:
|
||||||
|
// payload is `{game_id, turn, reason}` consumed by the same in-game
|
||||||
|
// shell layout, so there is no value in dragging a FB schema in for
|
||||||
|
// one consumer.
|
||||||
|
var jsonFriendlyKinds = map[string]bool{
|
||||||
|
KindGameTurnReady: true,
|
||||||
|
KindGamePaused: true,
|
||||||
|
}
|
||||||
|
|
||||||
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
// TestBuildClientPushEventCoversCatalog asserts that every catalog kind
|
||||||
// returns a typed FB event (preMarshaledEvent) and that an unknown kind
|
// is exercised by this test, that FB-typed kinds return a
|
||||||
// falls through to the JSON safety net.
|
// `preMarshaledEvent`, and that JSON-friendly kinds (see
|
||||||
|
// `jsonFriendlyKinds` above) return a `push.JSONEvent`.
|
||||||
func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -57,6 +79,15 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
|||||||
"game_id": gameID.String(),
|
"game_id": gameID.String(),
|
||||||
"reason": "missing engine version",
|
"reason": "missing engine version",
|
||||||
}},
|
}},
|
||||||
|
{"game turn ready", KindGameTurnReady, map[string]any{
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"turn": int32(7),
|
||||||
|
}},
|
||||||
|
{"game paused", KindGamePaused, map[string]any{
|
||||||
|
"game_id": gameID.String(),
|
||||||
|
"turn": int32(7),
|
||||||
|
"reason": "generation_failed",
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
seenKinds := map[string]bool{}
|
seenKinds := map[string]bool{}
|
||||||
@@ -78,8 +109,10 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) {
|
|||||||
if len(bytes) == 0 {
|
if len(bytes) == 0 {
|
||||||
t.Fatalf("Marshal returned empty bytes")
|
t.Fatalf("Marshal returned empty bytes")
|
||||||
}
|
}
|
||||||
if _, isJSON := event.(push.JSONEvent); isJSON {
|
_, isJSON := event.(push.JSONEvent)
|
||||||
t.Fatalf("expected typed FB event for %s, got JSONEvent", tt.kind)
|
wantJSON := jsonFriendlyKinds[tt.kind]
|
||||||
|
if isJSON != wantJSON {
|
||||||
|
t.Fatalf("kind %s: JSONEvent=%v, want JSONEvent=%v", tt.kind, isJSON, wantJSON)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
seenKinds[tt.kind] = true
|
seenKinds[tt.kind] = true
|
||||||
|
|||||||
@@ -605,7 +605,8 @@ CREATE TABLE notifications (
|
|||||||
'lobby.race_name.registered', 'lobby.race_name.pending',
|
'lobby.race_name.registered', 'lobby.race_name.pending',
|
||||||
'lobby.race_name.expired',
|
'lobby.race_name.expired',
|
||||||
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
'runtime.image_pull_failed', 'runtime.container_start_failed',
|
||||||
'runtime.start_config_invalid'
|
'runtime.start_config_invalid',
|
||||||
|
'game.turn.ready', 'game.paused'
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,23 @@ var (
|
|||||||
// ErrShutdown means the runtime service has stopped accepting
|
// ErrShutdown means the runtime service has stopped accepting
|
||||||
// work because the parent context was cancelled.
|
// work because the parent context was cancelled.
|
||||||
ErrShutdown = errors.New("runtime: shutting down")
|
ErrShutdown = errors.New("runtime: shutting down")
|
||||||
|
|
||||||
|
// ErrTurnAlreadyClosed reports that the runtime is currently
|
||||||
|
// producing a turn — runtime status is `generation_in_progress`
|
||||||
|
// — and the engine is not accepting writes for the closing
|
||||||
|
// turn. Handlers map this to HTTP 409 with httperr code
|
||||||
|
// `turn_already_closed`; the UI shows a conflict banner and
|
||||||
|
// waits for the next `game.turn.ready` push.
|
||||||
|
ErrTurnAlreadyClosed = errors.New("runtime: turn already closed")
|
||||||
|
|
||||||
|
// ErrGamePaused reports that the game is not in a state that
|
||||||
|
// accepts user-games commands or orders: the runtime row
|
||||||
|
// carries `paused = true`, or the runtime status lands on any
|
||||||
|
// terminal value (`engine_unreachable`, `generation_failed`,
|
||||||
|
// `stopped`, `finished`, `removed`), or the game has not yet
|
||||||
|
// finished bootstrapping (`starting`). Handlers map this to
|
||||||
|
// HTTP 409 with httperr code `game_paused`; the UI surfaces a
|
||||||
|
// pause banner and waits for an admin resume or a fresh
|
||||||
|
// snapshot.
|
||||||
|
ErrGamePaused = errors.New("runtime: game paused")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestOrdersAcceptStatus pins down the Phase 25 pre-check that
|
||||||
|
// gates the user-games command/order handlers against the runtime
|
||||||
|
// record. The decision must distinguish a turn cutoff (engine is
|
||||||
|
// producing) from a paused game so the UI can surface the right
|
||||||
|
// banner; all other non-running runtime statuses collapse into
|
||||||
|
// `ErrGamePaused`.
|
||||||
|
func TestOrdersAcceptStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rec RuntimeRecord
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "running and not paused accepts orders",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: false},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "running but paused returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusRunning, Paused: true},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generation in progress returns turn already closed",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress},
|
||||||
|
want: ErrTurnAlreadyClosed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generation failed returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusGenerationFailed},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "engine unreachable returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusEngineUnreachable},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stopped returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusStopped},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "finished returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusFinished},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "removed returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusRemoved},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starting returns game paused",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusStarting},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "paused takes precedence over generation in progress",
|
||||||
|
rec: RuntimeRecord{Status: RuntimeStatusGenerationInProgress, Paused: true},
|
||||||
|
want: ErrGamePaused,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := OrdersAcceptStatus(tt.rec)
|
||||||
|
if !errors.Is(got, tt.want) {
|
||||||
|
t.Errorf("OrdersAcceptStatus = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"galaxy/backend/internal/dockerclient"
|
"galaxy/backend/internal/dockerclient"
|
||||||
|
"galaxy/backend/internal/engineclient"
|
||||||
"galaxy/cronutil"
|
"galaxy/cronutil"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -213,6 +214,22 @@ func (sch *Scheduler) loop(ctx context.Context, rec RuntimeRecord, done chan str
|
|||||||
|
|
||||||
// tick runs one engine /admin/turn call under the per-game mutex,
|
// tick runs one engine /admin/turn call under the per-game mutex,
|
||||||
// publishes the resulting snapshot, and clears `skip_next_tick`.
|
// publishes the resulting snapshot, and clears `skip_next_tick`.
|
||||||
|
//
|
||||||
|
// Phase 25 wraps the engine call between two runtime-status flips so
|
||||||
|
// the backend order handler can reject late submits while the engine
|
||||||
|
// is producing:
|
||||||
|
//
|
||||||
|
// - before `Engine.Turn`: runtime status moves to
|
||||||
|
// `generation_in_progress`; the loop's running-only guard tolerates
|
||||||
|
// this because the flip back happens inside the same tick.
|
||||||
|
// - on success: runtime status moves back to `running` (unless the
|
||||||
|
// engine reports `finished`, in which case `publishSnapshot` has
|
||||||
|
// already promoted the row to `finished`).
|
||||||
|
// - on error: runtime status moves to `generation_failed` (engine
|
||||||
|
// validation failure) or `engine_unreachable` (transport / 5xx).
|
||||||
|
// The matching snapshot is forwarded to lobby through
|
||||||
|
// `publishFailureSnapshot` so lobby can flip the game to `paused`
|
||||||
|
// and emit `game.paused`.
|
||||||
func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
||||||
mu := sch.svc.gameLock(rec.GameID)
|
mu := sch.svc.gameLock(rec.GameID)
|
||||||
if !mu.TryLock() {
|
if !mu.TryLock() {
|
||||||
@@ -224,10 +241,24 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusGenerationInProgress, ""); err != nil {
|
||||||
|
sch.svc.completeOperation(ctx, op, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint)
|
state, err := sch.svc.deps.Engine.Turn(ctx, rec.EngineEndpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sch.svc.completeOperation(ctx, op, err)
|
sch.svc.completeOperation(ctx, op, err)
|
||||||
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusEngineUnreachable, "")
|
failureStatus := RuntimeStatusEngineUnreachable
|
||||||
|
if errors.Is(err, engineclient.ErrEngineValidation) {
|
||||||
|
failureStatus = RuntimeStatusGenerationFailed
|
||||||
|
}
|
||||||
|
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, failureStatus, "down")
|
||||||
|
if pubErr := sch.svc.publishFailureSnapshot(ctx, rec.GameID, failureStatus); pubErr != nil {
|
||||||
|
sch.svc.deps.Logger.Warn("publish failure snapshot to lobby",
|
||||||
|
zap.String("game_id", rec.GameID.String()),
|
||||||
|
zap.String("runtime_status", failureStatus),
|
||||||
|
zap.Error(pubErr))
|
||||||
|
}
|
||||||
// On engine unreachable, also clear skip_next_tick so the next
|
// On engine unreachable, also clear skip_next_tick so the next
|
||||||
// real tick can start fresh.
|
// real tick can start fresh.
|
||||||
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
||||||
@@ -244,6 +275,12 @@ func (sch *Scheduler) tick(ctx context.Context, rec RuntimeRecord) error {
|
|||||||
sch.svc.completeOperation(ctx, op, err)
|
sch.svc.completeOperation(ctx, op, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if !state.Finished {
|
||||||
|
// `publishSnapshot` patches CurrentTurn / EngineHealth but does
|
||||||
|
// not reset the status column; reopen the orders window here so
|
||||||
|
// the next loop iteration finds the runtime back in `running`.
|
||||||
|
_, _ = sch.svc.transitionRuntimeStatus(ctx, rec.GameID, RuntimeStatusRunning, "ok")
|
||||||
|
}
|
||||||
sch.svc.completeOperation(ctx, op, nil)
|
sch.svc.completeOperation(ctx, op, nil)
|
||||||
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
_ = sch.clearSkipFlag(ctx, rec.GameID)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -257,6 +257,57 @@ func (s *Service) ResolvePlayerMapping(ctx context.Context, gameID, userID uuid.
|
|||||||
return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID)
|
return s.deps.Store.LoadPlayerMapping(ctx, gameID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CheckOrdersAccept verifies that the runtime is in a state that
|
||||||
|
// accepts user-games commands and orders. It is called by the user
|
||||||
|
// game-proxy handlers (`Commands`, `Orders`) before forwarding to
|
||||||
|
// engine, so the backend's turn-cutoff and pause guards run before
|
||||||
|
// network traffic leaves the host. The decision itself lives in the
|
||||||
|
// pure helper `OrdersAcceptStatus` so it can be unit-tested without
|
||||||
|
// constructing a full Service.
|
||||||
|
//
|
||||||
|
// A missing runtime row is surfaced as `ErrNotFound` so the handler
|
||||||
|
// keeps its existing 404 behaviour.
|
||||||
|
func (s *Service) CheckOrdersAccept(ctx context.Context, gameID uuid.UUID) error {
|
||||||
|
rec, err := s.GetRuntime(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return OrdersAcceptStatus(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrdersAcceptStatus inspects a runtime record and returns the
|
||||||
|
// matching sentinel for the user-games order/command pre-check:
|
||||||
|
//
|
||||||
|
// - `runtime_status = generation_in_progress` → `ErrTurnAlreadyClosed`.
|
||||||
|
// The cron-driven `Scheduler.tick` has flipped the row before
|
||||||
|
// calling the engine. The order window reopens once the tick
|
||||||
|
// completes successfully.
|
||||||
|
//
|
||||||
|
// - `runtime_status ∈ {engine_unreachable, generation_failed,
|
||||||
|
// stopped, finished, removed, starting}` → `ErrGamePaused`.
|
||||||
|
// The game is not in a state that accepts writes; the lobby
|
||||||
|
// state machine has either already flipped the game to
|
||||||
|
// `paused` / `finished` or is still bootstrapping.
|
||||||
|
//
|
||||||
|
// - `runtime.Paused = true` → `ErrGamePaused`. The lobby admin
|
||||||
|
// paused the game explicitly.
|
||||||
|
//
|
||||||
|
// - `runtime_status = running` and `Paused = false` → nil
|
||||||
|
// (forward).
|
||||||
|
func OrdersAcceptStatus(rec RuntimeRecord) error {
|
||||||
|
if rec.Paused {
|
||||||
|
return ErrGamePaused
|
||||||
|
}
|
||||||
|
switch rec.Status {
|
||||||
|
case RuntimeStatusRunning:
|
||||||
|
return nil
|
||||||
|
case RuntimeStatusGenerationInProgress:
|
||||||
|
return ErrTurnAlreadyClosed
|
||||||
|
default:
|
||||||
|
return ErrGamePaused
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// EngineEndpoint returns the engine endpoint URL for gameID. Used by
|
// EngineEndpoint returns the engine endpoint URL for gameID. Used by
|
||||||
// the user game-proxy handlers.
|
// the user game-proxy handlers.
|
||||||
func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) {
|
func (s *Service) EngineEndpoint(ctx context.Context, gameID uuid.UUID) (string, error) {
|
||||||
@@ -812,6 +863,33 @@ func (s *Service) publishSnapshot(ctx context.Context, gameID uuid.UUID, state r
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishFailureSnapshot forwards a runtime-failure observation to
|
||||||
|
// lobby so the game lifecycle can react (e.g. flipping `running` to
|
||||||
|
// `paused` on `engine_unreachable` / `generation_failed` per Phase
|
||||||
|
// 25). The snapshot carries the unchanged `current_turn` because no
|
||||||
|
// new turn has been produced; lobby uses the turn number to anchor
|
||||||
|
// the `game.paused` idempotency key.
|
||||||
|
//
|
||||||
|
// The call is best-effort: lobby errors are returned to the caller
|
||||||
|
// (the scheduler tick) so the warn-level logging stays in one place.
|
||||||
|
// A missing runtime cache entry (e.g. the row was just removed by
|
||||||
|
// the reconciler) collapses into a silent no-op.
|
||||||
|
func (s *Service) publishFailureSnapshot(ctx context.Context, gameID uuid.UUID, runtimeStatus string) error {
|
||||||
|
if s.deps.Lobby == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rec, ok := s.deps.Cache.GetRuntime(gameID)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.deps.Lobby.OnRuntimeSnapshot(ctx, gameID, LobbySnapshot{
|
||||||
|
CurrentTurn: rec.CurrentTurn,
|
||||||
|
RuntimeStatus: runtimeStatus,
|
||||||
|
EngineHealth: "down",
|
||||||
|
ObservedAt: s.deps.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// transitionRuntimeStatus updates the status / engine_health columns
|
// transitionRuntimeStatus updates the status / engine_health columns
|
||||||
// and refreshes the cache.
|
// and refreshes the cache.
|
||||||
func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) {
|
func (s *Service) transitionRuntimeStatus(ctx context.Context, gameID uuid.UUID, status, health string) (RuntimeRecord, error) {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ var pathParamStubs = map[string]string{
|
|||||||
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
"delivery_id": "00000000-0000-0000-0000-000000000006",
|
||||||
"user_id": "00000000-0000-0000-0000-000000000007",
|
"user_id": "00000000-0000-0000-0000-000000000007",
|
||||||
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
"device_session_id": "00000000-0000-0000-0000-000000000008",
|
||||||
|
"battle_id": "00000000-0000-0000-0000-000000000009",
|
||||||
"id": "1.2.3",
|
"id": "1.2.3",
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"turn": "42",
|
"turn": "42",
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ func (h *UserGamesHandlers) Commands() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
respondGameProxyError(c, h.logger, "user games commands", ctx, err)
|
||||||
@@ -105,6 +109,10 @@ func (h *UserGamesHandlers) Orders() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
if err := h.runtime.CheckOrdersAccept(ctx, gameID); err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
mapping, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
respondGameProxyError(c, h.logger, "user games orders", ctx, err)
|
||||||
@@ -235,6 +243,60 @@ func (h *UserGamesHandlers) Report() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Battle handles GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}.
|
||||||
|
// Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. Path
|
||||||
|
// parameters are validated up-front to save a network hop. 404 from
|
||||||
|
// the engine is forwarded as 404. The recipient race is resolved
|
||||||
|
// from the runtime mapping but not forwarded — engine returns the
|
||||||
|
// battle by id, visibility is enforced by the engine state.
|
||||||
|
func (h *UserGamesHandlers) Battle() gin.HandlerFunc {
|
||||||
|
if h == nil || h.runtime == nil || h.engine == nil {
|
||||||
|
return handlers.NotImplemented("userGamesBattle")
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
gameID, ok := parseGameIDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
turnRaw := c.Param("turn")
|
||||||
|
turn, err := strconv.Atoi(turnRaw)
|
||||||
|
if err != nil || turn < 0 {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "turn must be a non-negative integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
battleID := c.Param("battle_id")
|
||||||
|
if battleID == "" {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "battle id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, ok := userid.FromContext(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "user id missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
if _, err := h.runtime.ResolvePlayerMapping(ctx, gameID, userID); err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endpoint, err := h.runtime.EngineEndpoint(ctx, gameID)
|
||||||
|
if err != nil {
|
||||||
|
respondGameProxyError(c, h.logger, "user games battle", ctx, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, status, err := h.engine.FetchBattle(ctx, endpoint, turn, battleID)
|
||||||
|
if err != nil {
|
||||||
|
respondEngineProxyError(c, h.logger, "user games battle", ctx, body, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "battle not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "application/json", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// rebindActor decodes a JSON object from raw, sets `actor` to
|
// rebindActor decodes a JSON object from raw, sets `actor` to
|
||||||
// raceName, and re-encodes. Backend never trusts the actor field
|
// raceName, and re-encodes. Backend never trusts the actor field
|
||||||
// supplied by the client (per ARCHITECTURE.md §9).
|
// supplied by the client (per ARCHITECTURE.md §9).
|
||||||
@@ -257,6 +319,12 @@ func respondGameProxyError(c *gin.Context, logger *zap.Logger, op string, ctx co
|
|||||||
switch {
|
switch {
|
||||||
case errors.Is(err, runtime.ErrNotFound):
|
case errors.Is(err, runtime.ErrNotFound):
|
||||||
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
|
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "no runtime mapping for this user/game")
|
||||||
|
case errors.Is(err, runtime.ErrTurnAlreadyClosed):
|
||||||
|
httperr.Abort(c, http.StatusConflict, httperr.CodeTurnAlreadyClosed,
|
||||||
|
"turn already closed; orders are not accepted while the engine is producing")
|
||||||
|
case errors.Is(err, runtime.ErrGamePaused):
|
||||||
|
httperr.Abort(c, http.StatusConflict, httperr.CodeGamePaused,
|
||||||
|
"game is paused; orders are not accepted until it resumes")
|
||||||
case errors.Is(err, runtime.ErrConflict):
|
case errors.Is(err, runtime.ErrConflict):
|
||||||
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ const (
|
|||||||
CodeMethodNotAllowed = "method_not_allowed"
|
CodeMethodNotAllowed = "method_not_allowed"
|
||||||
CodeInternalError = "internal_error"
|
CodeInternalError = "internal_error"
|
||||||
CodeServiceUnavailable = "service_unavailable"
|
CodeServiceUnavailable = "service_unavailable"
|
||||||
|
|
||||||
|
// CodeTurnAlreadyClosed marks a user-games command or order rejection
|
||||||
|
// caused by the backend's turn-cutoff guard: the request arrived
|
||||||
|
// after the active turn started generating (runtime status
|
||||||
|
// `generation_in_progress` / `generation_failed` / `engine_unreachable`)
|
||||||
|
// and the engine no longer accepts writes for the closing turn. The
|
||||||
|
// caller is expected to wait for the next `game.turn.ready` push and
|
||||||
|
// resubmit against the new turn.
|
||||||
|
CodeTurnAlreadyClosed = "turn_already_closed"
|
||||||
|
|
||||||
|
// CodeGamePaused marks a user-games command or order rejection caused
|
||||||
|
// by the lobby-side game lifecycle: the game is in `paused`,
|
||||||
|
// `finished`, or any other status that does not accept writes. The
|
||||||
|
// caller is expected to wait for the game to resume before
|
||||||
|
// resubmitting.
|
||||||
|
CodeGamePaused = "game_paused"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Body stores the inner `error` object of the standard envelope.
|
// Body stores the inner `error` object of the standard envelope.
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de
|
|||||||
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
userGames.POST("/:game_id/orders", deps.UserGames.Orders())
|
||||||
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
userGames.GET("/:game_id/orders", deps.UserGames.GetOrders())
|
||||||
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report())
|
||||||
|
userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle())
|
||||||
|
|
||||||
userSessions := group.Group("/sessions")
|
userSessions := group.Group("/sessions")
|
||||||
userSessions.GET("", deps.UserSessions.List())
|
userSessions.GET("", deps.UserSessions.List())
|
||||||
|
|||||||
+44
-3
@@ -1106,6 +1106,44 @@ paths:
|
|||||||
$ref: "#/components/responses/NotImplementedError"
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id}:
|
||||||
|
get:
|
||||||
|
tags: [User]
|
||||||
|
operationId: userGamesBattle
|
||||||
|
summary: Read one engine battle report
|
||||||
|
description: |
|
||||||
|
Forwards to the engine's `GET /api/v1/battle/:turn/:uuid`. The
|
||||||
|
engine response body is passed through verbatim. `404 Not Found`
|
||||||
|
is returned when the battle does not exist for the supplied
|
||||||
|
`turn` / `battle_id` pair.
|
||||||
|
security:
|
||||||
|
- UserHeader: []
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/XUserID"
|
||||||
|
- $ref: "#/components/parameters/GameID"
|
||||||
|
- $ref: "#/components/parameters/Turn"
|
||||||
|
- name: battle_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Battle identifier (RFC 4122 UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Engine battle report passed through.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PassthroughObject"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/InvalidRequestError"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFoundError"
|
||||||
|
"501":
|
||||||
|
$ref: "#/components/responses/NotImplementedError"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/user/sessions:
|
/api/v1/user/sessions:
|
||||||
get:
|
get:
|
||||||
tags: [User]
|
tags: [User]
|
||||||
@@ -2314,9 +2352,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: |
|
description: |
|
||||||
Stable machine-readable failure marker. The closed set is
|
Stable machine-readable failure marker. The closed set is
|
||||||
`not_implemented`, `invalid_request`, `unauthorized`, `not_found`,
|
`not_implemented`, `invalid_request`, `unauthorized`,
|
||||||
`conflict`, `method_not_allowed`, `internal_error`,
|
`forbidden`, `not_found`, `conflict`, `method_not_allowed`,
|
||||||
`service_unavailable`.
|
`internal_error`, `service_unavailable`,
|
||||||
|
`turn_already_closed`, `game_paused`.
|
||||||
enum:
|
enum:
|
||||||
- not_implemented
|
- not_implemented
|
||||||
- invalid_request
|
- invalid_request
|
||||||
@@ -2327,6 +2366,8 @@ components:
|
|||||||
- method_not_allowed
|
- method_not_allowed
|
||||||
- internal_error
|
- internal_error
|
||||||
- service_unavailable
|
- service_unavailable
|
||||||
|
- turn_already_closed
|
||||||
|
- game_paused
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
description: Human-readable client-safe failure description.
|
description: Human-readable client-safe failure description.
|
||||||
|
|||||||
+15
-3
@@ -785,9 +785,21 @@ Future scale-out hooks (not in MVP):
|
|||||||
- **runtime snapshot** — engine-status read materialised into the lobby's
|
- **runtime snapshot** — engine-status read materialised into the lobby's
|
||||||
denormalised view: `current_turn`, `runtime_status`,
|
denormalised view: `current_turn`, `runtime_status`,
|
||||||
`engine_health_summary`, `player_turn_stats`.
|
`engine_health_summary`, `player_turn_stats`.
|
||||||
- **turn cutoff** — the `running → generation_in_progress` CAS transition
|
- **turn cutoff** — the `running → generation_in_progress` runtime-status
|
||||||
that closes the command window. Commands arriving after the CAS are
|
flip performed by `backend/internal/runtime/scheduler.go` before each
|
||||||
rejected.
|
engine `/admin/turn` call. Commands and orders arriving while the
|
||||||
|
flag is set are rejected by the user-games handlers with HTTP 409
|
||||||
|
`turn_already_closed`. The matching reopening flip
|
||||||
|
(`generation_in_progress → running`) happens on a successful tick;
|
||||||
|
a failing tick instead drives the lobby to `paused` and fans out
|
||||||
|
`game.paused` (FUNCTIONAL.md §6.3, §6.5).
|
||||||
|
- **auto-pause** — the lobby reaction to a failed runtime snapshot
|
||||||
|
(`engine_unreachable` / `generation_failed`): the game flips
|
||||||
|
`running → paused`, the order handlers refuse new submits with
|
||||||
|
HTTP 409 `game_paused`, and `lobby.publishGamePaused` fans out the
|
||||||
|
push event. Only an admin `/resume` followed by a successful tick
|
||||||
|
recovers the game; the UI relies on the next `game.turn.ready` to
|
||||||
|
clear the paused banner.
|
||||||
- **outbox** — the durable queue of pending mail rows in
|
- **outbox** — the durable queue of pending mail rows in
|
||||||
`mail_deliveries`, drained by the mail worker.
|
`mail_deliveries`, drained by the mail worker.
|
||||||
- **freshness window** — the symmetric ±5-minute interval around server
|
- **freshness window** — the symmetric ±5-minute interval around server
|
||||||
|
|||||||
+128
-17
@@ -635,18 +635,40 @@ validity and ordering of in-game decisions. Gateway needs to know
|
|||||||
the typed FB shape only to transcode the wire format; the per-command
|
the typed FB shape only to transcode the wire format; the per-command
|
||||||
semantics live in the engine.
|
semantics live in the engine.
|
||||||
|
|
||||||
### 6.3 Turn cutoff
|
### 6.3 Turn cutoff and auto-pause
|
||||||
|
|
||||||
A running game continuously alternates between a command-accepting
|
A running game continuously alternates between a command-accepting
|
||||||
window and a generation phase. The transition `running →
|
window and a generation phase, driven by the cron expression stored
|
||||||
generation_in_progress` is the cutoff: any command or order that
|
in `runtime_records.turn_schedule`. The backend scheduler
|
||||||
arrives after the cutoff is rejected by backend before forwarding,
|
(`backend/internal/runtime/scheduler.go`) wraps each engine
|
||||||
because the engine no longer accepts writes for the closing turn.
|
`/admin/turn` call between two `runtime_status` flips:
|
||||||
After generation finishes, backend re-opens the window for the next
|
|
||||||
turn.
|
- Before the engine call: `running → generation_in_progress`.
|
||||||
|
The user-games command/order handlers
|
||||||
|
(`backend/internal/server/handlers_user_games.go`) consult the
|
||||||
|
per-game runtime record on every request and reject with
|
||||||
|
HTTP 409 + `code = turn_already_closed` while the runtime sits in
|
||||||
|
`generation_in_progress`. The error envelope mirrors backend's
|
||||||
|
standard `httperr` shape: `{"error": {"code":
|
||||||
|
"turn_already_closed", "message": "..."}}`.
|
||||||
|
- After a successful tick: `generation_in_progress → running`.
|
||||||
|
The order window re-opens for the new turn and the next
|
||||||
|
scheduled tick continues normally.
|
||||||
|
- After a failed tick (`engine_unreachable` /
|
||||||
|
`generation_failed`): the lobby's `OnRuntimeSnapshot` flips the
|
||||||
|
game from `running` to `paused` and publishes a `game.paused`
|
||||||
|
push event (see §6.6). The order handlers reject with HTTP 409
|
||||||
|
+ `code = game_paused` until an admin resume succeeds.
|
||||||
|
|
||||||
`force-next-turn` (admin) schedules a one-shot extra tick that
|
`force-next-turn` (admin) schedules a one-shot extra tick that
|
||||||
advances the next scheduled turn by one cron step.
|
advances the next scheduled turn by one cron step; the same
|
||||||
|
status-flip and rejection rules apply.
|
||||||
|
|
||||||
|
Clients distinguish the two rejections by `code`:
|
||||||
|
`turn_already_closed` means "wait for the next `game.turn.ready`
|
||||||
|
and resubmit", whereas `game_paused` means "wait for an admin
|
||||||
|
resume". The web client implements both reactions in
|
||||||
|
`ui/docs/sync-protocol.md`.
|
||||||
|
|
||||||
### 6.4 Reports
|
### 6.4 Reports
|
||||||
|
|
||||||
@@ -654,7 +676,79 @@ Per-turn reports are read-only views fetched from the engine on
|
|||||||
demand. Backend authorises the caller and forwards the request;
|
demand. Backend authorises the caller and forwards the request;
|
||||||
there is no caching or denormalisation in this path.
|
there is no caching or denormalisation in this path.
|
||||||
|
|
||||||
### 6.5 Side effects
|
The web client renders the report as one section per FBS array
|
||||||
|
(galaxy summary, votes, player status, my / foreign sciences, my /
|
||||||
|
foreign ship classes, battles, bombings, approaching groups, my /
|
||||||
|
foreign / uninhabited / unknown planets, ships in production,
|
||||||
|
cargo routes, my fleets, my / foreign / unidentified ship groups).
|
||||||
|
Empty sections render explicit empty-state copy. Section anchors
|
||||||
|
are exposed in a sticky table of contents (a `<select>` on mobile)
|
||||||
|
and the scroll position is preserved across active-view switches
|
||||||
|
via SvelteKit's `Snapshot` API.
|
||||||
|
|
||||||
|
The Bombings section is a flat read-only table — one row per
|
||||||
|
bombing event, columns for `attacker`, `attack_power`, `wiped`
|
||||||
|
state and the post-bombing resource snapshot. The Battles section
|
||||||
|
is a list of links into the Battle Viewer (see [§6.5](#65-battle-viewer)).
|
||||||
|
|
||||||
|
### 6.5 Battle viewer
|
||||||
|
|
||||||
|
The Battle Viewer is a dedicated view that replaces the map and
|
||||||
|
renders one battle at a time. Entry points:
|
||||||
|
|
||||||
|
- A row in the Reports view's Battles section (link with the
|
||||||
|
current turn pinned via `?turn=`).
|
||||||
|
- A battle marker on the map (yellow cross drawn through the
|
||||||
|
corners of the square that circumscribes the planet circle;
|
||||||
|
stroke width scales with the protocol length).
|
||||||
|
|
||||||
|
The viewer is a logically isolated component that consumes a
|
||||||
|
`BattleReport` (shape per `pkg/model/report/battle.go`). The page
|
||||||
|
loader (`ui/frontend/src/lib/active-view/battle.svelte`) fetches
|
||||||
|
the report through the backend gateway route
|
||||||
|
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
|
||||||
|
which forwards verbatim to the engine's
|
||||||
|
`GET /api/v1/battle/:turn/:uuid`.
|
||||||
|
|
||||||
|
Visual model is radial: the planet sits at the centre, races are
|
||||||
|
placed at equal angular spacing on an outer ring, and each race is
|
||||||
|
rendered as a cloud of ship-class circles arranged on a Vogel
|
||||||
|
sunflower spiral biased toward the planet (the largest group by
|
||||||
|
NumberLeft sits closest to the planet, lighter buckets fan behind).
|
||||||
|
Tech-variants of the same `(race, className)` collapse into one
|
||||||
|
visual bucket labelled `<className>:<numLeft>`; per-class detail
|
||||||
|
stays available in the Reports view. Circle radius scales with
|
||||||
|
per-ship FullMass (range `[6, 24] px`, per-battle normalisation)
|
||||||
|
so heavy ships visually dominate. Observer groups (`inBattle:
|
||||||
|
false`) are not drawn. Eliminated races drop out and the survivors
|
||||||
|
re-spread on the next frame. The viewer is pinned to the viewport
|
||||||
|
(scene grows, log scrolls internally) so no page-level scroll
|
||||||
|
appears.
|
||||||
|
|
||||||
|
Each frame is one protocol entry; the shot is drawn as a thin line
|
||||||
|
from attacker to defender, red on `destroyed`, green otherwise.
|
||||||
|
Continuous playback offers 1x / 2x / 4x speeds (400 / 200 / 100 ms
|
||||||
|
per frame), plus play/pause, step ±, and rewind. The accessibility
|
||||||
|
text protocol below the scene mirrors the same events line-by-line.
|
||||||
|
|
||||||
|
Bombings and battles are intentionally not mixed: bombings remain a
|
||||||
|
static table in the Reports view; the bombing marker on the map is
|
||||||
|
a thin stroke-only ring around the planet (yellow when damaged, red
|
||||||
|
when wiped) and a click scrolls the corresponding row into view.
|
||||||
|
|
||||||
|
The current report wire carries a `battle: [{ id, planet, shots }]`
|
||||||
|
summary per battle so the map markers know where to anchor without
|
||||||
|
fetching every full `BattleReport`.
|
||||||
|
|
||||||
|
For DEV / e2e the legacy-report CLI
|
||||||
|
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) emits an
|
||||||
|
envelope `{version: 1, report, battles}` where `battles` carries the
|
||||||
|
full `BattleReport`-s parsed out of legacy `Battle at (#N)` blocks.
|
||||||
|
The synthetic-report loader on the lobby unwraps the envelope and
|
||||||
|
hands every battle to `registerSyntheticBattle`, so the Battle Viewer
|
||||||
|
resolves any UUID without a network fetch.
|
||||||
|
|
||||||
|
### 6.6 Side effects
|
||||||
|
|
||||||
A successful turn generation publishes a runtime snapshot into the
|
A successful turn generation publishes a runtime snapshot into the
|
||||||
lobby module, which updates the denormalised view (current turn,
|
lobby module, which updates the denormalised view (current turn,
|
||||||
@@ -662,15 +756,32 @@ runtime status, per-player stats). The engine's "game finished"
|
|||||||
report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish))
|
report drives the `running → finished` transition ([Section 3.5](#35-cancellation-and-finish))
|
||||||
and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
|
and triggers Race Name Directory promotions ([Section 5](#5-race-name-directory)).
|
||||||
|
|
||||||
The `game.*` notification kinds (`game.started`, `game.turn.ready`,
|
Among the `game.*` notification kinds, `game.turn.ready` and
|
||||||
`game.generation.failed`, `game.finished`) are reserved in the
|
`game.paused` are wired:
|
||||||
documentation but have **no producer** in the codebase today; the
|
|
||||||
notification catalog explicitly omits them (`backend/internal/notification/catalog.go`).
|
|
||||||
Adding a producer is purely additive: register the kind in the
|
|
||||||
catalog, populate `MailTemplateID` if email fan-out is desired, and
|
|
||||||
have the appropriate domain module call `notification.Submit`.
|
|
||||||
|
|
||||||
### 6.6 Cross-references
|
- `game.turn.ready` —
|
||||||
|
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||||
|
emits one intent per advancing `current_turn`, addressed to every
|
||||||
|
active membership of the game, with idempotency key
|
||||||
|
`turn-ready:<game_id>:<turn>` and JSON payload `{game_id, turn}`.
|
||||||
|
- `game.paused` — the same hook publishes one intent per transition
|
||||||
|
into `paused` driven by an `engine_unreachable` /
|
||||||
|
`generation_failed` runtime snapshot, addressed to every active
|
||||||
|
membership, with idempotency key `paused:<game_id>:<turn>` and
|
||||||
|
JSON payload `{game_id, turn, reason}`. The runtime status that
|
||||||
|
triggered the transition is carried as `reason` so the UI can
|
||||||
|
differentiate the copy in a future revision.
|
||||||
|
|
||||||
|
Both kinds route through the push channel only; email is
|
||||||
|
deliberately omitted to avoid per-turn / per-pause spam.
|
||||||
|
|
||||||
|
The remaining `game.*` kinds (`game.started`, `game.generation.failed`,
|
||||||
|
`game.finished`) and `mail.dead_lettered` are reserved without a
|
||||||
|
producer; adding one is purely additive (register the kind in the
|
||||||
|
catalog, extend the migration `CHECK` constraint, and call
|
||||||
|
`notification.Submit` from the appropriate domain module).
|
||||||
|
|
||||||
|
### 6.7 Cross-references
|
||||||
|
|
||||||
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
|
- Backend ↔ engine wire contract (`pkg/model/{order,report,rest}`):
|
||||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||||
|
|||||||
+130
-17
@@ -653,17 +653,40 @@ Backend не парсит содержимое payload команд или пр
|
|||||||
FB-форму только чтобы транскодировать wire-формат; per-command-
|
FB-форму только чтобы транскодировать wire-формат; per-command-
|
||||||
семантика живёт в движке.
|
семантика живёт в движке.
|
||||||
|
|
||||||
### 6.3 Окно хода
|
### 6.3 Окно хода и auto-pause
|
||||||
|
|
||||||
Запущенная игра постоянно чередуется между окном приёма команд
|
Запущенная игра постоянно чередуется между окном приёма команд
|
||||||
и фазой генерации. Переход `running → generation_in_progress` —
|
и фазой генерации, управляемой cron-выражением из
|
||||||
cutoff: любая команда или приказ, пришедшие после cutoff,
|
`runtime_records.turn_schedule`. Backend-планировщик
|
||||||
отклоняются backend до форварда, потому что движок больше не
|
(`backend/internal/runtime/scheduler.go`) оборачивает каждый
|
||||||
принимает запись для закрывающегося хода. После окончания
|
engine `/admin/turn` двумя `runtime_status`-флипами:
|
||||||
генерации backend заново открывает окно для следующего хода.
|
|
||||||
|
- Перед engine-вызовом: `running → generation_in_progress`.
|
||||||
|
User-games-handler'ы команд/приказов
|
||||||
|
(`backend/internal/server/handlers_user_games.go`) на каждом
|
||||||
|
запросе сверяются с per-game runtime-записью и отклоняют с
|
||||||
|
HTTP 409 + `code = turn_already_closed`, пока runtime в
|
||||||
|
`generation_in_progress`. Тело ошибки — стандартный
|
||||||
|
`httperr`-конверт: `{"error": {"code": "turn_already_closed",
|
||||||
|
"message": "..."}}`.
|
||||||
|
- После успешного тика: `generation_in_progress → running`.
|
||||||
|
Окно приказов открывается на новый ход, следующий тик идёт
|
||||||
|
как обычно.
|
||||||
|
- После провалившегося тика (`engine_unreachable` /
|
||||||
|
`generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру
|
||||||
|
`running → paused` и публикует push-эвент `game.paused`
|
||||||
|
(см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 +
|
||||||
|
`code = game_paused`, пока админ не выполнит resume.
|
||||||
|
|
||||||
`force-next-turn` (admin) планирует one-shot-доп-тик, который
|
`force-next-turn` (admin) планирует one-shot-доп-тик, который
|
||||||
сдвигает следующий запланированный ход на один cron-шаг.
|
сдвигает следующий запланированный ход на один cron-шаг; те же
|
||||||
|
правила status-flip и отклонения применимы.
|
||||||
|
|
||||||
|
Клиенты различают два варианта отказа по `code`:
|
||||||
|
`turn_already_closed` — «дождись следующего `game.turn.ready` и
|
||||||
|
отправь ещё раз», `game_paused` — «дождись resume администратором».
|
||||||
|
Web-клиент реализует оба сценария согласно
|
||||||
|
`ui/docs/sync-protocol.md`.
|
||||||
|
|
||||||
### 6.4 Отчёты
|
### 6.4 Отчёты
|
||||||
|
|
||||||
@@ -671,7 +694,79 @@ Per-turn-отчёты — read-only-вью, забираемые из движк
|
|||||||
Backend авторизует вызывающего и форвардит запрос; в этом пути
|
Backend авторизует вызывающего и форвардит запрос; в этом пути
|
||||||
нет ни кэширования, ни денормализации.
|
нет ни кэширования, ни денормализации.
|
||||||
|
|
||||||
### 6.5 Побочные эффекты
|
Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив
|
||||||
|
(общие сведения, голоса, статус игроков, мои / чужие науки, мои /
|
||||||
|
чужие классы кораблей, сражения, бомбардировки, приближающиеся
|
||||||
|
группы, мои / чужие / необитаемые / неопознанные планеты, корабли в
|
||||||
|
производстве, грузовые маршруты, мои флоты, мои / чужие /
|
||||||
|
неопознанные группы кораблей). Пустые секции получают явную копию
|
||||||
|
empty-state. Якоря секций отображены в sticky-TOC (на мобильном —
|
||||||
|
`<select>`); позиция скролла сохраняется при переключении активного
|
||||||
|
представления через SvelteKit `Snapshot` API.
|
||||||
|
|
||||||
|
Секция бомбардировок — это плоская read-only-таблица: одна строка на
|
||||||
|
событие, колонки `attacker`, `attack_power`, признак `wiped` и
|
||||||
|
ресурсный снимок после удара. Секция сражений — список ссылок в
|
||||||
|
Battle Viewer (см. [§6.5](#65-battle-viewer)).
|
||||||
|
|
||||||
|
### 6.5 Battle viewer
|
||||||
|
|
||||||
|
Battle Viewer — отдельное представление, заменяющее карту и
|
||||||
|
показывающее одну битву. Входы:
|
||||||
|
|
||||||
|
- Строка в секции «сражения» в Reports (ссылка с пиннингом
|
||||||
|
текущего хода через `?turn=`).
|
||||||
|
- Battle-marker на карте (жёлтый крест через противоположные углы
|
||||||
|
квадрата, описанного вокруг круга планеты; толщина линий растёт
|
||||||
|
с длиной протокола).
|
||||||
|
|
||||||
|
Сам Viewer — логически изолированный компонент, потребляющий
|
||||||
|
`BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка
|
||||||
|
(`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт
|
||||||
|
через backend-маршрут
|
||||||
|
`GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`,
|
||||||
|
который проксирует ответ engine-эндпоинта
|
||||||
|
`GET /api/v1/battle/:turn/:uuid`.
|
||||||
|
|
||||||
|
Визуальная модель — радиальная: планета в центре, расы по внешней
|
||||||
|
окружности на равных угловых интервалах, внутри расы — облако
|
||||||
|
кружков по классам кораблей, выложенное Vogel-спиралью с биасом к
|
||||||
|
планете (самая многочисленная группа по NumberLeft — ближе к
|
||||||
|
планете, остальные раскручиваются спиралью позади). Tech-варианты
|
||||||
|
одного `(race, className)` схлопываются в один визуальный нод
|
||||||
|
`<className>:<numLeft>`; детали по тех-уровням остаются в Reports.
|
||||||
|
Радиус кружка масштабируется по FullMass корабля (диапазон
|
||||||
|
`[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что
|
||||||
|
тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle:
|
||||||
|
false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся
|
||||||
|
перераспределяются на следующем кадре. Viewer закреплён по высоте
|
||||||
|
viewport-а: сцена растягивается, лог скроллит внутри — никаких
|
||||||
|
скроллов на уровне страницы.
|
||||||
|
|
||||||
|
Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией
|
||||||
|
от атакующего к защитнику, красной при `destroyed`, зелёной иначе.
|
||||||
|
Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на
|
||||||
|
кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол
|
||||||
|
доступности под сценой дублирует те же события построчно.
|
||||||
|
|
||||||
|
Бомбардировки и сражения умышленно не смешиваются: бомбардировки
|
||||||
|
остаются статической таблицей в Reports; bombing-marker на карте —
|
||||||
|
тонкая окружность вокруг планеты (жёлтая при damaged, красная при
|
||||||
|
wiped), клик скроллит соответствующую строку в Reports.
|
||||||
|
|
||||||
|
Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]`
|
||||||
|
на каждую битву, чтобы map-маркеры могли расположиться без
|
||||||
|
дополнительного запроса полного `BattleReport`.
|
||||||
|
|
||||||
|
Для DEV / e2e легаси-CLI
|
||||||
|
(`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт
|
||||||
|
envelope `{version: 1, report, battles}`, где `battles` несёт полные
|
||||||
|
`BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic-
|
||||||
|
загрузчик в лобби разбирает envelope и регистрирует каждую битву
|
||||||
|
через `registerSyntheticBattle`, так что Battle Viewer открывает
|
||||||
|
любой UUID без сетевого запроса.
|
||||||
|
|
||||||
|
### 6.6 Побочные эффекты
|
||||||
|
|
||||||
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
|
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
|
||||||
который обновляет денормализованное вью (текущий ход, runtime-
|
который обновляет денормализованное вью (текущий ход, runtime-
|
||||||
@@ -680,16 +775,34 @@ status, per-player-stats). Engine-отчёт "game finished" гонит
|
|||||||
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
|
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
|
||||||
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
|
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
|
||||||
|
|
||||||
`game.*`-виды уведомлений (`game.started`, `game.turn.ready`,
|
Из `game.*`-видов уведомлений подключены `game.turn.ready` и
|
||||||
`game.generation.failed`, `game.finished`) зарезервированы в
|
`game.paused`:
|
||||||
документации, но **не имеют поставщика** в кодовой базе сегодня;
|
|
||||||
notification-каталог явно их опускает
|
|
||||||
(`backend/internal/notification/catalog.go`). Добавление поставщика
|
|
||||||
аддитивно: зарегистрировать вид в каталоге, заполнить
|
|
||||||
`MailTemplateID`, если нужен email-веер, и заставить нужный
|
|
||||||
доменный модуль вызвать `notification.Submit`.
|
|
||||||
|
|
||||||
### 6.6 Перекрёстные ссылки
|
- `game.turn.ready` —
|
||||||
|
`lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`)
|
||||||
|
выпускает один intent на каждое увеличение `current_turn`,
|
||||||
|
адресуя его всем активным membership-ам игры, с
|
||||||
|
idempotency-ключом `turn-ready:<game_id>:<turn>` и
|
||||||
|
JSON-payload-ом `{game_id, turn}`.
|
||||||
|
- `game.paused` — тот же хук публикует один intent на каждое
|
||||||
|
выставление статуса `paused` по runtime-снапшоту
|
||||||
|
(`engine_unreachable` / `generation_failed`), адресуя его всем
|
||||||
|
активным membership-ам игры, с idempotency-ключом
|
||||||
|
`paused:<game_id>:<turn>` и JSON-payload-ом
|
||||||
|
`{game_id, turn, reason}`. `reason` несёт runtime-статус,
|
||||||
|
спровоцировавший переход, чтобы UI смог в будущем
|
||||||
|
дифференцировать копию.
|
||||||
|
|
||||||
|
Оба вида направляются только в push-канал; email-фан-аут
|
||||||
|
сознательно опущен, чтобы избежать спама на каждом ходе/паузе.
|
||||||
|
|
||||||
|
Остальные `game.*`-виды (`game.started`, `game.generation.failed`,
|
||||||
|
`game.finished`) и `mail.dead_lettered` зарезервированы без поставщика;
|
||||||
|
добавление поставщика чисто аддитивное (зарегистрировать вид в
|
||||||
|
каталоге, расширить `CHECK`-констрейнт миграции и вызвать
|
||||||
|
`notification.Submit` из подходящего доменного модуля).
|
||||||
|
|
||||||
|
### 6.7 Перекрёстные ссылки
|
||||||
|
|
||||||
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
|
- Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`):
|
||||||
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
[ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"galaxy/calc"
|
"galaxy/calc"
|
||||||
"galaxy/game/internal/controller"
|
"galaxy/game/internal/controller"
|
||||||
"galaxy/game/internal/model/game"
|
"galaxy/game/internal/model/game"
|
||||||
|
"galaxy/model/report"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -184,3 +185,89 @@ func TestProduceBattles(t *testing.T) {
|
|||||||
assert.Zero(t, c.ShipGroup(3).Number)
|
assert.Zero(t, c.ShipGroup(3).Number)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTransformBattleAggregatesSameShipClass guards against the
|
||||||
|
// engine-side variant of the duplicate-class bug. Several ShipGroups
|
||||||
|
// of the same ShipClass.ID can take part in the same battle (arrivals
|
||||||
|
// from different planets, tech splits, etc.); they must collapse into
|
||||||
|
// a single BattleReportGroup with summed Number and NumberLeft. The
|
||||||
|
// pre-fix engine cached the first group's index and silently dropped
|
||||||
|
// every subsequent group's initial / survivor counts, which manifested
|
||||||
|
// downstream as more Destroyed shots in the protocol than the
|
||||||
|
// recorded initial roster could account for.
|
||||||
|
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
|
||||||
|
c, g := newCache()
|
||||||
|
|
||||||
|
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
|
||||||
|
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
|
||||||
|
|
||||||
|
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
|
||||||
|
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
|
||||||
|
// (owned by Race_0; the Race_1 group lands there via the Unsafe
|
||||||
|
// helper that bypasses the ownership check). Group indices land
|
||||||
|
// at 0, 1, 2 in creation order.
|
||||||
|
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
|
||||||
|
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
|
||||||
|
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
|
||||||
|
|
||||||
|
// Simulate post-battle survivor counts: Group 0 ended the battle
|
||||||
|
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
|
||||||
|
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
|
||||||
|
// group's 6 — that's the regression).
|
||||||
|
c.ShipGroup(0).Number = 8
|
||||||
|
c.ShipGroup(1).Number = 6
|
||||||
|
|
||||||
|
b := &controller.Battle{
|
||||||
|
Planet: R0_Planet_0_num,
|
||||||
|
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
|
||||||
|
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
|
||||||
|
// Protocol must reference every in-battle group at least once
|
||||||
|
// (otherwise TransformBattle won't register it through the
|
||||||
|
// `ship()` path). Two shots from Race_1 against each Race_0
|
||||||
|
// group hits both groupIds.
|
||||||
|
Protocol: []controller.BattleAction{
|
||||||
|
{Attacker: 2, Defender: 0, Destroyed: true},
|
||||||
|
{Attacker: 2, Defender: 1, Destroyed: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := controller.TransformBattle(c, b)
|
||||||
|
|
||||||
|
// Two BattleReportGroup entries total: one merged Race_0_Gunship
|
||||||
|
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
|
||||||
|
if got, want := len(r.Ships), 2; got != want {
|
||||||
|
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gunship0, gunship1 *report.BattleReportGroup
|
||||||
|
for i := range r.Ships {
|
||||||
|
grp := r.Ships[i]
|
||||||
|
switch grp.Race {
|
||||||
|
case Race_0.Name:
|
||||||
|
gunship0 = &grp
|
||||||
|
case Race_1.Name:
|
||||||
|
gunship1 = &grp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gunship0 == nil || gunship1 == nil {
|
||||||
|
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gunship0.ClassName != Race_0_Gunship {
|
||||||
|
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
|
||||||
|
}
|
||||||
|
if gunship0.Number != 20 {
|
||||||
|
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
|
||||||
|
}
|
||||||
|
if gunship0.NumberLeft != 14 {
|
||||||
|
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
|
||||||
|
}
|
||||||
|
if !gunship0.InBattle {
|
||||||
|
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
|
||||||
|
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
|
||||||
|
gunship1.Number, gunship1.NumberLeft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,10 +18,35 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
|
|||||||
|
|
||||||
cacheShipClass := make(map[uuid.UUID]int)
|
cacheShipClass := make(map[uuid.UUID]int)
|
||||||
cacheRaceName := make(map[uuid.UUID]int)
|
cacheRaceName := make(map[uuid.UUID]int)
|
||||||
|
processedGroup := make(map[int]bool)
|
||||||
|
|
||||||
addShipGroup := func(groupId int, inBattle bool) int {
|
addShipGroup := func(groupId int, inBattle bool) int {
|
||||||
shipClass := c.ShipGroupShipClass(groupId)
|
shipClass := c.ShipGroupShipClass(groupId)
|
||||||
sg := c.ShipGroup(groupId)
|
sg := c.ShipGroup(groupId)
|
||||||
|
// Several ship-groups of the same race/class can take part
|
||||||
|
// in the same battle (different tech upgrades, arrivals from
|
||||||
|
// different planets, …). They share a single
|
||||||
|
// BattleReportGroup entry keyed by ShipClass.ID — when a
|
||||||
|
// later group lands on a cached class we add its Number and
|
||||||
|
// NumberLeft into the existing entry instead of dropping
|
||||||
|
// them, so the protocol's per-class destroy counts reconcile
|
||||||
|
// with the recorded totals. `processedGroup` guards against
|
||||||
|
// double-counting a single groupId across multiple shots in
|
||||||
|
// the protocol — `ship()` runs on every attacker and defender
|
||||||
|
// reference, the merge must happen once per groupId.
|
||||||
|
if existing, ok := cacheShipClass[shipClass.ID]; ok {
|
||||||
|
if !processedGroup[groupId] {
|
||||||
|
bg := r.Ships[existing]
|
||||||
|
bg.Number += b.InitialNumbers[groupId]
|
||||||
|
bg.NumberLeft += sg.Number
|
||||||
|
if inBattle {
|
||||||
|
bg.InBattle = true
|
||||||
|
}
|
||||||
|
r.Ships[existing] = bg
|
||||||
|
processedGroup[groupId] = true
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
itemNumber := len(r.Ships)
|
itemNumber := len(r.Ships)
|
||||||
bg := &report.BattleReportGroup{
|
bg := &report.BattleReportGroup{
|
||||||
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
|
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
|
||||||
@@ -31,22 +56,19 @@ func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
|
|||||||
ClassName: shipClass.Name,
|
ClassName: shipClass.Name,
|
||||||
LoadType: sg.CargoString(),
|
LoadType: sg.CargoString(),
|
||||||
LoadQuantity: report.F(sg.Load.F()),
|
LoadQuantity: report.F(sg.Load.F()),
|
||||||
|
Tech: make(map[string]report.Float, len(sg.Tech)),
|
||||||
}
|
}
|
||||||
for t, v := range sg.Tech {
|
for t, v := range sg.Tech {
|
||||||
bg.Tech[t.String()] = report.F(v.F())
|
bg.Tech[t.String()] = report.F(v.F())
|
||||||
}
|
}
|
||||||
r.Ships[itemNumber] = *bg
|
r.Ships[itemNumber] = *bg
|
||||||
cacheShipClass[shipClass.ID] = itemNumber
|
cacheShipClass[shipClass.ID] = itemNumber
|
||||||
|
processedGroup[groupId] = true
|
||||||
return itemNumber
|
return itemNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
ship := func(groupId int) int {
|
ship := func(groupId int) int {
|
||||||
shipClass := c.ShipGroupShipClass(groupId)
|
return addShipGroup(groupId, true)
|
||||||
if v, ok := cacheShipClass[shipClass.ID]; ok {
|
|
||||||
return v
|
|
||||||
} else {
|
|
||||||
return addShipGroup(groupId, true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
race := func(groupId int) int {
|
race := func(groupId int) int {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ type Repo interface {
|
|||||||
// SaveBattle stores a new battle protocol and battle meta data for turn t
|
// SaveBattle stores a new battle protocol and battle meta data for turn t
|
||||||
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
|
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
|
||||||
|
|
||||||
|
// LoadBattle reads battle's protocol for turn t and battle id.
|
||||||
|
// Returns false if battle with such id was never stored at turn t
|
||||||
|
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
|
||||||
|
|
||||||
// SaveBombing stores all prodused bombings for turn t
|
// SaveBombing stores all prodused bombings for turn t
|
||||||
SaveBombings(uint, []*game.Bombing) error
|
SaveBombings(uint, []*game.Bombing) error
|
||||||
|
|
||||||
@@ -143,6 +147,14 @@ func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.U
|
|||||||
return ec.fetchOrder(actor, turn)
|
return ec.fetchOrder(actor, turn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
|
||||||
|
ec, err := NewRepoController(configure)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return ec.fetchBattle(turn, ID)
|
||||||
|
}
|
||||||
|
|
||||||
func BanishRace(configure func(*Param), actor string) error {
|
func BanishRace(configure func(*Param), actor string) error {
|
||||||
ec, err := NewRepoController(configure)
|
ec, err := NewRepoController(configure)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,6 +273,14 @@ func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.User
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
|
||||||
|
err = ec.executeSafe(func(t uint, c *Controller) error {
|
||||||
|
order, exists, err = ec.Repo.LoadBattle(turn, ID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
|
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
|
||||||
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
|
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
|
||||||
id, exErr := c.RaceID(actor)
|
id, exErr := c.RaceID(actor)
|
||||||
|
|||||||
@@ -267,21 +267,20 @@ func (c *Cache) putMaterial(pn uint, v float64) {
|
|||||||
c.MustPlanet(pn).Mat(v)
|
c.MustPlanet(pn).Mat(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProduceShip returns number of ships with shipMass planet p can produce in one turn
|
||||||
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
|
||||||
if productionAvailable <= 0 {
|
if productionAvailable <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
ships := uint(0)
|
ships := uint(0)
|
||||||
pa := productionAvailable
|
pa := productionAvailable
|
||||||
PRODcost := calc.ShipProductionCost(shipMass)
|
var MATneed, totalCost float64
|
||||||
var MATneed, MATfarm, totalCost float64
|
|
||||||
for {
|
for {
|
||||||
MATneed = shipMass - float64(p.Material)
|
MATneed = shipMass - float64(p.Material)
|
||||||
if MATneed < 0 {
|
if MATneed < 0 {
|
||||||
MATneed = 0
|
MATneed = 0
|
||||||
}
|
}
|
||||||
MATfarm = MATneed / float64(p.Resources)
|
totalCost = calc.ShipBuildCost(shipMass, float64(p.Material), float64(p.Resources))
|
||||||
totalCost = PRODcost + MATfarm
|
|
||||||
if pa < totalCost {
|
if pa < totalCost {
|
||||||
progress := pa / totalCost
|
progress := pa / totalCost
|
||||||
pval := game.F(progress)
|
pval := game.F(progress)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (c *Cache) InitReport(t uint) *mr.Report {
|
|||||||
OtherScience: make([]mr.OtherScience, 0, 10),
|
OtherScience: make([]mr.OtherScience, 0, 10),
|
||||||
LocalShipClass: make([]mr.ShipClass, 0, 20),
|
LocalShipClass: make([]mr.ShipClass, 0, 20),
|
||||||
OtherShipClass: make([]mr.OthersShipClass, 0, 50),
|
OtherShipClass: make([]mr.OthersShipClass, 0, 50),
|
||||||
Battle: make([]uuid.UUID, 0, 10),
|
Battle: make([]mr.BattleSummary, 0, 10),
|
||||||
Bombing: make([]*mr.Bombing, 0, 10),
|
Bombing: make([]*mr.Bombing, 0, 10),
|
||||||
IncomingGroup: make([]mr.IncomingGroup, 0, 10),
|
IncomingGroup: make([]mr.IncomingGroup, 0, 10),
|
||||||
OnPlanetGroupCache: make(map[uint][]int),
|
OnPlanetGroupCache: make(map[uint][]int),
|
||||||
@@ -342,7 +342,11 @@ func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sliceIndexValidate(&rep.Battle, i)
|
sliceIndexValidate(&rep.Battle, i)
|
||||||
rep.Battle[i] = br[bi].ID
|
rep.Battle[i] = mr.BattleSummary{
|
||||||
|
ID: br[bi].ID,
|
||||||
|
Planet: br[bi].Planet,
|
||||||
|
Shots: uint(len(br[bi].Protocol)),
|
||||||
|
}
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-16
@@ -13,6 +13,7 @@ package repo
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
@@ -117,9 +118,25 @@ func loadMeta(s Storage) (*game.GameMeta, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveMeta(s Storage, t uint, gm *game.GameMeta) error {
|
func loadTurnMeta(s Storage, turn uint) (*game.GameMeta, error) {
|
||||||
|
var result *game.GameMeta = new(game.GameMeta)
|
||||||
|
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
||||||
|
exist, err := s.Exists(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewStorageError(err)
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if err := s.ReadSafe(path, result); err != nil {
|
||||||
|
return nil, NewStorageError(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMeta(s Storage, turn uint, gm *game.GameMeta) error {
|
||||||
// save turn's meta
|
// save turn's meta
|
||||||
path := fmt.Sprintf("%s/%s", TurnDir(t), metaPath)
|
path := fmt.Sprintf("%s/%s", TurnDir(turn), metaPath)
|
||||||
if err := s.Write(path, gm); err != nil {
|
if err := s.Write(path, gm); err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
}
|
}
|
||||||
@@ -131,27 +148,43 @@ func saveMeta(s Storage, t uint, gm *game.GameMeta) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error {
|
func (r *repo) LoadBattle(turn uint, id uuid.UUID) (*report.BattleReport, bool, error) {
|
||||||
|
meta, err := loadTurnMeta(r.s, turn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
i := slices.IndexFunc(meta.Battles, func(m game.BattleMeta) bool { return m.BattleID == id })
|
||||||
|
if i < 0 {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
result, err := loadBattle(r.s, turn, meta.Battles[i].BattleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return result, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repo) SaveBattle(turn uint, b *report.BattleReport, m *game.BattleMeta) error {
|
||||||
meta, err := loadMeta(r.s)
|
meta, err := loadMeta(r.s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
err = saveBattle(r.s, t, b)
|
err = saveBattle(r.s, turn, b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
meta.Battles = append(meta.Battles, *m)
|
meta.Battles = append(meta.Battles, *m)
|
||||||
return saveMeta(r.s, t, meta)
|
return saveMeta(r.s, turn, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveBattle(s Storage, t uint, b *report.BattleReport) error {
|
func saveBattle(s Storage, turn uint, b *report.BattleReport) error {
|
||||||
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(t), b.ID.String())
|
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), b.ID.String())
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
}
|
}
|
||||||
if exist {
|
if exist {
|
||||||
return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, t))
|
return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, turn))
|
||||||
}
|
}
|
||||||
if err := s.Write(path, b); err != nil {
|
if err := s.Write(path, b); err != nil {
|
||||||
return NewStorageError(err)
|
return NewStorageError(err)
|
||||||
@@ -159,7 +192,23 @@ func saveBattle(s Storage, t uint, b *report.BattleReport) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) SaveBombings(t uint, b []*game.Bombing) error {
|
func loadBattle(s Storage, turn uint, id uuid.UUID) (*report.BattleReport, error) {
|
||||||
|
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(turn), id.String())
|
||||||
|
exist, err := s.Exists(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, NewStorageError(err)
|
||||||
|
}
|
||||||
|
if !exist {
|
||||||
|
return nil, NewStateError(fmt.Sprintf("battle %v for turn %d never was saved", id, turn))
|
||||||
|
}
|
||||||
|
result := new(report.BattleReport)
|
||||||
|
if err := s.ReadSafe(path, result); err != nil {
|
||||||
|
return nil, NewStorageError(err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repo) SaveBombings(turn uint, b []*game.Bombing) error {
|
||||||
meta, err := loadMeta(r.s)
|
meta, err := loadMeta(r.s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -167,11 +216,11 @@ func (r *repo) SaveBombings(t uint, b []*game.Bombing) error {
|
|||||||
for i := range b {
|
for i := range b {
|
||||||
meta.Bombings = append(meta.Bombings, *b[i])
|
meta.Bombings = append(meta.Bombings, *b[i])
|
||||||
}
|
}
|
||||||
return saveMeta(r.s, t, meta)
|
return saveMeta(r.s, turn, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) SaveReport(t uint, rep *report.Report) error {
|
func (r *repo) SaveReport(turn uint, rep *report.Report) error {
|
||||||
return saveReport(r.s, t, rep)
|
return saveReport(r.s, turn, rep)
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveReport(s Storage, t uint, v *report.Report) error {
|
func saveReport(s Storage, t uint, v *report.Report) error {
|
||||||
@@ -182,12 +231,12 @@ func saveReport(s Storage, t uint, v *report.Report) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repo) LoadReport(t uint, id uuid.UUID) (*report.Report, error) {
|
func (r *repo) LoadReport(turn uint, id uuid.UUID) (*report.Report, error) {
|
||||||
return loadReport(r.s, t, id)
|
return loadReport(r.s, turn, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReport(s Storage, t uint, id uuid.UUID) (*report.Report, error) {
|
func loadReport(s Storage, turn uint, id uuid.UUID) (*report.Report, error) {
|
||||||
path := ReportDir(t, id)
|
path := ReportDir(turn, id)
|
||||||
result := new(report.Report)
|
result := new(report.Report)
|
||||||
exist, err := s.Exists(path)
|
exist, err := s.Exists(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package router_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/model/report"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetBattleValidation(t *testing.T) {
|
||||||
|
validUUID := uuid.New().String()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
description string
|
||||||
|
turn string
|
||||||
|
battleID string
|
||||||
|
expectStatus int
|
||||||
|
}{
|
||||||
|
{"Negative turn", "-1", validUUID, http.StatusBadRequest},
|
||||||
|
{"Non-numeric turn", "abc", validUUID, http.StatusBadRequest},
|
||||||
|
{"Invalid uuid", "0", invalidId, http.StatusBadRequest},
|
||||||
|
} {
|
||||||
|
t.Run(tc.description, func(t *testing.T) {
|
||||||
|
e := &dummyExecutor{}
|
||||||
|
r := setupRouterExecutor(e)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
path := fmt.Sprintf("/api/v1/battle/%s/%s", tc.turn, tc.battleID)
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, path, nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
|
||||||
|
assert.Equal(t, uuid.Nil, e.FetchBattleID, "FetchBattle must not be called on validation error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBattleFound(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
raceA := uuid.New()
|
||||||
|
raceB := uuid.New()
|
||||||
|
stored := &report.BattleReport{
|
||||||
|
ID: id,
|
||||||
|
Planet: 42,
|
||||||
|
PlanetName: "X-Prime",
|
||||||
|
Races: map[int]uuid.UUID{
|
||||||
|
0: raceA,
|
||||||
|
1: raceB,
|
||||||
|
},
|
||||||
|
Ships: map[int]report.BattleReportGroup{
|
||||||
|
10: {
|
||||||
|
Race: "Alpha",
|
||||||
|
ClassName: "Drone",
|
||||||
|
Tech: map[string]report.Float{"WEAPONS": report.F(1)},
|
||||||
|
Number: 5,
|
||||||
|
NumberLeft: 3,
|
||||||
|
LoadType: "EMP",
|
||||||
|
LoadQuantity: report.F(0),
|
||||||
|
InBattle: true,
|
||||||
|
},
|
||||||
|
20: {
|
||||||
|
Race: "Beta",
|
||||||
|
ClassName: "Spy",
|
||||||
|
Tech: map[string]report.Float{"SHIELDS": report.F(2)},
|
||||||
|
Number: 4,
|
||||||
|
NumberLeft: 0,
|
||||||
|
LoadType: "EMP",
|
||||||
|
LoadQuantity: report.F(0),
|
||||||
|
InBattle: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Protocol: []report.BattleActionReport{
|
||||||
|
{Attacker: 0, AttackerShipClass: 10, Defender: 1, DefenderShipClass: 20, Destroyed: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
e := &dummyExecutor{
|
||||||
|
FetchBattleResult: stored,
|
||||||
|
FetchBattleOK: true,
|
||||||
|
}
|
||||||
|
r := setupRouterExecutor(e)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
path := fmt.Sprintf("/api/v1/battle/%d/%s", 7, id.String())
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, path, nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, w.Body)
|
||||||
|
assert.Equal(t, uint(7), e.FetchBattleTurn)
|
||||||
|
assert.Equal(t, id, e.FetchBattleID)
|
||||||
|
|
||||||
|
var got report.BattleReport
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &got))
|
||||||
|
assert.Equal(t, stored.ID, got.ID)
|
||||||
|
assert.Equal(t, stored.Planet, got.Planet)
|
||||||
|
assert.Equal(t, stored.PlanetName, got.PlanetName)
|
||||||
|
assert.Equal(t, stored.Races, got.Races)
|
||||||
|
require.Len(t, got.Ships, len(stored.Ships))
|
||||||
|
assert.Equal(t, stored.Ships[10].ClassName, got.Ships[10].ClassName)
|
||||||
|
assert.Equal(t, stored.Ships[20].NumberLeft, got.Ships[20].NumberLeft)
|
||||||
|
require.Len(t, got.Protocol, 1)
|
||||||
|
assert.Equal(t, stored.Protocol[0], got.Protocol[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBattleTurnZero(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
e := &dummyExecutor{
|
||||||
|
FetchBattleResult: &report.BattleReport{ID: id},
|
||||||
|
FetchBattleOK: true,
|
||||||
|
}
|
||||||
|
r := setupRouterExecutor(e)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/0/%s", id.String()), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, w.Body)
|
||||||
|
assert.Equal(t, uint(0), e.FetchBattleTurn)
|
||||||
|
assert.Equal(t, id, e.FetchBattleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBattleNotFound(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
e := &dummyExecutor{FetchBattleOK: false}
|
||||||
|
r := setupRouterExecutor(e)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", id.String()), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, w.Body)
|
||||||
|
assert.Equal(t, uint(3), e.FetchBattleTurn)
|
||||||
|
assert.Equal(t, id, e.FetchBattleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBattleEngineError(t *testing.T) {
|
||||||
|
e := &dummyExecutor{FetchBattleErr: errors.New("engine boom")}
|
||||||
|
r := setupRouterExecutor(e)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/battle/3/%s", uuid.NewString()), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, w.Code, w.Body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BattleHandler(c *gin.Context, executor CommandExecutor) {
|
||||||
|
turn := c.Param("turn")
|
||||||
|
t, err := strconv.Atoi(turn)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t < 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "turn number can't be negative"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.Param("uuid")
|
||||||
|
battleID, err := uuid.Parse(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, exists, err := executor.FetchBattle(uint(t), battleID)
|
||||||
|
if errorResponse(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown battle"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, r)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommandExecutor interface {
|
type CommandExecutor interface {
|
||||||
@@ -29,6 +30,7 @@ type CommandExecutor interface {
|
|||||||
Execute(cmd ...Command) error
|
Execute(cmd ...Command) error
|
||||||
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
||||||
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
||||||
|
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Command func(controller.Ctrl) error
|
type Command func(controller.Ctrl) error
|
||||||
@@ -86,6 +88,10 @@ func (e *executor) FetchOrder(actor string, turn uint) (*order.UserGamesOrder, b
|
|||||||
return controller.FetchOrder(e.cfg, actor, turn)
|
return controller.FetchOrder(e.cfg, actor, turn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *executor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
|
||||||
|
return controller.FetchBattle(e.cfg, turn, ID)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
|
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
|
||||||
s, err := controller.GenerateGame(e.cfg, races)
|
s, err := controller.GenerateGame(e.cfg, races)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func setupRouter(executor handler.CommandExecutor) *gin.Engine {
|
|||||||
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
|
groupV1.GET("/report", func(ctx *gin.Context) { handler.ReportHandler(ctx, executor) })
|
||||||
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
|
groupV1.PUT("/order", func(ctx *gin.Context) { handler.PutOrderHandler(ctx, executor) })
|
||||||
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
|
groupV1.GET("/order", func(ctx *gin.Context) { handler.GetOrderHandler(ctx, executor) })
|
||||||
|
groupV1.GET("/battle/:turn/:uuid", func(ctx *gin.Context) { handler.BattleHandler(ctx, executor) })
|
||||||
|
|
||||||
// /command is reserved for future use; any API request for orders should use /order
|
// /command is reserved for future use; any API request for orders should use /order
|
||||||
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
|
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ type dummyExecutor struct {
|
|||||||
FetchOrderResult *order.UserGamesOrder
|
FetchOrderResult *order.UserGamesOrder
|
||||||
FetchOrderOK bool
|
FetchOrderOK bool
|
||||||
FetchOrderErr error
|
FetchOrderErr error
|
||||||
|
|
||||||
|
// FetchBattle controls and observes calls to FetchBattle.
|
||||||
|
FetchBattleTurn uint
|
||||||
|
FetchBattleID uuid.UUID
|
||||||
|
FetchBattleResult *report.BattleReport
|
||||||
|
FetchBattleOK bool
|
||||||
|
FetchBattleErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
|
||||||
@@ -68,6 +75,12 @@ func (e *dummyExecutor) FetchOrder(actor string, turn uint) (*order.UserGamesOrd
|
|||||||
return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr
|
return e.FetchOrderResult, e.FetchOrderOK, e.FetchOrderErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *dummyExecutor) FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error) {
|
||||||
|
e.FetchBattleTurn = turn
|
||||||
|
e.FetchBattleID = ID
|
||||||
|
return e.FetchBattleResult, e.FetchBattleOK, e.FetchBattleErr
|
||||||
|
}
|
||||||
|
|
||||||
func (e *dummyExecutor) Execute(command ...handler.Command) error {
|
func (e *dummyExecutor) Execute(command ...handler.Command) error {
|
||||||
e.CommandsExecuted = len(command)
|
e.CommandsExecuted = len(command)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+187
-3
@@ -207,6 +207,33 @@ paths:
|
|||||||
$ref: "#/components/responses/ValidationError"
|
$ref: "#/components/responses/ValidationError"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
/api/v1/battle/{turn}/{uuid}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- PlayerActions
|
||||||
|
operationId: getBattle
|
||||||
|
summary: Fetch a single battle report
|
||||||
|
description: |
|
||||||
|
Returns the full `BattleReport` for the supplied `turn` and battle
|
||||||
|
identifier. The `turn` segment must be a non-negative integer; the
|
||||||
|
`uuid` segment must be a valid RFC 4122 UUID. Responds with
|
||||||
|
`404 Not Found` when no battle is stored for the supplied pair.
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/BattleTurnParam"
|
||||||
|
- $ref: "#/components/parameters/BattleIDParam"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Battle report for the supplied turn and identifier.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/BattleReport"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ValidationError"
|
||||||
|
"404":
|
||||||
|
description: No battle exists for the supplied turn and identifier.
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/admin/turn:
|
/api/v1/admin/turn:
|
||||||
put:
|
put:
|
||||||
tags:
|
tags:
|
||||||
@@ -265,6 +292,22 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
default: 0
|
default: 0
|
||||||
|
BattleTurnParam:
|
||||||
|
name: turn
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Turn number the battle was generated on.
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
BattleIDParam:
|
||||||
|
name: uuid
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: Battle identifier (RFC 4122 UUID).
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
schemas:
|
schemas:
|
||||||
HealthzResponse:
|
HealthzResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -541,10 +584,9 @@ components:
|
|||||||
$ref: "#/components/schemas/OtherShipClass"
|
$ref: "#/components/schemas/OtherShipClass"
|
||||||
battle:
|
battle:
|
||||||
type: array
|
type: array
|
||||||
description: UUIDs of battle reports relevant to this turn.
|
description: Battle summaries relevant to this turn.
|
||||||
items:
|
items:
|
||||||
type: string
|
$ref: "#/components/schemas/BattleSummary"
|
||||||
format: uuid
|
|
||||||
bombing:
|
bombing:
|
||||||
type: array
|
type: array
|
||||||
description: Bombing events that occurred during this turn.
|
description: Bombing events that occurred during this turn.
|
||||||
@@ -788,6 +830,148 @@ components:
|
|||||||
wiped:
|
wiped:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: True when all population was eliminated by the bombing.
|
description: True when all population was eliminated by the bombing.
|
||||||
|
BattleSummary:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Identifies one battle relevant to the report recipient. Used by
|
||||||
|
clients to render a battle marker on the map without fetching
|
||||||
|
the full BattleReport. `planet` locates the marker; `shots`
|
||||||
|
scales the marker stroke with the battle length.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- planet
|
||||||
|
- shots
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Battle identifier; fetch the full report via `/api/v1/battle/{turn}/{uuid}`.
|
||||||
|
planet:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Planet number the battle took place on.
|
||||||
|
shots:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Number of shots exchanged during the battle.
|
||||||
|
BattleReport:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Full battle report. `races` and `ships` are JSON objects whose
|
||||||
|
keys are stringified integers used to cross-reference entries
|
||||||
|
from `protocol`: a `BattleActionReport` carries integer indices
|
||||||
|
into both maps. The serialised key is a string because JSON
|
||||||
|
object keys are always strings.
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- planet
|
||||||
|
- planetName
|
||||||
|
- races
|
||||||
|
- ships
|
||||||
|
- protocol
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Battle identifier.
|
||||||
|
planet:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Planet number the battle took place on.
|
||||||
|
planetName:
|
||||||
|
type: string
|
||||||
|
description: Planet name at battle start.
|
||||||
|
races:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Participating races keyed by the integer index used in
|
||||||
|
`protocol.a` / `protocol.d`. Values are race identifiers.
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
ships:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
Participating ship groups keyed by the integer index used
|
||||||
|
in `protocol.sa` / `protocol.sd`.
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/BattleReportGroup"
|
||||||
|
protocol:
|
||||||
|
type: array
|
||||||
|
description: Ordered list of shots exchanged during the battle.
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/BattleActionReport"
|
||||||
|
BattleReportGroup:
|
||||||
|
type: object
|
||||||
|
description: One ship group participating in the battle.
|
||||||
|
required:
|
||||||
|
- race
|
||||||
|
- className
|
||||||
|
- tech
|
||||||
|
- num
|
||||||
|
- numLeft
|
||||||
|
- loadType
|
||||||
|
- loadQuantity
|
||||||
|
- inBattle
|
||||||
|
properties:
|
||||||
|
race:
|
||||||
|
type: string
|
||||||
|
description: Race name of the group owner.
|
||||||
|
className:
|
||||||
|
type: string
|
||||||
|
description: Ship class name; resolvable through `LocalShipClass` or `OtherShipClass`.
|
||||||
|
tech:
|
||||||
|
type: object
|
||||||
|
description: Technology levels keyed by tech type name.
|
||||||
|
additionalProperties:
|
||||||
|
type: number
|
||||||
|
num:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Initial number of ships in this group.
|
||||||
|
numLeft:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
description: Number of ships remaining at the end of the battle.
|
||||||
|
loadType:
|
||||||
|
type: string
|
||||||
|
description: Type of cargo loaded.
|
||||||
|
loadQuantity:
|
||||||
|
type: number
|
||||||
|
description: Quantity of cargo loaded.
|
||||||
|
inBattle:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
True when the group actually fights. False groups observe
|
||||||
|
the battle in peace state and never fire or take damage.
|
||||||
|
BattleActionReport:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
One shot in the battle. Attacker and defender indices reference
|
||||||
|
`BattleReport.races`; ship-class indices reference
|
||||||
|
`BattleReport.ships`.
|
||||||
|
required:
|
||||||
|
- a
|
||||||
|
- sa
|
||||||
|
- d
|
||||||
|
- sd
|
||||||
|
- x
|
||||||
|
properties:
|
||||||
|
a:
|
||||||
|
type: integer
|
||||||
|
description: Index into `BattleReport.races` for the attacker.
|
||||||
|
sa:
|
||||||
|
type: integer
|
||||||
|
description: Index into `BattleReport.ships` for the attacker's group.
|
||||||
|
d:
|
||||||
|
type: integer
|
||||||
|
description: Index into `BattleReport.races` for the defender.
|
||||||
|
sd:
|
||||||
|
type: integer
|
||||||
|
description: Index into `BattleReport.ships` for the defender's group.
|
||||||
|
x:
|
||||||
|
type: boolean
|
||||||
|
description: True when the defender ship was destroyed by this shot.
|
||||||
IncomingGroup:
|
IncomingGroup:
|
||||||
type: object
|
type: object
|
||||||
description: An identified ship group inbound toward a planet of this race.
|
description: An identified ship group inbound toward a planet of this race.
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ func TestGameOpenAPISpecFreezesResponseSchemas(t *testing.T) {
|
|||||||
status: http.StatusOK,
|
status: http.StatusOK,
|
||||||
wantRef: "#/components/schemas/HealthzResponse",
|
wantRef: "#/components/schemas/HealthzResponse",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "get battle",
|
||||||
|
path: "/api/v1/battle/{turn}/{uuid}",
|
||||||
|
method: http.MethodGet,
|
||||||
|
status: http.StatusOK,
|
||||||
|
wantRef: "#/components/schemas/BattleReport",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -271,6 +278,71 @@ func TestGameOpenAPISpecFreezesCommandRequest(t *testing.T) {
|
|||||||
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
|
require.Equal(t, uint64(1), cmdSchema.Value.MinItems, "CommandRequest.cmd minItems must be 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGameOpenAPISpecFreezesGetBattleOperation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
doc := loadOpenAPISpec(t)
|
||||||
|
operation := getOpenAPIOperation(t, doc, "/api/v1/battle/{turn}/{uuid}", http.MethodGet)
|
||||||
|
|
||||||
|
require.Equal(t, "getBattle", operation.OperationID, "GET /api/v1/battle/{turn}/{uuid} operation id")
|
||||||
|
|
||||||
|
paramRefs := make(map[string]bool)
|
||||||
|
for _, p := range operation.Parameters {
|
||||||
|
require.NotNil(t, p.Value, "parameter must have value")
|
||||||
|
paramRefs[p.Ref] = true
|
||||||
|
}
|
||||||
|
require.True(t, paramRefs["#/components/parameters/BattleTurnParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleTurnParam")
|
||||||
|
require.True(t, paramRefs["#/components/parameters/BattleIDParam"], "GET /api/v1/battle/{turn}/{uuid} must reference BattleIDParam")
|
||||||
|
|
||||||
|
require.NotNil(t, operation.Responses, "operation must declare responses")
|
||||||
|
notFound := operation.Responses.Status(http.StatusNotFound)
|
||||||
|
require.NotNil(t, notFound, "operation must declare 404 response")
|
||||||
|
require.NotNil(t, notFound.Value, "404 response must have a value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameOpenAPISpecFreezesBattleReport(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
doc := loadOpenAPISpec(t)
|
||||||
|
|
||||||
|
reportSchema := componentSchemaRef(t, doc, "BattleReport")
|
||||||
|
assertRequiredFields(t, reportSchema, "id", "planet", "planetName", "races", "ships", "protocol")
|
||||||
|
|
||||||
|
groupSchema := componentSchemaRef(t, doc, "BattleReportGroup")
|
||||||
|
assertRequiredFields(t, groupSchema, "race", "className", "tech", "num", "numLeft", "loadType", "loadQuantity", "inBattle")
|
||||||
|
|
||||||
|
actionSchema := componentSchemaRef(t, doc, "BattleActionReport")
|
||||||
|
assertRequiredFields(t, actionSchema, "a", "sa", "d", "sd", "x")
|
||||||
|
|
||||||
|
protocolSchema := reportSchema.Value.Properties["protocol"]
|
||||||
|
require.NotNil(t, protocolSchema, "BattleReport.protocol schema must exist")
|
||||||
|
require.True(t, protocolSchema.Value.Type.Is("array"), "BattleReport.protocol must be array")
|
||||||
|
require.NotNil(t, protocolSchema.Value.Items, "BattleReport.protocol items must be defined")
|
||||||
|
assertSchemaRef(t, protocolSchema.Value.Items, "#/components/schemas/BattleActionReport", "BattleReport.protocol items schema")
|
||||||
|
|
||||||
|
shipsSchema := reportSchema.Value.Properties["ships"]
|
||||||
|
require.NotNil(t, shipsSchema, "BattleReport.ships schema must exist")
|
||||||
|
require.True(t, shipsSchema.Value.Type.Is("object"), "BattleReport.ships must be object")
|
||||||
|
require.NotNil(t, shipsSchema.Value.AdditionalProperties.Schema, "BattleReport.ships additionalProperties must be a schema")
|
||||||
|
assertSchemaRef(t, shipsSchema.Value.AdditionalProperties.Schema, "#/components/schemas/BattleReportGroup", "BattleReport.ships additionalProperties schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGameOpenAPISpecFreezesBattleSummary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
doc := loadOpenAPISpec(t)
|
||||||
|
|
||||||
|
summary := componentSchemaRef(t, doc, "BattleSummary")
|
||||||
|
assertRequiredFields(t, summary, "id", "planet", "shots")
|
||||||
|
|
||||||
|
report := componentSchemaRef(t, doc, "Report")
|
||||||
|
battle := report.Value.Properties["battle"]
|
||||||
|
require.NotNil(t, battle, "Report.battle schema must exist")
|
||||||
|
require.True(t, battle.Value.Type.Is("array"), "Report.battle must be array")
|
||||||
|
require.NotNil(t, battle.Value.Items, "Report.battle items must be defined")
|
||||||
|
assertSchemaRef(t, battle.Value.Items, "#/components/schemas/BattleSummary", "Report.battle items schema")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
|
func TestGameOpenAPISpecHealthzStatusEnum(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -385,6 +385,12 @@ The current direct `Gateway -> User` self-service boundary uses that pattern:
|
|||||||
- business error projection:
|
- business error projection:
|
||||||
- gateway `result_code`
|
- gateway `result_code`
|
||||||
- FlatBuffers error payload mirroring User Service `code` and `message`
|
- FlatBuffers error payload mirroring User Service `code` and `message`
|
||||||
|
- User Service `code` values pass through verbatim as `result_code`
|
||||||
|
via `projectUserBackendError`; known non-`ok` codes that clients
|
||||||
|
branch on include `turn_already_closed` (Phase 25 turn cutoff,
|
||||||
|
HTTP 409 from `Orders` / `Commands` while the runtime is in
|
||||||
|
`generation_in_progress`) and `game_paused` (Phase 25 auto-pause,
|
||||||
|
HTTP 409 while the game is in `paused` / `finished` / `removed`).
|
||||||
|
|
||||||
The request envelope version literal is `v1`.
|
The request envelope version literal is `v1`.
|
||||||
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
|
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
|
||||||
|
|||||||
@@ -11,3 +11,29 @@ func PlanetProduceShipMass(L, Mat, Res float64) float64 {
|
|||||||
}
|
}
|
||||||
return (L + Mat/Res) / (10 + 1/Res)
|
return (L + Mat/Res) / (10 + 1/Res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShipBuildCost returns the total per-turn cost (production units) to
|
||||||
|
// build one ship of empty mass shipMass on a planet that currently
|
||||||
|
// holds material stockpile and has natural resources. The cost is the
|
||||||
|
// ship's production cost ([ShipProductionCost]) plus the cost of
|
||||||
|
// farming any missing material from the planet (the missing-material
|
||||||
|
// volume divided by the planet's resources rating).
|
||||||
|
//
|
||||||
|
// resources is expected to be positive in normal play; the helper
|
||||||
|
// guards against a non-positive value by collapsing the material-
|
||||||
|
// farming term to zero, which keeps callers numerically stable on
|
||||||
|
// pathological synthetic data. Mirrors the per-iteration math inside
|
||||||
|
// the engine's controller.ProduceShip so both surfaces — and the
|
||||||
|
// legacy-report-to-json dev tool that needs to derive prod_used from
|
||||||
|
// percent — share the same formula.
|
||||||
|
func ShipBuildCost(shipMass, material, resources float64) float64 {
|
||||||
|
matNeed := shipMass - material
|
||||||
|
if matNeed < 0 {
|
||||||
|
matNeed = 0
|
||||||
|
}
|
||||||
|
matFarm := 0.
|
||||||
|
if resources > 0 {
|
||||||
|
matFarm = matNeed / resources
|
||||||
|
}
|
||||||
|
return ShipProductionCost(shipMass) + matFarm
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package calc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galaxy/calc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShipBuildCost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
shipMass float64
|
||||||
|
material float64
|
||||||
|
resources float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "material exceeds mass: no farming needed",
|
||||||
|
shipMass: 5,
|
||||||
|
material: 10,
|
||||||
|
resources: 0.5,
|
||||||
|
want: 50, // ShipProductionCost(5) = 50; matFarm = 0.
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "material equal to mass: no farming needed",
|
||||||
|
shipMass: 5,
|
||||||
|
material: 5,
|
||||||
|
resources: 0.5,
|
||||||
|
want: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "material short of mass: farming term added",
|
||||||
|
shipMass: 10,
|
||||||
|
material: 3,
|
||||||
|
resources: 0.5,
|
||||||
|
want: 114, // 100 + (7 / 0.5).
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no material at all: full mass farmed",
|
||||||
|
shipMass: 4,
|
||||||
|
material: 0,
|
||||||
|
resources: 0.5,
|
||||||
|
want: 48, // 40 + (4 / 0.5).
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero resources collapses farming term to zero",
|
||||||
|
shipMass: 10,
|
||||||
|
material: 3,
|
||||||
|
resources: 0,
|
||||||
|
want: 100, // 100 + 0; resources == 0 is a pathological guard.
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := calc.ShipBuildCost(tc.shipMass, tc.material, tc.resources)
|
||||||
|
if math.Abs(got-tc.want) > 1e-9 {
|
||||||
|
t.Errorf("ShipBuildCost(%v, %v, %v) = %v, want %v",
|
||||||
|
tc.shipMass, tc.material, tc.resources, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-19
@@ -6,32 +6,64 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BattleSummary identifies one battle relevant to the report recipient
|
||||||
|
// and carries the data needed to render a battle marker on the map
|
||||||
|
// without fetching the full BattleReport. Planet locates the marker;
|
||||||
|
// Shots scales the marker stroke with the battle length.
|
||||||
|
type BattleSummary struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Planet uint `json:"planet"`
|
||||||
|
Shots uint `json:"shots"`
|
||||||
|
}
|
||||||
|
|
||||||
type BattleReport struct {
|
type BattleReport struct {
|
||||||
ID uuid.UUID `json:"id"`
|
// Battle unique ID
|
||||||
Planet uint `json:"planet"`
|
ID uuid.UUID `json:"id"`
|
||||||
PlanetName string `json:"planetName"`
|
// Planet number
|
||||||
Races map[int]uuid.UUID `json:"races"`
|
Planet uint `json:"planet"`
|
||||||
Ships map[int]BattleReportGroup `json:"ships"`
|
// Planet name at battle start
|
||||||
Protocol []BattleActionReport `json:"protocol"`
|
PlanetName string `json:"planetName"`
|
||||||
|
// Races participating map: <key:RaceID>
|
||||||
|
Races map[int]uuid.UUID `json:"races"`
|
||||||
|
// Ships Groups participating map: <key:BattleReportGroup>
|
||||||
|
Ships map[int]BattleReportGroup `json:"ships"`
|
||||||
|
// Battle's firing protocol
|
||||||
|
Protocol []BattleActionReport `json:"protocol"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BattleReportGroup struct {
|
type BattleReportGroup struct {
|
||||||
InBattle bool `json:"inBattle"`
|
// Name of the race
|
||||||
Number uint `json:"num"`
|
Race string `json:"race"`
|
||||||
NumberLeft uint `json:"numLeft"`
|
// Name of the Ship Class.
|
||||||
LoadQuantity Float `json:"loadQuantity"`
|
// By design, ship's info MUST be present in Game's Repors in 'LocalShipClass' or 'OtherShipClass'
|
||||||
Tech map[string]Float `json:"tech"`
|
ClassName string `json:"className"`
|
||||||
Race string `json:"race"`
|
// Ship Group's technologies mapping <tech:level>
|
||||||
ClassName string `json:"className"`
|
Tech map[string]Float `json:"tech"`
|
||||||
LoadType string `json:"loadType"`
|
// Initial number of ships in this group
|
||||||
|
Number uint `json:"num"`
|
||||||
|
// Number of ships left after battle
|
||||||
|
NumberLeft uint `json:"numLeft"`
|
||||||
|
// Type of cargo loaded
|
||||||
|
LoadType string `json:"loadType"`
|
||||||
|
// Quantity of cargo loaded
|
||||||
|
LoadQuantity Float `json:"loadQuantity"`
|
||||||
|
// A Race with its ships can be in Peace state with all participants,
|
||||||
|
// so no shots will be fired and no damage taken, participating only as viewer
|
||||||
|
// when InBattle=false
|
||||||
|
InBattle bool `json:"inBattle"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BattleActionReport struct {
|
type BattleActionReport struct {
|
||||||
Attacker int `json:"a"`
|
// `key` from BattleReport.Races map
|
||||||
AttackerShipClass int `json:"sa"`
|
Attacker int `json:"a"`
|
||||||
Defender int `json:"d"`
|
// `key` from BattleReport.Ships map
|
||||||
DefenderShipClass int `json:"sd"`
|
AttackerShipClass int `json:"sa"`
|
||||||
Destroyed bool `json:"x"`
|
// `key` from BattleReport.Races map
|
||||||
|
Defender int `json:"d"`
|
||||||
|
// `key` from BattleReport.Ships map
|
||||||
|
DefenderShipClass int `json:"sd"`
|
||||||
|
// Was ship destroyed after attack or survived under shields
|
||||||
|
Destroyed bool `json:"x"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b BattleReport) MarshalBinary() (data []byte, err error) {
|
func (b BattleReport) MarshalBinary() (data []byte, err error) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ type Report struct {
|
|||||||
OtherScience []OtherScience `json:"otherScience,omitempty"`
|
OtherScience []OtherScience `json:"otherScience,omitempty"`
|
||||||
LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
|
LocalShipClass []ShipClass `json:"localShipClass,omitempty"`
|
||||||
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
|
OtherShipClass []OthersShipClass `json:"otherShipClass,omitempty"`
|
||||||
Battle []uuid.UUID `json:"battle,omitempty"`
|
Battle []BattleSummary `json:"battle,omitempty"`
|
||||||
Bombing []*Bombing `json:"bombing,omitempty"`
|
Bombing []*Bombing `json:"bombing,omitempty"`
|
||||||
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
|
IncomingGroup []IncomingGroup `json:"incomingGroup,omitempty"`
|
||||||
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
|
LocalPlanet []LocalPlanet `json:"localPlanet,omitempty"`
|
||||||
|
|||||||
@@ -196,6 +196,17 @@ table LocalFleet {
|
|||||||
state:string;
|
state:string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BattleSummary identifies one battle the report recipient
|
||||||
|
// participated in or could see on a planet. `planet` lets the map
|
||||||
|
// place a battle marker without fetching the full BattleReport;
|
||||||
|
// `shots` lets the marker scale its stroke with the protocol length
|
||||||
|
// (1 shot → thinnest cross, 100+ shots → maximum cross thickness).
|
||||||
|
table BattleSummary {
|
||||||
|
id:common.UUID (required);
|
||||||
|
planet:uint64;
|
||||||
|
shots:uint64;
|
||||||
|
}
|
||||||
|
|
||||||
table Report {
|
table Report {
|
||||||
version:uint64;
|
version:uint64;
|
||||||
turn:uint64;
|
turn:uint64;
|
||||||
@@ -210,7 +221,7 @@ table Report {
|
|||||||
other_science:[OtherScience];
|
other_science:[OtherScience];
|
||||||
local_ship_class:[ShipClass];
|
local_ship_class:[ShipClass];
|
||||||
other_ship_class:[OthersShipClass];
|
other_ship_class:[OthersShipClass];
|
||||||
battle:[common.UUID];
|
battle:[BattleSummary];
|
||||||
bombing:[Bombing];
|
bombing:[Bombing];
|
||||||
incoming_group:[IncomingGroup];
|
incoming_group:[IncomingGroup];
|
||||||
local_planet:[LocalPlanet];
|
local_planet:[LocalPlanet];
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||||
|
|
||||||
|
package report
|
||||||
|
|
||||||
|
import (
|
||||||
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
|
common "galaxy/schema/fbs/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BattleSummary struct {
|
||||||
|
_tab flatbuffers.Table
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||||
|
x := &BattleSummary{}
|
||||||
|
x.Init(buf, n+offset)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.Finish(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSizePrefixedRootAsBattleSummary(buf []byte, offset flatbuffers.UOffsetT) *BattleSummary {
|
||||||
|
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||||
|
x := &BattleSummary{}
|
||||||
|
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func FinishSizePrefixedBattleSummaryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||||
|
builder.FinishSizePrefixed(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||||
|
rcv._tab.Bytes = buf
|
||||||
|
rcv._tab.Pos = i
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) Table() flatbuffers.Table {
|
||||||
|
return rcv._tab
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) Id(obj *common.UUID) *common.UUID {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||||
|
if o != 0 {
|
||||||
|
x := o + rcv._tab.Pos
|
||||||
|
if obj == nil {
|
||||||
|
obj = new(common.UUID)
|
||||||
|
}
|
||||||
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) Planet() uint64 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint64(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) MutatePlanet(n uint64) bool {
|
||||||
|
return rcv._tab.MutateUint64Slot(6, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) Shots() uint64 {
|
||||||
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||||
|
if o != 0 {
|
||||||
|
return rcv._tab.GetUint64(o + rcv._tab.Pos)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rcv *BattleSummary) MutateShots(n uint64) bool {
|
||||||
|
return rcv._tab.MutateUint64Slot(8, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BattleSummaryStart(builder *flatbuffers.Builder) {
|
||||||
|
builder.StartObject(3)
|
||||||
|
}
|
||||||
|
func BattleSummaryAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
|
||||||
|
builder.PrependStructSlot(0, flatbuffers.UOffsetT(id), 0)
|
||||||
|
}
|
||||||
|
func BattleSummaryAddPlanet(builder *flatbuffers.Builder, planet uint64) {
|
||||||
|
builder.PrependUint64Slot(1, planet, 0)
|
||||||
|
}
|
||||||
|
func BattleSummaryAddShots(builder *flatbuffers.Builder, shots uint64) {
|
||||||
|
builder.PrependUint64Slot(2, shots, 0)
|
||||||
|
}
|
||||||
|
func BattleSummaryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
|
return builder.EndObject()
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ package report
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
flatbuffers "github.com/google/flatbuffers/go"
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
|
|
||||||
common "galaxy/schema/fbs/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Report struct {
|
type Report struct {
|
||||||
@@ -231,11 +229,12 @@ func (rcv *Report) OtherShipClassLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *Report) Battle(obj *common.UUID, j int) bool {
|
func (rcv *Report) Battle(obj *BattleSummary, j int) bool {
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
|
o := flatbuffers.UOffsetT(rcv._tab.Offset(30))
|
||||||
if o != 0 {
|
if o != 0 {
|
||||||
x := rcv._tab.Vector(o)
|
x := rcv._tab.Vector(o)
|
||||||
x += flatbuffers.UOffsetT(j) * 16
|
x += flatbuffers.UOffsetT(j) * 4
|
||||||
|
x = rcv._tab.Indirect(x)
|
||||||
obj.Init(rcv._tab.Bytes, x)
|
obj.Init(rcv._tab.Bytes, x)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -551,7 +550,7 @@ func ReportAddBattle(builder *flatbuffers.Builder, battle flatbuffers.UOffsetT)
|
|||||||
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
|
builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(battle), 0)
|
||||||
}
|
}
|
||||||
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func ReportStartBattleVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(16, numElems, 8)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
|
func ReportAddBombing(builder *flatbuffers.Builder, bombing flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
|
builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(bombing), 0)
|
||||||
|
|||||||
+36
-12
@@ -10,7 +10,6 @@ import (
|
|||||||
fbs "galaxy/schema/fbs/report"
|
fbs "galaxy/schema/fbs/report"
|
||||||
|
|
||||||
flatbuffers "github.com/google/flatbuffers/go"
|
flatbuffers "github.com/google/flatbuffers/go"
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ReportToPayload converts model.Report from the internal representation to
|
// ReportToPayload converts model.Report from the internal representation to
|
||||||
@@ -120,7 +119,7 @@ func ReportToPayload(report *model.Report) ([]byte, error) {
|
|||||||
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
|
otherScienceVector := encodeReportOffsetVector(builder, len(otherScienceOffsets), fbs.ReportStartOtherScienceVector, otherScienceOffsets)
|
||||||
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
|
localShipClassVector := encodeReportOffsetVector(builder, len(localShipClassOffsets), fbs.ReportStartLocalShipClassVector, localShipClassOffsets)
|
||||||
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
|
otherShipClassVector := encodeReportOffsetVector(builder, len(otherShipClassOffsets), fbs.ReportStartOtherShipClassVector, otherShipClassOffsets)
|
||||||
battleVector := encodeReportUUIDVector(builder, report.Battle)
|
battleVector := encodeReportBattleSummaries(builder, report.Battle)
|
||||||
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
|
bombingVector := encodeReportOffsetVector(builder, len(bombingOffsets), fbs.ReportStartBombingVector, bombingOffsets)
|
||||||
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
|
incomingGroupVector := encodeReportOffsetVector(builder, len(incomingGroupOffsets), fbs.ReportStartIncomingGroupVector, incomingGroupOffsets)
|
||||||
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
|
localPlanetVector := encodeReportOffsetVector(builder, len(localPlanetOffsets), fbs.ReportStartLocalPlanetVector, localPlanetOffsets)
|
||||||
@@ -734,13 +733,29 @@ func decodeReportBattleVector(flatReport *fbs.Report, result *model.Report) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Battle = make([]uuid.UUID, length)
|
result.Battle = make([]model.BattleSummary, length)
|
||||||
item := new(commonfbs.UUID)
|
item := new(fbs.BattleSummary)
|
||||||
|
idHolder := new(commonfbs.UUID)
|
||||||
for i := 0; i < length; i++ {
|
for i := 0; i < length; i++ {
|
||||||
if !flatReport.Battle(item, i) {
|
if !flatReport.Battle(item, i) {
|
||||||
|
return fmt.Errorf("decode report battle %d: battle is missing", i)
|
||||||
|
}
|
||||||
|
if item.Id(idHolder) == nil {
|
||||||
return fmt.Errorf("decode report battle %d: battle id is missing", i)
|
return fmt.Errorf("decode report battle %d: battle id is missing", i)
|
||||||
}
|
}
|
||||||
result.Battle[i] = uuidFromHiLo(item.Hi(), item.Lo())
|
planet, err := uint64ToUint(item.Planet(), "planet")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode report battle %d: %w", i, err)
|
||||||
|
}
|
||||||
|
shots, err := uint64ToUint(item.Shots(), "shots")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("decode report battle %d: %w", i, err)
|
||||||
|
}
|
||||||
|
result.Battle[i] = model.BattleSummary{
|
||||||
|
ID: uuidFromHiLo(idHolder.Hi(), idHolder.Lo()),
|
||||||
|
Planet: planet,
|
||||||
|
Shots: shots,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -1299,17 +1314,26 @@ func encodeReportOffsetVector(
|
|||||||
return builder.EndVector(length)
|
return builder.EndVector(length)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeReportUUIDVector(builder *flatbuffers.Builder, ids []uuid.UUID) flatbuffers.UOffsetT {
|
func encodeReportBattleSummaries(builder *flatbuffers.Builder, summaries []model.BattleSummary) flatbuffers.UOffsetT {
|
||||||
if len(ids) == 0 {
|
if len(summaries) == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
fbs.ReportStartBattleVector(builder, len(ids))
|
offsets := make([]flatbuffers.UOffsetT, len(summaries))
|
||||||
for i := len(ids) - 1; i >= 0; i-- {
|
for i := range summaries {
|
||||||
hi, lo := uuidToHiLo(ids[i])
|
hi, lo := uuidToHiLo(summaries[i].ID)
|
||||||
commonfbs.CreateUUID(builder, hi, lo)
|
fbs.BattleSummaryStart(builder)
|
||||||
|
fbs.BattleSummaryAddId(builder, commonfbs.CreateUUID(builder, hi, lo))
|
||||||
|
fbs.BattleSummaryAddPlanet(builder, uint64(summaries[i].Planet))
|
||||||
|
fbs.BattleSummaryAddShots(builder, uint64(summaries[i].Shots))
|
||||||
|
offsets[i] = fbs.BattleSummaryEnd(builder)
|
||||||
}
|
}
|
||||||
return builder.EndVector(len(ids))
|
|
||||||
|
fbs.ReportStartBattleVector(builder, len(offsets))
|
||||||
|
for i := len(offsets) - 1; i >= 0; i-- {
|
||||||
|
builder.PrependUOffsetT(offsets[i])
|
||||||
|
}
|
||||||
|
return builder.EndVector(len(offsets))
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
|
func encodeReportRouteEntryVector(builder *flatbuffers.Builder, route map[uint]string) flatbuffers.UOffsetT {
|
||||||
|
|||||||
@@ -255,9 +255,17 @@ func sampleReport() *model.Report {
|
|||||||
OtherShipClass: []model.OthersShipClass{
|
OtherShipClass: []model.OthersShipClass{
|
||||||
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
|
{Race: "Martians", ShipClass: model.ShipClass{Name: "destroyer", Drive: model.Float(1.75), Armament: 6, Weapons: model.Float(2.25), Shields: model.Float(2.75), Cargo: model.Float(3.25), Mass: model.Float(10.5)}},
|
||||||
},
|
},
|
||||||
Battle: []uuid.UUID{
|
Battle: []model.BattleSummary{
|
||||||
uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
{
|
||||||
uuid.MustParse("22222222-2222-2222-2222-222222222222"),
|
ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"),
|
||||||
|
Planet: 4,
|
||||||
|
Shots: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: uuid.MustParse("22222222-2222-2222-2222-222222222222"),
|
||||||
|
Planet: 11,
|
||||||
|
Shots: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Bombing: []*model.Bombing{
|
Bombing: []*model.Bombing{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
# legacy-report-to-json
|
# legacy-report-to-json
|
||||||
|
|
||||||
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
|
Converts legacy text-format Galaxy turn reports (the *dg* and *gplus*
|
||||||
engines that lived under `tools/local-dev/reports/`) into the JSON
|
engines that lived under `tools/local-dev/reports/`) into a JSON
|
||||||
shape of [`pkg/model/report.Report`](../../../pkg/model/report).
|
envelope around [`pkg/model/report.Report`](../../../pkg/model/report)
|
||||||
|
plus full `BattleReport`s (Phase 27).
|
||||||
|
|
||||||
|
## Output envelope
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"report": { /* report.Report */ },
|
||||||
|
"battles": { "<uuid>": { /* report.BattleReport */ }, ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`version: 1` lets the UI distinguish a current-format envelope from a
|
||||||
|
bare `Report` JSON. The synthetic-report loader accepts both — pre-
|
||||||
|
envelope synthetic JSON files still load, just without battle
|
||||||
|
fixtures. `battles` is omitted when the legacy file has no combat
|
||||||
|
events.
|
||||||
|
|
||||||
The output is consumed by the **DEV-only synthetic-report loader** on
|
The output is consumed by the **DEV-only synthetic-report loader** on
|
||||||
the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
|
the UI client's lobby (`import.meta.env.DEV`). With it, the map view,
|
||||||
@@ -17,8 +34,8 @@ The tool is part of the synthetic-report parity rule documented in
|
|||||||
```sh
|
```sh
|
||||||
# from the repo root, with the Go workspace active
|
# from the repo root, with the Go workspace active
|
||||||
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
|
go run ./tools/local-dev/legacy-report/cmd/legacy-report-to-json \
|
||||||
--in tools/local-dev/reports/dg/KNNTS039.REP \
|
--in tools/local-dev/reports/dg/KNNTS041.REP \
|
||||||
--out tools/local-dev/reports/dg/KNNTS039.json
|
--out tools/local-dev/reports/dg/KNNTS041.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or
|
`--in` reads `-` as stdin; `--out` defaults to stdout when empty or
|
||||||
@@ -60,9 +77,29 @@ already decodes from server responses
|
|||||||
| `UninhabitedPlanet[]` | `Uninhabited Planets` |
|
| `UninhabitedPlanet[]` | `Uninhabited Planets` |
|
||||||
| `UnidentifiedPlanet[]`| `Unidentified Planets` |
|
| `UnidentifiedPlanet[]`| `Unidentified Planets` |
|
||||||
| `LocalShipClass[]` | `Your Ship Types` |
|
| `LocalShipClass[]` | `Your Ship Types` |
|
||||||
|
| `OtherShipClass[]` | `<Race> Ship Types` (Phase 23) |
|
||||||
|
| `LocalScience[]` | `Your Sciences` (Phase 23) |
|
||||||
|
| `OtherScience[]` | `<Race> Sciences` (Phase 23) |
|
||||||
|
| `Bombing[]` | `Bombings` (Phase 23) |
|
||||||
|
| `ShipProduction[]` | `Ships In Production` (Phase 23) |
|
||||||
| `LocalGroup[]` | `Your Groups` (Phase 19) |
|
| `LocalGroup[]` | `Your Groups` (Phase 19) |
|
||||||
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
|
| `LocalFleet[]` | `Your Fleets` (Phase 19) |
|
||||||
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
|
| `IncomingGroup[]` | `Incoming Groups` (Phase 19) |
|
||||||
|
| `Battle[]` (summary) | `Battle at (#N) Name` headers + `Battle Protocol` (Phase 27 follow-up) |
|
||||||
|
|
||||||
|
The envelope's `battles` map carries the full `BattleReport`-s parsed
|
||||||
|
out of the same blocks: every roster row turns into a
|
||||||
|
`BattleReportGroup` (`Number`/`Tech`/`LoadType`/`LoadQuantity`/
|
||||||
|
`NumberLeft`/`InBattle`), every `... fires on ... : Destroyed|Shields`
|
||||||
|
line turns into a `BattleActionReport`. UUIDs are synthesised
|
||||||
|
deterministically — `syntheticBattleID(idx)` for the battle
|
||||||
|
identifier (per-report 0-based index, SHA1 namespace
|
||||||
|
`be01a000-0000-0000-0000-000000000002`) and
|
||||||
|
`syntheticBattleRaceID(name)` for `BattleReport.Races` entries (SHA1
|
||||||
|
namespace `be01a000-0000-0000-0000-000000000003`). Re-running the
|
||||||
|
converter on the same input file yields byte-identical JSON, so
|
||||||
|
synthetic-mode UI URLs (`/games/synthetic-…/battle/<uuid>?turn=N`)
|
||||||
|
stay stable across regenerations.
|
||||||
|
|
||||||
Players whose name in the legacy file ends with `_RIP` are emitted with
|
Players whose name in the legacy file ends with `_RIP` are emitted with
|
||||||
the suffix stripped and `Extinct: true`.
|
the suffix stripped and `Extinct: true`.
|
||||||
@@ -78,29 +115,31 @@ tables (foreign-only knowledge the local player lacks) cause the entire
|
|||||||
group / fleet / incoming row to be dropped — preferable to fabricating
|
group / fleet / incoming row to be dropped — preferable to fabricating
|
||||||
a destination.
|
a destination.
|
||||||
|
|
||||||
|
`ShipProduction.ProdUsed` is derived from the on-disk `Percent` and the
|
||||||
|
producing planet's material/resources via [`pkg/calc.ShipBuildCost`]
|
||||||
|
(the same helper the engine's `controller.ProduceShip` uses). The
|
||||||
|
legacy text format does not carry a `prod_used` column directly; the
|
||||||
|
derivation gives the cumulative production-equivalent of the build
|
||||||
|
progress so far. The real engine's `ProdUsed` is the per-turn
|
||||||
|
residual production poured into the partial ship, which is not
|
||||||
|
recoverable from a single legacy snapshot. The two numbers stay in
|
||||||
|
the same units and the same ballpark, which is good enough for the
|
||||||
|
synthetic-mode UI — live engine reports come over the FBS wire and
|
||||||
|
do not flow through this parser. A ships-in-production row pointing
|
||||||
|
at a planet that did not appear in `Your Planets` (which would be a
|
||||||
|
malformed legacy file) is dropped.
|
||||||
|
|
||||||
## Skipped sections (today)
|
## Skipped sections (today)
|
||||||
|
|
||||||
These exist in legacy reports but either have no UI decoder yet or
|
These exist in legacy reports but cannot be derived from the legacy
|
||||||
cannot be derived from the legacy text format at all. Each becomes
|
text format at all. Each could become in-scope if a strong enough
|
||||||
in-scope as soon as its UI phase lands (see "Adding a new field"
|
reason arises (see "Adding a new field" below).
|
||||||
below).
|
|
||||||
|
|
||||||
- Foreign / other ship types (`<Race> Ship Types`)
|
|
||||||
- Sciences, both local (`Your Sciences`) and foreign (`<Race> Sciences`)
|
|
||||||
- Battles (`Battle at (#N) Name`, `Battle Protocol`) — battle rosters
|
|
||||||
inside these blocks carry minimal columns (no origin / range /
|
|
||||||
destination) and are intentionally skipped: parsing them would
|
|
||||||
produce mostly-empty `OtherGroup` records that drift away from the
|
|
||||||
typed contract.
|
|
||||||
- Bombings (`Bombings`)
|
|
||||||
- Ships in production (`Ships In Production`)
|
|
||||||
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
|
- `OtherGroup[]` — no top-level legacy section. Foreign groups appear
|
||||||
only inside battle rosters (see above), with stripped columns; the
|
only inside battle rosters; the synthetic JSON emits
|
||||||
synthetic JSON emits `otherGroup: []`.
|
`otherGroup: []`.
|
||||||
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
|
- `UnidentifiedGroup[]` — no legacy section at all; synthetic JSON
|
||||||
emits `unidentifiedGroup: []`.
|
emits `unidentifiedGroup: []`.
|
||||||
- `OtherShipClass[]` — present in legacy as `<Race> Ship Types`, but
|
|
||||||
no UI decoder yet; synthetic JSON emits `otherShipClass: []`.
|
|
||||||
- Cargo routes — no dedicated section in the legacy text format; the
|
- Cargo routes — no dedicated section in the legacy text format; the
|
||||||
synthetic JSON emits `route: []`. The UI's overlay path
|
synthetic JSON emits `route: []`. The UI's overlay path
|
||||||
(`applyOrderOverlay`) supports running on top of an empty `routes`.
|
(`applyOrderOverlay`) supports running on top of an empty `routes`.
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
// Command legacy-report-to-json converts a legacy text-format Galaxy
|
// Command legacy-report-to-json converts a legacy text-format Galaxy
|
||||||
// turn report (the "dg" / "gplus" engines) into the JSON shape of
|
// turn report (the "dg" / "gplus" engines) into a JSON envelope
|
||||||
// pkg/model/report.Report. The resulting file is what the UI client's
|
// readable by the UI client's DEV-only synthetic-report loader:
|
||||||
// DEV-only synthetic-report loader on the lobby consumes.
|
//
|
||||||
|
// {
|
||||||
|
// "version": 1,
|
||||||
|
// "report": <Report JSON>,
|
||||||
|
// "battles": { "<uuid>": <BattleReport JSON>, ... }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Carrying the per-turn report and the full BattleReports in one
|
||||||
|
// payload lets the synthetic loader register the battles up-front
|
||||||
|
// so the Battle Viewer can render any battle without a network
|
||||||
|
// fetch. The bare Report shape (no envelope) the lobby loader
|
||||||
|
// historically accepted remains backward-compatible on the UI side.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,8 +23,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
legacyreport "galaxy/legacy-report"
|
legacyreport "galaxy/legacy-report"
|
||||||
|
"galaxy/model/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// envelope is the on-disk shape emitted by this CLI. `Version` lets
|
||||||
|
// the UI loader distinguish a v1 envelope from a bare Report; future
|
||||||
|
// versions can bump it without breaking older synthetic JSON files.
|
||||||
|
type envelope struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Report report.Report `json:"report"`
|
||||||
|
Battles map[string]report.BattleReport `json:"battles,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
|
in := flag.String("in", "", "path to legacy .REP file (use - for stdin)")
|
||||||
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
|
out := flag.String("out", "", "path to write JSON to (use - or empty for stdout)")
|
||||||
@@ -31,7 +52,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer closeIn()
|
defer closeIn()
|
||||||
|
|
||||||
rep, err := legacyreport.Parse(r)
|
rep, battles, err := legacyreport.Parse(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "parse: %v\n", err)
|
fmt.Fprintf(os.Stderr, "parse: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -44,9 +65,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer closeOut()
|
defer closeOut()
|
||||||
|
|
||||||
|
env := envelope{Version: 1, Report: rep}
|
||||||
|
if len(battles) > 0 {
|
||||||
|
env.Battles = make(map[string]report.BattleReport, len(battles))
|
||||||
|
for i := range battles {
|
||||||
|
env.Battles[battles[i].ID.String()] = battles[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
if err := enc.Encode(rep); err != nil {
|
if err := enc.Encode(env); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
fmt.Fprintf(os.Stderr, "encode: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,26 +21,34 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"galaxy/calc"
|
||||||
"galaxy/model/report"
|
"galaxy/model/report"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse reads a legacy text report and returns a [report.Report]
|
// Parse reads a legacy text report and returns a [report.Report]
|
||||||
// carrying the in-scope subset of fields. The Width and Height of the
|
// carrying the in-scope subset of fields, plus the per-battle
|
||||||
// returned report are both set to the legacy "Size" value (galaxies
|
// [report.BattleReport] payloads parsed out of the "Battle at (#N)"
|
||||||
// are square in the legacy engines).
|
// blocks. The Width and Height of the returned report are both set
|
||||||
func Parse(r io.Reader) (report.Report, error) {
|
// to the legacy "Size" value (galaxies are square in the legacy
|
||||||
|
// engines). The battle slice is empty when the legacy file carries
|
||||||
|
// no combat events.
|
||||||
|
func Parse(r io.Reader) (report.Report, []report.BattleReport, error) {
|
||||||
p := newParser()
|
p := newParser()
|
||||||
sc := bufio.NewScanner(r)
|
sc := bufio.NewScanner(r)
|
||||||
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
|
sc.Buffer(make([]byte, 1024*1024), 4*1024*1024)
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
if err := p.handle(sc.Text()); err != nil {
|
if err := p.handle(sc.Text()); err != nil {
|
||||||
return report.Report{}, err
|
return report.Report{}, nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := sc.Err(); err != nil {
|
if err := sc.Err(); err != nil {
|
||||||
return report.Report{}, fmt.Errorf("legacyreport: scan: %w", err)
|
return report.Report{}, nil, fmt.Errorf("legacyreport: scan: %w", err)
|
||||||
}
|
}
|
||||||
return p.finish()
|
battles, err := p.finish()
|
||||||
|
if err != nil {
|
||||||
|
return report.Report{}, nil, err
|
||||||
|
}
|
||||||
|
return p.rep, battles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type section int
|
type section int
|
||||||
@@ -57,6 +65,13 @@ const (
|
|||||||
sectionYourGroups
|
sectionYourGroups
|
||||||
sectionYourFleets
|
sectionYourFleets
|
||||||
sectionIncomingGroups
|
sectionIncomingGroups
|
||||||
|
sectionYourSciences
|
||||||
|
sectionOtherSciences
|
||||||
|
sectionOtherShipTypes
|
||||||
|
sectionBombings
|
||||||
|
sectionShipsInProduction
|
||||||
|
sectionBattle
|
||||||
|
sectionBattleProtocol
|
||||||
)
|
)
|
||||||
|
|
||||||
type parser struct {
|
type parser struct {
|
||||||
@@ -71,10 +86,48 @@ type parser struct {
|
|||||||
// they carry destination/origin planet names that may resolve
|
// they carry destination/origin planet names that may resolve
|
||||||
// against the planet tables only after the whole file has been
|
// against the planet tables only after the whole file has been
|
||||||
// read — "Incoming Groups" can appear before "Your Planets" in
|
// read — "Incoming Groups" can appear before "Your Planets" in
|
||||||
// some engine variants.
|
// some engine variants. Ships-in-production rows are buffered
|
||||||
pendingGroups []pendingGroup
|
// because their prod_used derivation needs the producing planet's
|
||||||
pendingFleets []pendingFleet
|
// material and resources (read from "Your Planets") to call
|
||||||
pendingIncomings []pendingIncoming
|
// [calc.ShipBuildCost], and the section order is not guaranteed.
|
||||||
|
pendingGroups []pendingGroup
|
||||||
|
pendingFleets []pendingFleet
|
||||||
|
pendingIncomings []pendingIncoming
|
||||||
|
pendingShipProducts []pendingShipProduction
|
||||||
|
|
||||||
|
// Battle accumulator. `battles` collects every parsed BattleReport;
|
||||||
|
// `pendingBattle` carries the in-flight battle until its block
|
||||||
|
// ends (next "Battle at " header, a top-level section header, or
|
||||||
|
// end-of-file). `battleIndex` is the per-report 0-based index used
|
||||||
|
// to derive a stable synthetic UUID through `syntheticBattleID`.
|
||||||
|
// `pendingBattleRace` holds the race name currently being
|
||||||
|
// rostered, set by the "<Race> Groups" sub-header that opens each
|
||||||
|
// race's roster table inside the battle block.
|
||||||
|
battles []report.BattleReport
|
||||||
|
pendingBattle *pendingBattle
|
||||||
|
battleIndex uint
|
||||||
|
pendingBattleRace string
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingBattle struct {
|
||||||
|
id uuid.UUID
|
||||||
|
planet uint
|
||||||
|
planetName string
|
||||||
|
// Race name → race index used in Protocol.{a,d}. Indices are
|
||||||
|
// 0-based and assigned in first-seen order across the battle.
|
||||||
|
raceIndex map[string]int
|
||||||
|
// (race name, class name) → ship-group index used in
|
||||||
|
// Protocol.{sa,sd}. Indices are 0-based and assigned in
|
||||||
|
// first-seen order across the battle, across all races.
|
||||||
|
shipIndex map[shipKey]int
|
||||||
|
races map[int]uuid.UUID
|
||||||
|
ships map[int]report.BattleReportGroup
|
||||||
|
protocol []report.BattleActionReport
|
||||||
|
}
|
||||||
|
|
||||||
|
type shipKey struct {
|
||||||
|
race string
|
||||||
|
class string
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingGroup struct {
|
type pendingGroup struct {
|
||||||
@@ -112,6 +165,14 @@ type pendingIncoming struct {
|
|||||||
mass float64
|
mass float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pendingShipProduction struct {
|
||||||
|
planetNumber uint
|
||||||
|
class string
|
||||||
|
cost float64
|
||||||
|
percent float64
|
||||||
|
free float64
|
||||||
|
}
|
||||||
|
|
||||||
func newParser() *parser {
|
func newParser() *parser {
|
||||||
return &parser{sec: sectionNone}
|
return &parser{sec: sectionNone}
|
||||||
}
|
}
|
||||||
@@ -137,10 +198,62 @@ func (p *parser) handle(line string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inside a battle block, "<Race> Groups" lines open a per-race
|
||||||
|
// roster sub-table. The line matches singleTokenPrefix(_, " Groups")
|
||||||
|
// and would otherwise be treated as a top-level section transition
|
||||||
|
// by classifySection. Trap it here so the battle state stays open.
|
||||||
|
if (p.sec == sectionBattle || p.sec == sectionBattleProtocol) && p.pendingBattle != nil {
|
||||||
|
if race, ok := singleTokenPrefix(trimmed, " Groups"); ok {
|
||||||
|
// New roster — the protocol block, if it had started,
|
||||||
|
// cannot reopen; but the engine never emits "<Race> Groups"
|
||||||
|
// after "Battle Protocol" inside the same battle.
|
||||||
|
p.sec = sectionBattle
|
||||||
|
p.pendingBattleRace = race
|
||||||
|
p.skipHeader = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if newSec, owner, isHeader := classifySection(trimmed); isHeader {
|
if newSec, owner, isHeader := classifySection(trimmed); isHeader {
|
||||||
|
// Flush the previous battle on any header transition that
|
||||||
|
// moves us out of the battle block. Sub-transitions
|
||||||
|
// (sectionBattle → sectionBattleProtocol or vice-versa)
|
||||||
|
// inside the same battle do not flush.
|
||||||
|
switch {
|
||||||
|
case newSec == sectionBattle:
|
||||||
|
p.flushPendingBattle()
|
||||||
|
planet, planetName, ok := parseBattleHeader(trimmed)
|
||||||
|
if ok {
|
||||||
|
p.pendingBattle = &pendingBattle{
|
||||||
|
id: syntheticBattleID(p.battleIndex),
|
||||||
|
planet: planet,
|
||||||
|
planetName: planetName,
|
||||||
|
raceIndex: make(map[string]int),
|
||||||
|
shipIndex: make(map[shipKey]int),
|
||||||
|
races: make(map[int]uuid.UUID),
|
||||||
|
ships: make(map[int]report.BattleReportGroup),
|
||||||
|
}
|
||||||
|
p.battleIndex++
|
||||||
|
}
|
||||||
|
p.pendingBattleRace = ""
|
||||||
|
case newSec == sectionBattleProtocol:
|
||||||
|
// Stay in the same battle; the protocol header itself
|
||||||
|
// has no column header to skip — `Battle Protocol` is
|
||||||
|
// followed by the shot lines directly. Reset
|
||||||
|
// pendingBattleRace because the roster phase ended.
|
||||||
|
p.pendingBattleRace = ""
|
||||||
|
default:
|
||||||
|
// Any other section transition closes the battle.
|
||||||
|
p.flushPendingBattle()
|
||||||
|
}
|
||||||
p.sec = newSec
|
p.sec = newSec
|
||||||
p.otherOwner = owner
|
p.otherOwner = owner
|
||||||
p.skipHeader = newSec != sectionNone
|
// `Battle Protocol` has no column header to skip; ditto for
|
||||||
|
// the per-race `<Race> Groups` sub-header trapped above (we
|
||||||
|
// handle that branch separately). For sectionBattle the
|
||||||
|
// header line is "Battle at (#N) Name" with no following
|
||||||
|
// column row, so skipHeader stays false there as well.
|
||||||
|
p.skipHeader = newSec != sectionNone && newSec != sectionBattle && newSec != sectionBattleProtocol
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,16 +290,31 @@ func (p *parser) handle(line string) error {
|
|||||||
p.parseYourFleet(fields)
|
p.parseYourFleet(fields)
|
||||||
case sectionIncomingGroups:
|
case sectionIncomingGroups:
|
||||||
p.parseIncomingGroup(fields)
|
p.parseIncomingGroup(fields)
|
||||||
|
case sectionYourSciences:
|
||||||
|
p.parseYourScience(fields)
|
||||||
|
case sectionOtherSciences:
|
||||||
|
p.parseOtherScience(fields)
|
||||||
|
case sectionOtherShipTypes:
|
||||||
|
p.parseOtherShipClass(fields)
|
||||||
|
case sectionBombings:
|
||||||
|
p.parseBombing(fields)
|
||||||
|
case sectionShipsInProduction:
|
||||||
|
p.parseShipProductionRow(fields)
|
||||||
|
case sectionBattle:
|
||||||
|
p.parseBattleRosterRow(fields)
|
||||||
|
case sectionBattleProtocol:
|
||||||
|
p.parseBattleProtocolLine(fields)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *parser) finish() (report.Report, error) {
|
func (p *parser) finish() ([]report.BattleReport, error) {
|
||||||
if !p.sawHeader {
|
if !p.sawHeader {
|
||||||
return report.Report{}, errors.New("legacyreport: missing report header line")
|
return nil, errors.New("legacyreport: missing report header line")
|
||||||
}
|
}
|
||||||
|
p.flushPendingBattle()
|
||||||
p.resolvePending()
|
p.resolvePending()
|
||||||
return p.rep, nil
|
return p.battles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseHeader extracts (race, turn) from
|
// parseHeader extracts (race, turn) from
|
||||||
@@ -259,19 +387,23 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
|||||||
return sectionUnidentifiedPlanets, "", true
|
return sectionUnidentifiedPlanets, "", true
|
||||||
case "Your vote:":
|
case "Your vote:":
|
||||||
return sectionYourVote, "", true
|
return sectionYourVote, "", true
|
||||||
case "Your Sciences",
|
case "Your Sciences":
|
||||||
"Bombings",
|
return sectionYourSciences, "", true
|
||||||
"Ships In Production",
|
case "Bombings":
|
||||||
"Approaching Groups",
|
return sectionBombings, "", true
|
||||||
"Broadcast Message",
|
case "Ships In Production":
|
||||||
"Battle Protocol":
|
return sectionShipsInProduction, "", true
|
||||||
|
case "Approaching Groups",
|
||||||
|
"Broadcast Message":
|
||||||
return sectionNone, "", true
|
return sectionNone, "", true
|
||||||
|
case "Battle Protocol":
|
||||||
|
return sectionBattleProtocol, "", true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "Status of Players") {
|
if strings.HasPrefix(line, "Status of Players") {
|
||||||
return sectionStatusOfPlayers, "", true
|
return sectionStatusOfPlayers, "", true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "Battle at ") {
|
if strings.HasPrefix(line, "Battle at ") {
|
||||||
return sectionNone, "", true
|
return sectionBattle, "", true
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(line, "=== ATTENTION") {
|
if strings.HasPrefix(line, "=== ATTENTION") {
|
||||||
return sectionNone, "", true
|
return sectionNone, "", true
|
||||||
@@ -279,11 +411,11 @@ func classifySection(line string) (sec section, owner string, isHeader bool) {
|
|||||||
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
|
if owner, ok := singleTokenPrefix(line, " Planets"); ok {
|
||||||
return sectionOtherPlanets, owner, true
|
return sectionOtherPlanets, owner, true
|
||||||
}
|
}
|
||||||
if _, ok := singleTokenPrefix(line, " Ship Types"); ok {
|
if owner, ok := singleTokenPrefix(line, " Ship Types"); ok {
|
||||||
return sectionNone, "", true
|
return sectionOtherShipTypes, owner, true
|
||||||
}
|
}
|
||||||
if _, ok := singleTokenPrefix(line, " Sciences"); ok {
|
if owner, ok := singleTokenPrefix(line, " Sciences"); ok {
|
||||||
return sectionNone, "", true
|
return sectionOtherSciences, owner, true
|
||||||
}
|
}
|
||||||
if _, ok := singleTokenPrefix(line, " Groups"); ok {
|
if _, ok := singleTokenPrefix(line, " Groups"); ok {
|
||||||
return sectionNone, "", true
|
return sectionNone, "", true
|
||||||
@@ -468,30 +600,394 @@ func (p *parser) parseUnidentifiedPlanet(fields []string) {
|
|||||||
//
|
//
|
||||||
// N D A W S C M
|
// N D A W S C M
|
||||||
func (p *parser) parseShipClass(fields []string) {
|
func (p *parser) parseShipClass(fields []string) {
|
||||||
if len(fields) < 7 {
|
sc, ok := decodeShipClassRow(fields)
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
p.rep.LocalShipClass = append(p.rep.LocalShipClass, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOtherShipClass parses one row of a "<Race> Ship Types" block.
|
||||||
|
// Same 7-column layout as [parser.parseShipClass]; the owning race is
|
||||||
|
// captured into [parser.otherOwner] when the section header is
|
||||||
|
// classified by [classifySection].
|
||||||
|
func (p *parser) parseOtherShipClass(fields []string) {
|
||||||
|
sc, ok := decodeShipClassRow(fields)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.rep.OtherShipClass = append(p.rep.OtherShipClass, report.OthersShipClass{
|
||||||
|
Race: p.otherOwner,
|
||||||
|
ShipClass: sc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeShipClassRow(fields []string) (report.ShipClass, bool) {
|
||||||
|
var sc report.ShipClass
|
||||||
|
if len(fields) < 7 {
|
||||||
|
return sc, false
|
||||||
|
}
|
||||||
drive, err := parseFloat(fields[1])
|
drive, err := parseFloat(fields[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return sc, false
|
||||||
}
|
}
|
||||||
armament, err := strconv.ParseUint(fields[2], 10, 32)
|
armament, err := strconv.ParseUint(fields[2], 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return sc, false
|
||||||
}
|
}
|
||||||
weapons, _ := parseFloat(fields[3])
|
weapons, _ := parseFloat(fields[3])
|
||||||
shields, _ := parseFloat(fields[4])
|
shields, _ := parseFloat(fields[4])
|
||||||
cargo, _ := parseFloat(fields[5])
|
cargo, _ := parseFloat(fields[5])
|
||||||
mass, _ := parseFloat(fields[6])
|
mass, _ := parseFloat(fields[6])
|
||||||
|
sc.Name = fields[0]
|
||||||
|
sc.Drive = report.F(drive)
|
||||||
|
sc.Armament = uint(armament)
|
||||||
|
sc.Weapons = report.F(weapons)
|
||||||
|
sc.Shields = report.F(shields)
|
||||||
|
sc.Cargo = report.F(cargo)
|
||||||
|
sc.Mass = report.F(mass)
|
||||||
|
return sc, true
|
||||||
|
}
|
||||||
|
|
||||||
p.rep.LocalShipClass = append(p.rep.LocalShipClass, report.ShipClass{
|
// parseYourScience parses one row of the "Your Sciences" block.
|
||||||
Name: fields[0],
|
// Columns:
|
||||||
Drive: report.F(drive),
|
//
|
||||||
Armament: uint(armament),
|
// N D W S C
|
||||||
Weapons: report.F(weapons),
|
//
|
||||||
Shields: report.F(shields),
|
// where D/W/S/C are the four tech proportions as fractions summing
|
||||||
Cargo: report.F(cargo),
|
// to 1.0 (`pkg/calc/validator.go.ValidateScienceValues`).
|
||||||
Mass: report.F(mass),
|
func (p *parser) parseYourScience(fields []string) {
|
||||||
|
sc, ok := decodeScienceRow(fields)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.rep.LocalScience = append(p.rep.LocalScience, sc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOtherScience parses one row of a "<Race> Sciences" block.
|
||||||
|
// Same 5-column layout as [parser.parseYourScience]; the owning race
|
||||||
|
// is captured into [parser.otherOwner] by [classifySection].
|
||||||
|
func (p *parser) parseOtherScience(fields []string) {
|
||||||
|
sc, ok := decodeScienceRow(fields)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.rep.OtherScience = append(p.rep.OtherScience, report.OtherScience{
|
||||||
|
Race: p.otherOwner,
|
||||||
|
Science: sc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeScienceRow(fields []string) (report.Science, bool) {
|
||||||
|
var sc report.Science
|
||||||
|
if len(fields) < 5 {
|
||||||
|
return sc, false
|
||||||
|
}
|
||||||
|
drive, err := parseFloat(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return sc, false
|
||||||
|
}
|
||||||
|
weapons, _ := parseFloat(fields[2])
|
||||||
|
shields, _ := parseFloat(fields[3])
|
||||||
|
cargo, _ := parseFloat(fields[4])
|
||||||
|
sc.Name = fields[0]
|
||||||
|
sc.Drive = report.F(drive)
|
||||||
|
sc.Weapons = report.F(weapons)
|
||||||
|
sc.Shields = report.F(shields)
|
||||||
|
sc.Cargo = report.F(cargo)
|
||||||
|
return sc, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBombing parses one row of the "Bombings" block. Columns
|
||||||
|
// (12 tokens, last is the wiped/damaged status word):
|
||||||
|
//
|
||||||
|
// W O # N P I P $ M C A status
|
||||||
|
//
|
||||||
|
// where the first P is the post-bombing population and the second
|
||||||
|
// P is the production string left on the planet. Status is parsed
|
||||||
|
// positionally — the header has a duplicate P, so a header-name
|
||||||
|
// lookup is not safe.
|
||||||
|
func (p *parser) parseBombing(fields []string) {
|
||||||
|
if len(fields) < 12 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
number, err := strconv.ParseUint(fields[2], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
population, _ := parseFloat(fields[4])
|
||||||
|
industry, _ := parseFloat(fields[5])
|
||||||
|
capital, _ := parseFloat(fields[7])
|
||||||
|
material, _ := parseFloat(fields[8])
|
||||||
|
colonists, _ := parseFloat(fields[9])
|
||||||
|
attack, _ := parseFloat(fields[10])
|
||||||
|
wiped := fields[11] == "Wiped"
|
||||||
|
p.rep.Bombing = append(p.rep.Bombing, &report.Bombing{
|
||||||
|
Attacker: fields[0],
|
||||||
|
Owner: fields[1],
|
||||||
|
Number: uint(number),
|
||||||
|
Planet: fields[3],
|
||||||
|
Population: report.F(population),
|
||||||
|
Industry: report.F(industry),
|
||||||
|
Production: fields[6],
|
||||||
|
Capital: report.F(capital),
|
||||||
|
Material: report.F(material),
|
||||||
|
Colonists: report.F(colonists),
|
||||||
|
AttackPower: report.F(attack),
|
||||||
|
Wiped: wiped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBattleHeader extracts (planet, planetName) from a
|
||||||
|
// "Battle at (#N) <PlanetName>" line. The planet number is the
|
||||||
|
// integer between "(#" and ")"; the planet name is the rest of the
|
||||||
|
// line after the closing parenthesis (trimmed).
|
||||||
|
func parseBattleHeader(line string) (uint, string, bool) {
|
||||||
|
const prefix = "Battle at "
|
||||||
|
if !strings.HasPrefix(line, prefix) {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(line[len(prefix):])
|
||||||
|
if !strings.HasPrefix(rest, "(#") {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
closing := strings.IndexByte(rest, ')')
|
||||||
|
if closing < 0 {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
num, err := strconv.ParseUint(rest[2:closing], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(rest[closing+1:])
|
||||||
|
return uint(num), name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBattleRosterRow consumes one ship-group line from a battle
|
||||||
|
// roster sub-table. Columns (10 tokens; the last is the per-group
|
||||||
|
// state word):
|
||||||
|
//
|
||||||
|
// # T D W S C T Q L state
|
||||||
|
// 1 Pistolet 1.6 1.00 1.00 0 - 0 1 In_Battle
|
||||||
|
//
|
||||||
|
// where column "L" carries the number of ships remaining after the
|
||||||
|
// battle (confirmed against KNNTS fixtures). Rows are appended to
|
||||||
|
// `pendingBattle.ships` under the race name currently held in
|
||||||
|
// `pendingBattleRace`.
|
||||||
|
func (p *parser) parseBattleRosterRow(fields []string) {
|
||||||
|
if p.pendingBattle == nil || p.pendingBattleRace == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(fields) < 10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
className := fields[1]
|
||||||
|
drive, _ := parseFloat(fields[2])
|
||||||
|
weapons, _ := parseFloat(fields[3])
|
||||||
|
shields, _ := parseFloat(fields[4])
|
||||||
|
cargo, _ := parseFloat(fields[5])
|
||||||
|
loadQuantity, _ := parseFloat(fields[7])
|
||||||
|
numLeft, err := strconv.ParseUint(fields[8], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := fields[9]
|
||||||
|
tech := make(map[string]report.Float, 4)
|
||||||
|
if drive != 0 {
|
||||||
|
tech["DRIVE"] = report.F(drive)
|
||||||
|
}
|
||||||
|
if weapons != 0 {
|
||||||
|
tech["WEAPONS"] = report.F(weapons)
|
||||||
|
}
|
||||||
|
if shields != 0 {
|
||||||
|
tech["SHIELDS"] = report.F(shields)
|
||||||
|
}
|
||||||
|
if cargo != 0 {
|
||||||
|
tech["CARGO"] = report.F(cargo)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.assignRaceIndex(p.pendingBattleRace)
|
||||||
|
key := shipKey{race: p.pendingBattleRace, class: className}
|
||||||
|
idx := p.assignShipIndex(key)
|
||||||
|
|
||||||
|
// Legacy battle rosters may list the same `(race, className)`
|
||||||
|
// across multiple rows — different tech variants, ships pulled
|
||||||
|
// from several stacks / planets, etc. We collapse those rows
|
||||||
|
// into one BattleReportGroup keyed by `(race, className)` (the
|
||||||
|
// viewer aggregates per class anyway) by SUMMING Number and
|
||||||
|
// NumberLeft instead of overwriting; otherwise only the last
|
||||||
|
// row's counts survive and the battle protocol's destroy count
|
||||||
|
// would dwarf the recorded initial count (the original
|
||||||
|
// motivation for the now-removed "phantom destroy" workaround).
|
||||||
|
if existing, found := p.pendingBattle.ships[idx]; found {
|
||||||
|
existing.Number += uint(number)
|
||||||
|
existing.NumberLeft += uint(numLeft)
|
||||||
|
// LoadQuantity is per-ship cargo — average is a fair fallback
|
||||||
|
// when several stacks of the same class merge into one bucket.
|
||||||
|
existing.LoadQuantity = report.F(
|
||||||
|
(existing.LoadQuantity.F() + loadQuantity) / 2,
|
||||||
|
)
|
||||||
|
// Tech / LoadType / InBattle keep their first-seen values:
|
||||||
|
// the viewer treats them as bucket-wide attributes and the
|
||||||
|
// first row is normally the most representative tech variant.
|
||||||
|
p.pendingBattle.ships[idx] = existing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pendingBattle.ships[idx] = report.BattleReportGroup{
|
||||||
|
Race: p.pendingBattleRace,
|
||||||
|
ClassName: className,
|
||||||
|
Tech: tech,
|
||||||
|
Number: uint(number),
|
||||||
|
NumberLeft: uint(numLeft),
|
||||||
|
LoadType: dashOrEmpty(fields[6]),
|
||||||
|
LoadQuantity: report.F(loadQuantity),
|
||||||
|
InBattle: state == "In_Battle",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBattleProtocolLine consumes one shot line of the
|
||||||
|
// "Battle Protocol" sub-block. Required shape (8 tokens):
|
||||||
|
//
|
||||||
|
// <atkRace> <atkClass> fires on <defRace> <defClass> : <Destroyed|Shields>
|
||||||
|
//
|
||||||
|
// Anything else (including the empty line separating the protocol
|
||||||
|
// from the preceding rosters) is silently skipped — the engine never
|
||||||
|
// emits other text inside this block.
|
||||||
|
func (p *parser) parseBattleProtocolLine(fields []string) {
|
||||||
|
if p.pendingBattle == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(fields) != 8 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fields[2] != "fires" || fields[3] != "on" || fields[6] != ":" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atkRace, atkClass := fields[0], fields[1]
|
||||||
|
defRace, defClass := fields[4], fields[5]
|
||||||
|
destroyed := fields[7] == "Destroyed"
|
||||||
|
|
||||||
|
aRace := p.assignRaceIndex(atkRace)
|
||||||
|
dRace := p.assignRaceIndex(defRace)
|
||||||
|
sa := p.assignShipIndex(shipKey{race: atkRace, class: atkClass})
|
||||||
|
sd := p.assignShipIndex(shipKey{race: defRace, class: defClass})
|
||||||
|
|
||||||
|
// Synthesise a minimal BattleReportGroup entry when the shot
|
||||||
|
// references a (race, class) pair that the roster did not
|
||||||
|
// declare. This happens when the legacy emitter trims a roster
|
||||||
|
// row but the engine logged a shot for that group.
|
||||||
|
if _, ok := p.pendingBattle.ships[sa]; !ok {
|
||||||
|
p.pendingBattle.ships[sa] = report.BattleReportGroup{
|
||||||
|
Race: atkRace, ClassName: atkClass, InBattle: true,
|
||||||
|
Tech: map[string]report.Float{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := p.pendingBattle.ships[sd]; !ok {
|
||||||
|
p.pendingBattle.ships[sd] = report.BattleReportGroup{
|
||||||
|
Race: defRace, ClassName: defClass, InBattle: true,
|
||||||
|
Tech: map[string]report.Float{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pendingBattle.protocol = append(p.pendingBattle.protocol, report.BattleActionReport{
|
||||||
|
Attacker: aRace,
|
||||||
|
AttackerShipClass: sa,
|
||||||
|
Defender: dRace,
|
||||||
|
DefenderShipClass: sd,
|
||||||
|
Destroyed: destroyed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignRaceIndex returns the in-battle race index for raceName,
|
||||||
|
// creating a new entry on first sight. Race indices are 0-based and
|
||||||
|
// monotonically increasing in first-seen order. The synthetic race
|
||||||
|
// UUID is derived from the race name through
|
||||||
|
// `syntheticBattleRaceNamespace`.
|
||||||
|
func (p *parser) assignRaceIndex(raceName string) int {
|
||||||
|
if idx, ok := p.pendingBattle.raceIndex[raceName]; ok {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
idx := len(p.pendingBattle.raceIndex)
|
||||||
|
p.pendingBattle.raceIndex[raceName] = idx
|
||||||
|
p.pendingBattle.races[idx] = syntheticBattleRaceID(raceName)
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignShipIndex returns the in-battle ship-group index for
|
||||||
|
// (race, class), creating a new entry on first sight. Indices are
|
||||||
|
// 0-based and monotonically increasing in first-seen order across
|
||||||
|
// all races.
|
||||||
|
func (p *parser) assignShipIndex(key shipKey) int {
|
||||||
|
if idx, ok := p.pendingBattle.shipIndex[key]; ok {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
idx := len(p.pendingBattle.shipIndex)
|
||||||
|
p.pendingBattle.shipIndex[key] = idx
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushPendingBattle finalises the in-flight battle: appends the
|
||||||
|
// BattleReport to `p.battles` and a matching BattleSummary
|
||||||
|
// (id/planet/shots) to `p.rep.Battle`. No-op when no battle is
|
||||||
|
// pending. Idempotent — clears `pendingBattle` on completion.
|
||||||
|
func (p *parser) flushPendingBattle() {
|
||||||
|
if p.pendingBattle == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pb := p.pendingBattle
|
||||||
|
p.pendingBattle = nil
|
||||||
|
p.pendingBattleRace = ""
|
||||||
|
|
||||||
|
br := report.BattleReport{
|
||||||
|
ID: pb.id,
|
||||||
|
Planet: pb.planet,
|
||||||
|
PlanetName: pb.planetName,
|
||||||
|
Races: pb.races,
|
||||||
|
Ships: pb.ships,
|
||||||
|
Protocol: pb.protocol,
|
||||||
|
}
|
||||||
|
p.battles = append(p.battles, br)
|
||||||
|
p.rep.Battle = append(p.rep.Battle, report.BattleSummary{
|
||||||
|
ID: pb.id,
|
||||||
|
Planet: pb.planet,
|
||||||
|
Shots: uint(len(pb.protocol)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseShipProductionRow buffers a "Ships In Production" row for
|
||||||
|
// post-processing in [parser.finish]. Columns:
|
||||||
|
//
|
||||||
|
// # N S C P L
|
||||||
|
//
|
||||||
|
// where # is the planet number, N is the planet name (decorative —
|
||||||
|
// resolution uses #), S is the building ship class, C is the cost
|
||||||
|
// (== shipMass * 10), P is the build progress as a fraction in
|
||||||
|
// [0, 1], and L is the producing planet's free industry. The wire
|
||||||
|
// shape's `prod_used` field is not carried by the legacy text; it is
|
||||||
|
// derived during [parser.resolvePending] from the planet's material
|
||||||
|
// and resources via [calc.ShipBuildCost].
|
||||||
|
func (p *parser) parseShipProductionRow(fields []string) {
|
||||||
|
if len(fields) < 6 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
number, err := strconv.ParseUint(fields[0], 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cost, _ := parseFloat(fields[3])
|
||||||
|
percent, _ := parseFloat(fields[4])
|
||||||
|
free, _ := parseFloat(fields[5])
|
||||||
|
p.pendingShipProducts = append(p.pendingShipProducts, pendingShipProduction{
|
||||||
|
planetNumber: uint(number),
|
||||||
|
class: fields[2],
|
||||||
|
cost: cost,
|
||||||
|
percent: percent,
|
||||||
|
free: free,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,6 +1191,51 @@ func (p *parser) resolvePending() {
|
|||||||
Mass: report.F(pi.mass),
|
Mass: report.F(pi.mass),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, ps := range p.pendingShipProducts {
|
||||||
|
lp, ok := p.findLocalPlanet(ps.planetNumber)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
shipMass := ps.cost / 10
|
||||||
|
totalCost := calc.ShipBuildCost(
|
||||||
|
shipMass,
|
||||||
|
float64(lp.Material),
|
||||||
|
float64(lp.Resources),
|
||||||
|
)
|
||||||
|
// `ProdUsed` is the cumulative production-equivalent of the
|
||||||
|
// build progress so far. The real engine's `Progress` field
|
||||||
|
// accumulates across turns and the per-turn `ProdUsed` is a
|
||||||
|
// transient residual — neither of those is recoverable from a
|
||||||
|
// single legacy report. The derivation here keeps the value in
|
||||||
|
// the same units (production points) and in the right ballpark
|
||||||
|
// for synthetic-mode UI rendering; live engine reports do not
|
||||||
|
// flow through this parser, so the approximation never reaches
|
||||||
|
// production traffic. README.md skips section explains.
|
||||||
|
prodUsed := totalCost * ps.percent
|
||||||
|
p.rep.ShipProduction = append(p.rep.ShipProduction, report.ShipProduction{
|
||||||
|
Planet: ps.planetNumber,
|
||||||
|
Class: ps.class,
|
||||||
|
Cost: report.F(ps.cost),
|
||||||
|
ProdUsed: report.F(prodUsed),
|
||||||
|
Percent: report.F(ps.percent),
|
||||||
|
Free: report.F(ps.free),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLocalPlanet returns the parsed "Your Planets" entry with the
|
||||||
|
// given number, used by the ships-in-production resolver to read
|
||||||
|
// material / resources for the [calc.ShipBuildCost] derivation.
|
||||||
|
// Ships-in-production only lists own ships, so the lookup against
|
||||||
|
// `LocalPlanet` is correct.
|
||||||
|
func (p *parser) findLocalPlanet(number uint) (report.LocalPlanet, bool) {
|
||||||
|
for _, lp := range p.rep.LocalPlanet {
|
||||||
|
if lp.Number == number {
|
||||||
|
return lp, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report.LocalPlanet{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupPlanetNumber resolves a legacy planet reference — either a
|
// lookupPlanetNumber resolves a legacy planet reference — either a
|
||||||
@@ -738,6 +1279,31 @@ func syntheticGroupID(g uint) uuid.UUID {
|
|||||||
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
|
return uuid.NewSHA1(syntheticGroupNamespace, fmt.Appendf(nil, "legacy-local-group-%d", g))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// syntheticBattleNamespace seeds [uuid.NewSHA1] for the per-report
|
||||||
|
// battle-index → UUID derivation used by `Report.Battle[i].ID` and
|
||||||
|
// `BattleReport.ID`. Distinct from `syntheticGroupNamespace` so a
|
||||||
|
// per-report battle index can never collide with a ship-group id.
|
||||||
|
// Mirrors the rationale in `syntheticGroupNamespace`: arbitrary
|
||||||
|
// value, stable across releases.
|
||||||
|
var syntheticBattleNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000002")
|
||||||
|
|
||||||
|
// syntheticBattleRaceNamespace seeds [uuid.NewSHA1] for the
|
||||||
|
// per-battle race name → race UUID derivation that fills
|
||||||
|
// `BattleReport.Races`. Engine-side reports carry the real race
|
||||||
|
// UUID; the legacy text only carries the race name, so we derive a
|
||||||
|
// stable identifier from the name. The constant is independent of
|
||||||
|
// `syntheticBattleNamespace` so race UUIDs can never collide with
|
||||||
|
// battle UUIDs.
|
||||||
|
var syntheticBattleRaceNamespace = uuid.MustParse("be01a000-0000-0000-0000-000000000003")
|
||||||
|
|
||||||
|
func syntheticBattleID(idx uint) uuid.UUID {
|
||||||
|
return uuid.NewSHA1(syntheticBattleNamespace, fmt.Appendf(nil, "legacy-battle-%d", idx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func syntheticBattleRaceID(name string) uuid.UUID {
|
||||||
|
return uuid.NewSHA1(syntheticBattleRaceNamespace, fmt.Appendf(nil, "legacy-battle-race-%s", name))
|
||||||
|
}
|
||||||
|
|
||||||
func dashOrEmpty(s string) string {
|
func dashOrEmpty(s string) string {
|
||||||
if s == "-" {
|
if s == "-" {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestParseHeaderAndSize(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func TestParseStatusOfPlayers(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
|
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ func TestParseYourVote(t *testing.T) {
|
|||||||
"KnightErrants 16.02",
|
"KnightErrants 16.02",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func TestParseLocalAndOtherPlanets(t *testing.T) {
|
|||||||
" 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
|
" 12 303.84 579.23 Skarabei 500.00 500.00 500.00 10.00 Capital 0.00 70.99 20.03 341.78",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func TestParseUninhabitedAndUnidentified(t *testing.T) {
|
|||||||
" 1 579.12 489.37",
|
" 1 579.12 489.37",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,12 +196,12 @@ func TestParseShipClasses(t *testing.T) {
|
|||||||
"Dragon 16.70 1 1.10 1.00 1 19.80",
|
"Dragon 16.70 1 1.10 1.00 1 19.80",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
if got, want := len(rep.LocalShipClass), 2; got != want {
|
if got, want := len(rep.LocalShipClass), 2; got != want {
|
||||||
t.Fatalf("len(LocalShipClass) = %d, want %d (foreign types must be ignored)", got, want)
|
t.Fatalf("len(LocalShipClass) = %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
bow := rep.LocalShipClass[1]
|
bow := rep.LocalShipClass[1]
|
||||||
if bow.Name != "Bow105" || bow.Armament != 105 {
|
if bow.Name != "Bow105" || bow.Armament != 105 {
|
||||||
@@ -210,24 +210,238 @@ func TestParseShipClasses(t *testing.T) {
|
|||||||
if got, want := float64(bow.Drive), 74.77; got != want {
|
if got, want := float64(bow.Drive), 74.77; got != want {
|
||||||
t.Errorf("Bow105.Drive = %v, want %v", got, want)
|
t.Errorf("Bow105.Drive = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
|
if got, want := len(rep.OtherShipClass), 1; got != want {
|
||||||
|
t.Fatalf("len(OtherShipClass) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
dragon := rep.OtherShipClass[0]
|
||||||
|
if dragon.Race != "Monstrai" || dragon.Name != "Dragon" || dragon.Armament != 1 {
|
||||||
|
t.Errorf("Dragon = (%q, %q, %d), want (Monstrai, Dragon, 1)",
|
||||||
|
dragon.Race, dragon.Name, dragon.Armament)
|
||||||
|
}
|
||||||
|
if got, want := float64(dragon.Mass), 19.80; got != want {
|
||||||
|
t.Errorf("Dragon.Mass = %v, want %v", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseSkipsBattlesAndBombings(t *testing.T) {
|
// TestParseSciences covers both "Your Sciences" and "<Race> Sciences"
|
||||||
|
// in one fixture. The five-column layout (N D W S C) is shared.
|
||||||
|
func TestParseSciences(t *testing.T) {
|
||||||
|
in := strings.Join([]string{
|
||||||
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
|
"",
|
||||||
|
"Your Sciences",
|
||||||
|
"",
|
||||||
|
"N D W S C",
|
||||||
|
"_TerraForm 1 0 0 0",
|
||||||
|
"BalancedMix 0.5 0.2 0.2 0.1",
|
||||||
|
"",
|
||||||
|
"Pahanchiks Sciences",
|
||||||
|
"",
|
||||||
|
"N D W S C",
|
||||||
|
"_Drift 1 0 0 0",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(rep.LocalScience), 2; got != want {
|
||||||
|
t.Fatalf("len(LocalScience) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
mix := rep.LocalScience[1]
|
||||||
|
if mix.Name != "BalancedMix" {
|
||||||
|
t.Errorf("LocalScience[1].Name = %q, want BalancedMix", mix.Name)
|
||||||
|
}
|
||||||
|
if float64(mix.Drive) != 0.5 || float64(mix.Cargo) != 0.1 {
|
||||||
|
t.Errorf("BalancedMix (Drive, Cargo) = (%v, %v), want (0.5, 0.1)",
|
||||||
|
float64(mix.Drive), float64(mix.Cargo))
|
||||||
|
}
|
||||||
|
if got, want := len(rep.OtherScience), 1; got != want {
|
||||||
|
t.Fatalf("len(OtherScience) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
drift := rep.OtherScience[0]
|
||||||
|
if drift.Race != "Pahanchiks" || drift.Name != "_Drift" {
|
||||||
|
t.Errorf("OtherScience[0] = (%q, %q), want (Pahanchiks, _Drift)",
|
||||||
|
drift.Race, drift.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseBombings covers a wiped row + a damaged row + the duplicate
|
||||||
|
// `P` column header (population vs production string) — assertions
|
||||||
|
// hit every wire field so a positional-index slip is caught.
|
||||||
|
func TestParseBombings(t *testing.T) {
|
||||||
|
in := strings.Join([]string{
|
||||||
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
|
"",
|
||||||
|
"Bombings",
|
||||||
|
"",
|
||||||
|
"W O # N P I P $ M C A",
|
||||||
|
"Knights Ricksha 20 DW-1207 1.56 0.00 Dron 0.00 0.00 0.00 7.62 Wiped",
|
||||||
|
"Knights Ricksha 332 PEHKE 500.00 258.64 Dron 184.39 0.00 6.42 331.93 Damaged",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(rep.Bombing), 2; got != want {
|
||||||
|
t.Fatalf("len(Bombing) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
wiped := rep.Bombing[0]
|
||||||
|
if !wiped.Wiped {
|
||||||
|
t.Errorf("Bombing[0].Wiped = false, want true")
|
||||||
|
}
|
||||||
|
if wiped.Attacker != "Knights" || wiped.Owner != "Ricksha" || wiped.Number != 20 {
|
||||||
|
t.Errorf("Bombing[0] head = (%q, %q, %d), want (Knights, Ricksha, 20)",
|
||||||
|
wiped.Attacker, wiped.Owner, wiped.Number)
|
||||||
|
}
|
||||||
|
if wiped.Planet != "DW-1207" || wiped.Production != "Dron" {
|
||||||
|
t.Errorf("Bombing[0] (planet, production) = (%q, %q), want (DW-1207, Dron)",
|
||||||
|
wiped.Planet, wiped.Production)
|
||||||
|
}
|
||||||
|
if float64(wiped.AttackPower) != 7.62 {
|
||||||
|
t.Errorf("Bombing[0].AttackPower = %v, want 7.62", float64(wiped.AttackPower))
|
||||||
|
}
|
||||||
|
damaged := rep.Bombing[1]
|
||||||
|
if damaged.Wiped {
|
||||||
|
t.Errorf("Bombing[1].Wiped = true, want false (Damaged)")
|
||||||
|
}
|
||||||
|
if float64(damaged.Capital) != 184.39 || float64(damaged.Colonists) != 6.42 {
|
||||||
|
t.Errorf("Bombing[1] (capital, colonists) = (%v, %v), want (184.39, 6.42)",
|
||||||
|
float64(damaged.Capital), float64(damaged.Colonists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseShipsInProduction covers the prod_used derivation through
|
||||||
|
// [calc.ShipBuildCost]. The producing planet is mounted first with a
|
||||||
|
// non-zero material stockpile so the farming term contributes a
|
||||||
|
// non-trivial slice of totalCost; the expected prod_used number is
|
||||||
|
// derived from totalCost * percent with the same formula the parser
|
||||||
|
// uses.
|
||||||
|
func TestParseShipsInProduction(t *testing.T) {
|
||||||
|
// Planet: Material=0.68, Resources=10.00.
|
||||||
|
// Ship: cost=990.10 -> shipMass=99.01.
|
||||||
|
// totalCost = ShipProductionCost(99.01) + max(0, 99.01-0.68)/10
|
||||||
|
// = 990.10 + 9.833
|
||||||
|
// = 999.933
|
||||||
|
// prod_used = 999.933 * 0.07 (percent) ≈ 69.99531
|
||||||
|
in := strings.Join([]string{
|
||||||
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
|
"",
|
||||||
|
"Your Planets",
|
||||||
|
"",
|
||||||
|
" # X Y N S P I R P $ M C L",
|
||||||
|
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 CombatFlame 0.00 0.68 88.78 1000.00",
|
||||||
|
"",
|
||||||
|
"Ships In Production",
|
||||||
|
"",
|
||||||
|
" # N S C P L",
|
||||||
|
" 17 Castle CombatFlame 990.10 0.07 1000.00",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(rep.ShipProduction), 1; got != want {
|
||||||
|
t.Fatalf("len(ShipProduction) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
sp := rep.ShipProduction[0]
|
||||||
|
if sp.Planet != 17 || sp.Class != "CombatFlame" {
|
||||||
|
t.Errorf("ShipProduction[0] = (planet=%d, class=%q), want (17, CombatFlame)",
|
||||||
|
sp.Planet, sp.Class)
|
||||||
|
}
|
||||||
|
if got := float64(sp.Cost); got != 990.10 {
|
||||||
|
t.Errorf("ShipProduction[0].Cost = %v, want 990.10", got)
|
||||||
|
}
|
||||||
|
if got := float64(sp.Percent); got != 0.07 {
|
||||||
|
t.Errorf("ShipProduction[0].Percent = %v, want 0.07", got)
|
||||||
|
}
|
||||||
|
if got := float64(sp.Free); got != 1000.0 {
|
||||||
|
t.Errorf("ShipProduction[0].Free = %v, want 1000", got)
|
||||||
|
}
|
||||||
|
wantProdUsed := 69.995
|
||||||
|
if got := float64(sp.ProdUsed); got < wantProdUsed-0.01 || got > wantProdUsed+0.01 {
|
||||||
|
t.Errorf("ShipProduction[0].ProdUsed = %v, want ~%v (totalCost * percent)",
|
||||||
|
got, wantProdUsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseShipsInProductionDropsUnknownPlanet exercises the safety
|
||||||
|
// net: a ships-in-production row referencing a planet not seen in
|
||||||
|
// "Your Planets" is dropped, because the prod_used derivation needs
|
||||||
|
// the planet's material and resources.
|
||||||
|
func TestParseShipsInProductionDropsUnknownPlanet(t *testing.T) {
|
||||||
|
in := strings.Join([]string{
|
||||||
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
|
"",
|
||||||
|
"Ships In Production",
|
||||||
|
"",
|
||||||
|
" # N S C P L",
|
||||||
|
" 99 Lost Frigate 100.00 0.05 500.00",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(rep.ShipProduction), 0; got != want {
|
||||||
|
t.Errorf("len(ShipProduction) = %d, want %d (planet #99 missing → drop)",
|
||||||
|
got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseBattles exercises the battle-block parser end-to-end:
|
||||||
|
// two battles with two races each, full rosters, and protocols. The
|
||||||
|
// inline fixture mirrors the KNNTS-style layout (race-named roster
|
||||||
|
// sub-headers, 10-column roster rows, 8-token shot lines) so any
|
||||||
|
// drift from the real engine format breaks this test before a smoke
|
||||||
|
// regression. Asserts:
|
||||||
|
// - report.Battle carries one BattleSummary per "Battle at"
|
||||||
|
// - BattleReport slice mirrors that with full Races/Ships/Protocol
|
||||||
|
// - Battle Protocol "Foo fires on Bar : <Destroyed|Shields>" lines
|
||||||
|
// map to BattleActionReport entries with the correct destroyed flag
|
||||||
|
// - Roster column 8 (the "L" column) populates NumberLeft
|
||||||
|
// - Top-level sections after a battle (Your Planets) still parse
|
||||||
|
// — battle state must close cleanly without leaking rows.
|
||||||
|
func TestParseBattles(t *testing.T) {
|
||||||
in := strings.Join([]string{
|
in := strings.Join([]string{
|
||||||
"Race Report for Galaxy PLUS Turn 1",
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
"",
|
"",
|
||||||
"Battle at (#7) B-007",
|
"Battle at (#7) B-007",
|
||||||
"",
|
"",
|
||||||
"# T D W S C T Q L",
|
"Foo Groups",
|
||||||
"1 PeaceShip 4 0 0 0 - 0 1 Out_Battle",
|
"",
|
||||||
|
"# T D W S C T Q L",
|
||||||
|
"1 PeaceShip 4.0 0 0 0 - 0 1 In_Battle",
|
||||||
|
"2 Drone 0.0 1 1 0 - 0 0 In_Battle",
|
||||||
|
"",
|
||||||
|
"Bar Groups",
|
||||||
|
"",
|
||||||
|
"# T D W S C T Q L",
|
||||||
|
"1 Pistolet 1.0 1.0 0 0 - 0 1 In_Battle",
|
||||||
"",
|
"",
|
||||||
"Battle Protocol",
|
"Battle Protocol",
|
||||||
"",
|
"",
|
||||||
"Foo fires on Bar : Destroyed",
|
"Foo PeaceShip fires on Bar Pistolet : Shields",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
"",
|
"",
|
||||||
"Bombings",
|
"Battle at (#11) X-011",
|
||||||
"",
|
"",
|
||||||
"# data line",
|
"Foo Groups",
|
||||||
|
"",
|
||||||
|
"# T D W S C T Q L",
|
||||||
|
"1 Scout 1.0 0 0 0 - 0 1 In_Battle",
|
||||||
|
"",
|
||||||
|
"Bar Groups",
|
||||||
|
"",
|
||||||
|
"# T D W S C T Q L",
|
||||||
|
"1 Sniper 2.0 1 0 0 - 0 0 In_Battle",
|
||||||
|
"",
|
||||||
|
"Battle Protocol",
|
||||||
|
"",
|
||||||
|
"Foo Scout fires on Bar Sniper : Destroyed",
|
||||||
"",
|
"",
|
||||||
"Your Planets",
|
"Your Planets",
|
||||||
"",
|
"",
|
||||||
@@ -235,12 +449,187 @@ func TestParseSkipsBattlesAndBombings(t *testing.T) {
|
|||||||
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
|
" 17 171.05 700.24 Castle 1000.00 1000.00 1000.00 10.00 Capital 0.00 0.68 88.78 1000.00",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, battles, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The trailing Your Planets section must still parse — battle
|
||||||
|
// state must close before the next top-level header.
|
||||||
if got, want := len(rep.LocalPlanet), 1; got != want {
|
if got, want := len(rep.LocalPlanet), 1; got != want {
|
||||||
t.Fatalf("len(LocalPlanet) = %d, want %d (battle/bombing rows must not leak in)", got, want)
|
t.Fatalf("len(LocalPlanet) = %d, want %d (battle state did not close)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(rep.Battle), 2; got != want {
|
||||||
|
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(battles), 2; got != want {
|
||||||
|
t.Fatalf("len(battles) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First battle: planet 7, 3 shots; protocol shape with one
|
||||||
|
// shielded shot and two destroyed shots.
|
||||||
|
b0 := battles[0]
|
||||||
|
if b0.Planet != 7 || b0.PlanetName != "B-007" {
|
||||||
|
t.Errorf("battle[0] = (planet=%d, name=%q), want (7, %q)",
|
||||||
|
b0.Planet, b0.PlanetName, "B-007")
|
||||||
|
}
|
||||||
|
if got, want := len(b0.Protocol), 3; got != want {
|
||||||
|
t.Fatalf("battle[0].Protocol = %d shots, want %d", got, want)
|
||||||
|
}
|
||||||
|
if b0.Protocol[0].Destroyed {
|
||||||
|
t.Errorf("battle[0].Protocol[0].Destroyed = true (Shields hit), want false")
|
||||||
|
}
|
||||||
|
if !b0.Protocol[1].Destroyed || !b0.Protocol[2].Destroyed {
|
||||||
|
t.Errorf("battle[0].Protocol[1..2].Destroyed must be true (Destroyed hits)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First battle: roster size and NumberLeft mapping.
|
||||||
|
if got, want := len(b0.Ships), 3; got != want {
|
||||||
|
t.Fatalf("battle[0].Ships = %d groups, want %d", got, want)
|
||||||
|
}
|
||||||
|
// 'Drone' has NumberLeft=0 in the roster (column 8 = 0). The
|
||||||
|
// protocol corroborates: Pistolet destroyed Drone twice.
|
||||||
|
dronePresent := false
|
||||||
|
for _, ship := range b0.Ships {
|
||||||
|
if ship.ClassName == "Drone" {
|
||||||
|
dronePresent = true
|
||||||
|
if ship.NumberLeft != 0 {
|
||||||
|
t.Errorf("Drone.NumberLeft = %d, want 0", ship.NumberLeft)
|
||||||
|
}
|
||||||
|
if ship.Number != 2 {
|
||||||
|
t.Errorf("Drone.Number = %d, want 2", ship.Number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dronePresent {
|
||||||
|
t.Errorf("Drone roster row not parsed into battle[0].Ships")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary mirrors the BattleReport ID and shot count.
|
||||||
|
if rep.Battle[0].ID != b0.ID {
|
||||||
|
t.Errorf("rep.Battle[0].ID = %s, want %s", rep.Battle[0].ID, b0.ID)
|
||||||
|
}
|
||||||
|
if rep.Battle[0].Shots != 3 {
|
||||||
|
t.Errorf("rep.Battle[0].Shots = %d, want 3", rep.Battle[0].Shots)
|
||||||
|
}
|
||||||
|
if rep.Battle[0].Planet != 7 {
|
||||||
|
t.Errorf("rep.Battle[0].Planet = %d, want 7", rep.Battle[0].Planet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second battle: planet 11, 1 shot.
|
||||||
|
if rep.Battle[1].Planet != 11 || rep.Battle[1].Shots != 1 {
|
||||||
|
t.Errorf("rep.Battle[1] = (planet=%d, shots=%d), want (11, 1)",
|
||||||
|
rep.Battle[1].Planet, rep.Battle[1].Shots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battle IDs are stable across re-parses.
|
||||||
|
rep2, battles2, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse (second pass): %v", err)
|
||||||
|
}
|
||||||
|
if rep.Battle[0].ID != rep2.Battle[0].ID || battles[0].ID != battles2[0].ID {
|
||||||
|
t.Errorf("battle id must be deterministic across re-parses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseBattleAggregatesDuplicateClasses guards against the
|
||||||
|
// regression that produced the original "phantom destroys" symptom:
|
||||||
|
// the same `(race, className)` pair appearing on multiple roster
|
||||||
|
// rows must collapse into a single BattleReportGroup whose `Number`
|
||||||
|
// (the "#" column, initial ship count) and `NumberLeft` (the "L"
|
||||||
|
// column, survivors) are the sums across rows. Without the
|
||||||
|
// aggregation only the last row's counts survived and the protocol's
|
||||||
|
// destroy count dwarfed the recorded initial count (e.g. KNNTS041
|
||||||
|
// turn-41 planet #7 lists `pup` seven separate times: 99 + 105 + 291 +
|
||||||
|
// 287 + 166 + 132 + 88 = 1168 ships, 86 survivors, 1082 destroys).
|
||||||
|
func TestParseBattleAggregatesDuplicateClasses(t *testing.T) {
|
||||||
|
in := strings.Join([]string{
|
||||||
|
"Race Report for Galaxy PLUS Turn 1",
|
||||||
|
"",
|
||||||
|
"Battle at (#7) B-007",
|
||||||
|
"",
|
||||||
|
"Foo Groups",
|
||||||
|
"",
|
||||||
|
" # T D W S C T Q L",
|
||||||
|
" 3 Drone 1.0 1.0 0 0 - 0 1 In_Battle",
|
||||||
|
" 4 Drone 1.2 1.0 0 0 - 0 2 In_Battle",
|
||||||
|
"10 Cruiser 3.0 2.0 0 0 - 0 9 In_Battle",
|
||||||
|
"",
|
||||||
|
"Bar Groups",
|
||||||
|
"",
|
||||||
|
"# T D W S C T Q L",
|
||||||
|
"5 Pistolet 1.0 1.0 0 0 - 0 3 In_Battle",
|
||||||
|
"",
|
||||||
|
"Battle Protocol",
|
||||||
|
"",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
|
"Bar Pistolet fires on Foo Drone : Destroyed",
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
rep, battles, err := Parse(strings.NewReader(in))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(battles), 1; got != want {
|
||||||
|
t.Fatalf("len(battles) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
b := battles[0]
|
||||||
|
|
||||||
|
// The three Foo roster rows collapse into two BattleReportGroup
|
||||||
|
// entries: one Foo:Drone (rows 1+2) and one Foo:Cruiser (row 3),
|
||||||
|
// plus one Bar:Pistolet. Total 3 groups, NOT 4.
|
||||||
|
if got, want := len(b.Ships), 3; got != want {
|
||||||
|
t.Fatalf("battle.Ships = %d groups, want %d (duplicate class rows must merge)", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
var drone, cruiser, pistolet *report.BattleReportGroup
|
||||||
|
for i := range b.Ships {
|
||||||
|
g := b.Ships[i]
|
||||||
|
switch g.ClassName {
|
||||||
|
case "Drone":
|
||||||
|
drone = &g
|
||||||
|
case "Cruiser":
|
||||||
|
cruiser = &g
|
||||||
|
case "Pistolet":
|
||||||
|
pistolet = &g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if drone == nil || cruiser == nil || pistolet == nil {
|
||||||
|
t.Fatalf("missing class: drone=%v cruiser=%v pistolet=%v", drone, cruiser, pistolet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drone rows sum to Number = 3 + 4 = 7 and NumberLeft = 1 + 2 = 3.
|
||||||
|
// Protocol corroborates: four Destroyed shots against Drone, so
|
||||||
|
// 7 - 3 = 4 — the protocol's destroy count reconciles with the
|
||||||
|
// recorded delta only when both rows are summed.
|
||||||
|
if drone.Number != 7 {
|
||||||
|
t.Errorf("Drone.Number = %d, want 7 (3+4)", drone.Number)
|
||||||
|
}
|
||||||
|
if drone.NumberLeft != 3 {
|
||||||
|
t.Errorf("Drone.NumberLeft = %d, want 3 (1+2)", drone.NumberLeft)
|
||||||
|
}
|
||||||
|
// Cruiser and Pistolet are single-row classes — counts must match
|
||||||
|
// the file verbatim with no spurious merging across classes.
|
||||||
|
if cruiser.Number != 10 || cruiser.NumberLeft != 9 {
|
||||||
|
t.Errorf("Cruiser = (Number=%d, NumberLeft=%d), want (10, 9)",
|
||||||
|
cruiser.Number, cruiser.NumberLeft)
|
||||||
|
}
|
||||||
|
if pistolet.Number != 5 || pistolet.NumberLeft != 3 {
|
||||||
|
t.Errorf("Pistolet = (Number=%d, NumberLeft=%d), want (5, 3)",
|
||||||
|
pistolet.Number, pistolet.NumberLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rep-level summary must reflect the merged shape: 4 shots, one
|
||||||
|
// battle, no crash or spurious extra battles.
|
||||||
|
if got, want := len(rep.Battle), 1; got != want {
|
||||||
|
t.Fatalf("len(rep.Battle) = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if rep.Battle[0].Shots != 4 {
|
||||||
|
t.Errorf("rep.Battle[0].Shots = %d, want 4", rep.Battle[0].Shots)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +657,7 @@ func TestParseYourGroups(t *testing.T) {
|
|||||||
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
|
" 2 1 Tormoz 11.19 0.00 0.00 1.0 CAP 4 North Castle 7.5 60.66 49.50 - In_Space",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -332,7 +721,7 @@ func TestParseYourFleets(t *testing.T) {
|
|||||||
" 1 Far 2 North Castle 4.50 20 In_Space",
|
" 1 Far 2 North Castle 4.50 20 In_Space",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -381,7 +770,7 @@ func TestParseIncomingGroups(t *testing.T) {
|
|||||||
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
|
" 87 169.59 694.49 North 500.00 500.00 500.00 10.00 Capital 0.00 0.52 35.76 500.00",
|
||||||
"",
|
"",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
rep, err := Parse(strings.NewReader(in))
|
rep, _, err := Parse(strings.NewReader(in))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse: %v", err)
|
t.Fatalf("Parse: %v", err)
|
||||||
}
|
}
|
||||||
@@ -404,19 +793,22 @@ func TestParseIncomingGroups(t *testing.T) {
|
|||||||
// --- smoke tests -----------------------------------------------------
|
// --- smoke tests -----------------------------------------------------
|
||||||
|
|
||||||
type smokeWant struct {
|
type smokeWant struct {
|
||||||
race string
|
race string
|
||||||
turn uint
|
turn uint
|
||||||
mapW, mapH, planetCount uint32
|
mapW, mapH, planetCount uint32
|
||||||
voteFor string
|
voteFor string
|
||||||
votes float64
|
votes float64
|
||||||
players, extinct, local, other int
|
players, extinct, local, other int
|
||||||
uninhabited, unidentified, shipClasses int
|
uninhabited, unidentified, shipClasses int
|
||||||
localGroups, localFleets, incomingGroups int
|
localGroups, localFleets, incomingGroups int
|
||||||
|
localScience, otherScience, otherShipClass int
|
||||||
|
bombings, shipProductions int
|
||||||
|
battles int
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSmoke(t *testing.T, path string, want smokeWant) {
|
func runSmoke(t *testing.T, path string, want smokeWant) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
rep, err := parseFile(t, path)
|
rep, battles, err := parseFile(t, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
t.Skipf("legacy report fixture missing: %s", path)
|
t.Skipf("legacy report fixture missing: %s", path)
|
||||||
@@ -457,12 +849,36 @@ func runSmoke(t *testing.T, path string, want smokeWant) {
|
|||||||
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
|
{"LocalGroup", len(rep.LocalGroup), want.localGroups},
|
||||||
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
|
{"LocalFleet", len(rep.LocalFleet), want.localFleets},
|
||||||
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
|
{"IncomingGroup", len(rep.IncomingGroup), want.incomingGroups},
|
||||||
|
{"LocalScience", len(rep.LocalScience), want.localScience},
|
||||||
|
{"OtherScience", len(rep.OtherScience), want.otherScience},
|
||||||
|
{"OtherShipClass", len(rep.OtherShipClass), want.otherShipClass},
|
||||||
|
{"Bombing", len(rep.Bombing), want.bombings},
|
||||||
|
{"ShipProduction", len(rep.ShipProduction), want.shipProductions},
|
||||||
|
{"Battle (summary)", len(rep.Battle), want.battles},
|
||||||
|
{"BattleReport", len(battles), want.battles},
|
||||||
}
|
}
|
||||||
for _, c := range checks {
|
for _, c := range checks {
|
||||||
if c.got != c.want {
|
if c.got != c.want {
|
||||||
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
|
t.Errorf("%s = %d, want %d", c.name, c.got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i, summary := range rep.Battle {
|
||||||
|
if i >= len(battles) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if summary.ID != battles[i].ID {
|
||||||
|
t.Errorf("battle[%d].ID summary=%s vs report=%s",
|
||||||
|
i, summary.ID, battles[i].ID)
|
||||||
|
}
|
||||||
|
if summary.Shots != uint(len(battles[i].Protocol)) {
|
||||||
|
t.Errorf("battle[%d].Shots = %d, want %d (len(Protocol))",
|
||||||
|
i, summary.Shots, len(battles[i].Protocol))
|
||||||
|
}
|
||||||
|
if summary.Planet != battles[i].Planet {
|
||||||
|
t.Errorf("battle[%d].Planet summary=%d vs report=%d",
|
||||||
|
i, summary.Planet, battles[i].Planet)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestParseDgKNNTS039 is a smoke test: the parser must produce
|
// TestParseDgKNNTS039 is a smoke test: the parser must produce
|
||||||
@@ -477,10 +893,16 @@ func TestParseDgKNNTS039(t *testing.T) {
|
|||||||
voteFor: "KnightErrants", votes: 16.02,
|
voteFor: "KnightErrants", votes: 16.02,
|
||||||
players: 91, extinct: 49,
|
players: 91, extinct: 49,
|
||||||
local: 22, other: 89, uninhabited: 17, unidentified: 572,
|
local: 22, other: 89, uninhabited: 17, unidentified: 572,
|
||||||
shipClasses: 24,
|
shipClasses: 24,
|
||||||
localGroups: 171,
|
localGroups: 171,
|
||||||
localFleets: 0,
|
localFleets: 0,
|
||||||
incomingGroups: 0,
|
incomingGroups: 0,
|
||||||
|
localScience: 1,
|
||||||
|
otherScience: 1,
|
||||||
|
otherShipClass: 170,
|
||||||
|
bombings: 16,
|
||||||
|
shipProductions: 6,
|
||||||
|
battles: 28,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,10 +912,16 @@ func TestParseDgKNNTS040(t *testing.T) {
|
|||||||
mapW: 800, mapH: 800, planetCount: 700,
|
mapW: 800, mapH: 800, planetCount: 700,
|
||||||
players: 91, extinct: 49,
|
players: 91, extinct: 49,
|
||||||
local: 22, other: 93, uninhabited: 27, unidentified: 558,
|
local: 22, other: 93, uninhabited: 27, unidentified: 558,
|
||||||
shipClasses: 34,
|
shipClasses: 34,
|
||||||
localGroups: 207,
|
localGroups: 207,
|
||||||
localFleets: 0,
|
localFleets: 0,
|
||||||
incomingGroups: 0,
|
incomingGroups: 0,
|
||||||
|
localScience: 1,
|
||||||
|
otherScience: 1,
|
||||||
|
otherShipClass: 160,
|
||||||
|
bombings: 24,
|
||||||
|
shipProductions: 16,
|
||||||
|
battles: 79,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,10 +934,16 @@ func TestParseDgKNNTS041(t *testing.T) {
|
|||||||
mapW: 800, mapH: 800, planetCount: 700,
|
mapW: 800, mapH: 800, planetCount: 700,
|
||||||
players: 91, extinct: 50,
|
players: 91, extinct: 50,
|
||||||
local: 29, other: 103, uninhabited: 23, unidentified: 545,
|
local: 29, other: 103, uninhabited: 23, unidentified: 545,
|
||||||
shipClasses: 36,
|
shipClasses: 36,
|
||||||
localGroups: 285,
|
localGroups: 285,
|
||||||
localFleets: 0,
|
localFleets: 0,
|
||||||
incomingGroups: 12,
|
incomingGroups: 12,
|
||||||
|
localScience: 1,
|
||||||
|
otherScience: 1,
|
||||||
|
otherShipClass: 218,
|
||||||
|
bombings: 12,
|
||||||
|
shipProductions: 22,
|
||||||
|
battles: 56,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,10 +956,16 @@ func TestParseGplus40(t *testing.T) {
|
|||||||
mapW: 350, mapH: 350, planetCount: 300,
|
mapW: 350, mapH: 350, planetCount: 300,
|
||||||
players: 26, extinct: 0,
|
players: 26, extinct: 0,
|
||||||
local: 26, other: 116, uninhabited: 7, unidentified: 151,
|
local: 26, other: 116, uninhabited: 7, unidentified: 151,
|
||||||
shipClasses: 56,
|
shipClasses: 56,
|
||||||
localGroups: 255,
|
localGroups: 255,
|
||||||
localFleets: 1,
|
localFleets: 1,
|
||||||
incomingGroups: 10,
|
incomingGroups: 10,
|
||||||
|
localScience: 0,
|
||||||
|
otherScience: 0,
|
||||||
|
otherShipClass: 183,
|
||||||
|
bombings: 4,
|
||||||
|
shipProductions: 8,
|
||||||
|
battles: 30,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,10 +978,16 @@ func TestParseDgKiller031(t *testing.T) {
|
|||||||
mapW: 250, mapH: 250, planetCount: 175,
|
mapW: 250, mapH: 250, planetCount: 175,
|
||||||
players: 25, extinct: 12,
|
players: 25, extinct: 12,
|
||||||
local: 18, other: 127, uninhabited: 20, unidentified: 10,
|
local: 18, other: 127, uninhabited: 20, unidentified: 10,
|
||||||
shipClasses: 11,
|
shipClasses: 11,
|
||||||
localGroups: 175,
|
localGroups: 175,
|
||||||
localFleets: 2,
|
localFleets: 2,
|
||||||
incomingGroups: 0,
|
incomingGroups: 0,
|
||||||
|
localScience: 0,
|
||||||
|
otherScience: 0,
|
||||||
|
otherShipClass: 161,
|
||||||
|
bombings: 18,
|
||||||
|
shipProductions: 0,
|
||||||
|
battles: 83,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,22 +1001,28 @@ func TestParseDgTancordia037(t *testing.T) {
|
|||||||
mapW: 210, mapH: 210, planetCount: 140,
|
mapW: 210, mapH: 210, planetCount: 140,
|
||||||
players: 18, extinct: 7,
|
players: 18, extinct: 7,
|
||||||
local: 23, other: 62, uninhabited: 26, unidentified: 29,
|
local: 23, other: 62, uninhabited: 26, unidentified: 29,
|
||||||
shipClasses: 40,
|
shipClasses: 40,
|
||||||
localGroups: 311,
|
localGroups: 311,
|
||||||
localFleets: 30,
|
localFleets: 30,
|
||||||
incomingGroups: 2,
|
incomingGroups: 2,
|
||||||
|
localScience: 1,
|
||||||
|
otherScience: 1,
|
||||||
|
otherShipClass: 123,
|
||||||
|
bombings: 22,
|
||||||
|
shipProductions: 20,
|
||||||
|
battles: 57,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseFile(t *testing.T, rel string) (report.Report, error) {
|
func parseFile(t *testing.T, rel string) (report.Report, []report.BattleReport, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
abs, err := filepath.Abs(rel)
|
abs, err := filepath.Abs(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Report{}, err
|
return report.Report{}, nil, err
|
||||||
}
|
}
|
||||||
f, err := os.Open(abs)
|
f, err := os.Open(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return report.Report{}, err
|
return report.Report{}, nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
return Parse(f)
|
return Parse(f)
|
||||||
|
|||||||
+48884
-11788
File diff suppressed because it is too large
Load Diff
+523
-102
@@ -1381,9 +1381,13 @@ Decisions taken with the project owner during implementation:
|
|||||||
6. **`historyMode` as a prop, not a module.** Layout passes
|
6. **`historyMode` as a prop, not a module.** Layout passes
|
||||||
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
|
`historyMode={false}` (a constant in Phase 12) to `Sidebar` and
|
||||||
`BottomTabs`; both forward to their tab-bar children which omit
|
`BottomTabs`; both forward to their tab-bar children which omit
|
||||||
the order entry when the flag is true. Phase 26 introduces the
|
the order entry when the flag is true. Phase 26 superseded the
|
||||||
real `lib/history-mode.ts` module and replaces the constant in
|
"introduce `lib/history-mode.ts`" half of this decision: the
|
||||||
one place.
|
single derivation `historyMode = $derived(gameState.historyMode)`
|
||||||
|
lives directly in `+layout.svelte`, the rune split between
|
||||||
|
`currentTurn` and `viewedTurn` lives in `GameStateStore`, and
|
||||||
|
no separate module is introduced. See Phase 26 decisions for
|
||||||
|
the rationale.
|
||||||
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
|
7. **Empty-state copy is `order is empty` / `приказ пуст`.** The
|
||||||
`coming soon` placeholder text is replaced; per-row delete
|
`coming soon` placeholder text is replaced; per-row delete
|
||||||
button reads `delete` / `удалить`.
|
button reads `delete` / `удалить`.
|
||||||
@@ -2470,9 +2474,9 @@ Targeted tests:
|
|||||||
observe both commands as `applied` in the sidebar order tab and
|
observe both commands as `applied` in the sidebar order tab and
|
||||||
verify the decoded gateway payload.
|
verify the decoded gateway payload.
|
||||||
|
|
||||||
## Phase 23. Reports View — Current Turn Sections
|
## ~~Phase 23. Reports View — Current Turn Sections~~
|
||||||
|
|
||||||
Status: pending.
|
Status: done (local-ci run 2).
|
||||||
|
|
||||||
Goal: present every section of the current turn's report as readable
|
Goal: present every section of the current turn's report as readable
|
||||||
panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and
|
panels, mirroring the structure documented in [`rules.txt`](../game/rules.txt) and
|
||||||
@@ -2506,99 +2510,415 @@ Targeted tests:
|
|||||||
- Playwright e2e: open the report, scroll to each section via anchor
|
- Playwright e2e: open the report, scroll to each section via anchor
|
||||||
navigation, assert content present.
|
navigation, assert content present.
|
||||||
|
|
||||||
## Phase 24. Push Events — Turn-Ready
|
Decisions during stage:
|
||||||
|
|
||||||
Status: pending.
|
1. **Component decomposition.** The orchestrator
|
||||||
|
`lib/active-view/report.svelte` is one file; each of the twenty
|
||||||
|
sections is its own component under
|
||||||
|
`lib/active-view/report/section-<slug>.svelte`. Six distinct data
|
||||||
|
shapes (kv-list, races-style grid, planets-style grid, sub-table-
|
||||||
|
per-race, raw UUID list, fleet/group grids) sit too unevenly in one
|
||||||
|
monolith; per-section components also map directly onto the Vitest
|
||||||
|
targeted-test seam. No shared `<Section>` abstraction was extracted
|
||||||
|
— CLAUDE.md "wait for the third real caller" still holds with one
|
||||||
|
shape per section. Shared formatters live in `report/format.ts`.
|
||||||
|
2. **`races` vs `players`.** A parallel
|
||||||
|
`GameReport.players: ReportPlayer[]` was added (full roster, self
|
||||||
|
row included, extinct rows kept with `extinct: true`). The Phase 22
|
||||||
|
`races[]` (non-extinct, self excluded) stays untouched so no Phase
|
||||||
|
22 surface had to change. Extinct races are shown in Player Status
|
||||||
|
with a `RIP` marker; the orchestrator highlights the local row.
|
||||||
|
3. **Scroll save / restore.** Wired through SvelteKit's `Snapshot`
|
||||||
|
API on `routes/games/[id]/report/+page.svelte`. Captures
|
||||||
|
`window.scrollY` (the in-game shell layout expands its
|
||||||
|
`active-view-host` to fit content, so the document body is the real
|
||||||
|
scroll container) and restores via a `requestAnimationFrame` poll
|
||||||
|
that waits for `documentElement.scrollHeight` to catch up before
|
||||||
|
calling `window.scrollTo`. The earlier plan to track the host's
|
||||||
|
`scrollTop` did not survive contact with the layout's
|
||||||
|
no-explicit-height contract; the change is contained to the route
|
||||||
|
file. No new context plumbing was introduced.
|
||||||
|
4. **Active-section highlight.** `IntersectionObserver` rooted on the
|
||||||
|
viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`
|
||||||
|
tracks which section sits in the upper third of the visible area
|
||||||
|
and updates the TOC. Cheaper than a scroll handler and degrades
|
||||||
|
gracefully where IO is not available.
|
||||||
|
5. **Mobile TOC.** A sticky `<select>` at the top of the report body
|
||||||
|
replaces the desktop anchor sidebar on viewports below 768 px. No
|
||||||
|
new overlay primitive is introduced; the existing layout-owned
|
||||||
|
bottom-tab bar stays unobstructed. Picking an option scrolls the
|
||||||
|
chosen section into view.
|
||||||
|
6. **Battles section.** Battle UUIDs render as inactive monospace
|
||||||
|
`<span>` rows until Phase 27 lights up `/games/:id/battle/:battleId`.
|
||||||
|
The earlier plan to link them now was reverted: a dead link is a
|
||||||
|
worse experience than a plain identifier, and the rewire when
|
||||||
|
Phase 27 lands is one line.
|
||||||
|
7. **Foreign sciences / ship classes layout.** One sub-table per race
|
||||||
|
with a `{race} sciences` / `{race} ship classes` sub-header. The
|
||||||
|
`(race, name)` decoder sort produces stable groups; cross-race
|
||||||
|
sorting is intentionally avoided (it would be semantically
|
||||||
|
meaningless across races).
|
||||||
|
8. **Bombings wiped state.** Wiped rows get a `.wiped` CSS class plus
|
||||||
|
a dedicated `report-bombing-wiped-badge` element so the boolean is
|
||||||
|
visually explicit and easy to assert in e2e.
|
||||||
|
9. **Ships in production `prodUsed` derivation (Go side).** The legacy
|
||||||
|
text reports do not carry the engine's per-turn `ProdUsed` field —
|
||||||
|
only `Cost`, `Percent`, `Free`. The legacy parser derives an
|
||||||
|
approximation as `ShipBuildCost(shipMass, material, resources) * percent`
|
||||||
|
using a new shared helper `pkg/calc.ShipBuildCost`. The engine's
|
||||||
|
`controller.ProduceShip` was refactored to call the same helper
|
||||||
|
(behavior-preserving — engine tests stay unchanged and pass). The
|
||||||
|
approximation is documented in
|
||||||
|
`tools/local-dev/legacy-report/README.md`; live engine reports come
|
||||||
|
over FBS and never flow through this parser.
|
||||||
|
10. **Legacy parser scope.** Per user direction, the parser was
|
||||||
|
extended to populate `LocalScience`, `OtherScience`,
|
||||||
|
`OthersShipClass`, `Bombing`, and `ShipProduction` from their
|
||||||
|
legacy text sections. Battles stay in the parser's Skipped list:
|
||||||
|
the legacy text carries per-battle rosters with no stable UUID,
|
||||||
|
and synthesising IDs would invent data Phase 27 would have to
|
||||||
|
drop. `OtherGroup[]`, `UnidentifiedGroup[]`, and cargo routes
|
||||||
|
remain skipped (no legacy section).
|
||||||
|
11. **i18n namespace.** All Phase 23 strings live under
|
||||||
|
`game.report.section.<slug>.*`; the duplicate-looking entries
|
||||||
|
(sciences / ship classes columns) are deliberately separate from
|
||||||
|
`game.table.*` so the two surfaces evolve independently. ≈90 new
|
||||||
|
keys, en + ru in lockstep.
|
||||||
|
|
||||||
|
## ~~Phase 24. Push Events — Turn-Ready~~
|
||||||
|
|
||||||
|
Status: done.
|
||||||
|
|
||||||
Goal: subscribe to the server push stream and refresh client state
|
Goal: subscribe to the server push stream and refresh client state
|
||||||
when a turn-ready event arrives.
|
when a turn-ready event arrives.
|
||||||
|
|
||||||
Artifacts:
|
Artifacts (delivered):
|
||||||
|
|
||||||
- `ui/frontend/src/api/events.ts` push-stream subscription wired
|
- `ui/frontend/src/api/events.svelte.ts` — single
|
||||||
through `GalaxyClient.subscribeEvents` and Connect server-streaming
|
`SubscribeEvents` consumer per session. Absorbs the previous
|
||||||
- on `game.turn.ready` event: invalidate `(game_id, current_turn)`
|
`revocation-watcher.ts` (now deleted) so there is exactly one
|
||||||
cache entries and trigger a fresh report fetch
|
authenticated stream per device session; clean end-of-stream and
|
||||||
- a top-of-screen toast: `Turn N+1 is ready. View now.` with a button
|
`Unauthenticated` ConnectError both funnel into
|
||||||
that re-renders the active view against the new turn
|
`session.signOut("revoked")`. Exposes a `connectionStatus` rune
|
||||||
- mandatory event signature verification through `ui/core` — any
|
for the future header indicator.
|
||||||
verification failure tears down the stream and reconnects with
|
- `ui/frontend/src/lib/toast.svelte.ts` and `toast-host.svelte` —
|
||||||
exponential backoff
|
single-slot transient-notification primitive mounted from the
|
||||||
|
root layout; later phases (battle, mail) reuse it.
|
||||||
|
- `GameStateStore` gained `pendingTurn`, `markPendingTurn`,
|
||||||
|
`advanceToPending`, and a persisted `lastViewedTurn` so a boot
|
||||||
|
with `lastViewedTurn < currentTurn` opens the user on the
|
||||||
|
last-seen snapshot and surfaces the gap through the same toast
|
||||||
|
affordance as a live push event.
|
||||||
|
- Backend producer: `lobby.Service.OnRuntimeSnapshot` emits
|
||||||
|
`game.turn.ready` on every `current_turn` advance, addressed to
|
||||||
|
every active membership, idempotency key
|
||||||
|
`turn-ready:<game_id>:<turn>`, payload `{game_id, turn}`.
|
||||||
|
Catalog routes it through the push channel only.
|
||||||
|
- Mandatory event-signature verification through `ui/core`:
|
||||||
|
`core.verifyPayloadHash` + `core.verifyEvent` on every frame.
|
||||||
|
Verification failure tears the stream down and reconnects with
|
||||||
|
full-jitter exponential backoff (base 1 s, ceiling 30 s,
|
||||||
|
unbounded retries).
|
||||||
|
- Topic doc: `ui/docs/events.md`.
|
||||||
|
|
||||||
Dependencies: Phases 23, 4 (Connect streaming in gateway).
|
Dependencies: Phases 23, 4 (Connect streaming in gateway).
|
||||||
|
|
||||||
Acceptance criteria:
|
Decisions baked back in (this stage):
|
||||||
|
|
||||||
- a server-side turn cutoff produces a toast within one second;
|
- **Minimum traffic on `game.turn.ready`.** The event flips
|
||||||
- accepting the toast refreshes the active view to the new turn's data
|
`gameState.pendingTurn` only; the report for the new turn is not
|
||||||
without a full page reload;
|
fetched until the user activates the toast's "view now" action.
|
||||||
- a forged event (test fixture with bad signature) is rejected and the
|
This is the same affordance the boot-time `lastViewedTurn < currentTurn`
|
||||||
stream reconnects.
|
branch surfaces, so a player who returns after several turns sees
|
||||||
|
one "view now" path instead of an auto-jump.
|
||||||
|
- **Revocation-watcher folded into `events.svelte.ts`.** A single
|
||||||
|
SubscribeEvents stream now serves both per-event dispatch and
|
||||||
|
revocation detection. Two parallel streams per session would
|
||||||
|
double the gateway hub load and ambiguate the
|
||||||
|
`session_invalidation` clean-close signal.
|
||||||
|
- **Integration test scope.** Backend producer is covered by
|
||||||
|
`lobby/runtime_hooks_test.go` (testcontainers); UI consumer by
|
||||||
|
`tests/events.test.ts` and the Playwright e2e in
|
||||||
|
`tests/e2e/turn-ready.spec.ts`. A dedicated
|
||||||
|
`integration/turn_ready_flow_test.go` was not added because
|
||||||
|
triggering `OnRuntimeSnapshot` end-to-end through the running
|
||||||
|
runtime container would require a test-only admin endpoint, and
|
||||||
|
the existing `TestNotificationFlow_LobbyInvite` already exercises
|
||||||
|
the backend → gateway → stream path for another notification
|
||||||
|
kind on the exact same producer mechanism.
|
||||||
|
|
||||||
Targeted tests:
|
Acceptance criteria (met):
|
||||||
|
|
||||||
- Vitest unit tests for `events.ts` handling subscribe, event
|
- a server-side turn cutoff produces a toast within one second
|
||||||
dispatch, error backoff;
|
(Phase 24's stream propagation; the producer side ships with the
|
||||||
- Playwright e2e: trigger a server turn, observe toast and refresh.
|
backend changes above);
|
||||||
|
- activating the toast refreshes the active view to the new turn's
|
||||||
|
data without a full page reload
|
||||||
|
(`gameState.advanceToPending` → fresh `lobby.my.games.list` +
|
||||||
|
`user.games.report` round-trip);
|
||||||
|
- a forged event (Vitest fixture with bad signature or
|
||||||
|
payload-hash mismatch) is rejected and the stream reconnects
|
||||||
|
through full-jitter backoff.
|
||||||
|
|
||||||
## Phase 25. Sync Protocol — Order Queue, Retry, Conflict
|
Targeted tests (delivered):
|
||||||
|
|
||||||
Status: pending.
|
- Vitest: `tests/events.test.ts` (verified dispatch, type
|
||||||
|
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
|
||||||
|
clean end-of-stream sign-out, connection-status transitions);
|
||||||
|
`tests/toast.test.ts`; extensions in `tests/game-state.test.ts`
|
||||||
|
for `pendingTurn` / `lastViewedTurn` / `advanceToPending`.
|
||||||
|
- Backend: `internal/notification/catalog_test.go` (kind +
|
||||||
|
channels); `internal/lobby/runtime_hooks_test.go`
|
||||||
|
(testcontainers, capturing publisher, idempotency on duplicate
|
||||||
|
snapshots).
|
||||||
|
- Playwright: `tests/e2e/turn-ready.spec.ts` (signed
|
||||||
|
`game.turn.ready` frame surfaces the toast, manual dismiss
|
||||||
|
clears it).
|
||||||
|
|
||||||
Goal: make the order draft survive network failures and turn cutoffs
|
## ~~Phase 25. Sync Protocol — Turn Cutoff, Conflict, Auto-Pause~~
|
||||||
gracefully, with explicit user feedback on conflicts.
|
|
||||||
|
|
||||||
Artifacts:
|
Status: done.
|
||||||
|
|
||||||
- `ui/frontend/src/sync/order-queue.ts` send loop: on disconnect, hold
|
Goal: make the order draft survive transient connectivity issues
|
||||||
the most recent submit; on reconnect, retry once; on persistent
|
**and** the real turn-cutoff machinery, with explicit user feedback
|
||||||
failure, surface error to the order tab
|
on conflicts and on admin-pause states. The phase is intentionally
|
||||||
- conflict detection: if the server returns `turn_already_closed` for
|
cross-module: the UI side leans on a backend turn-cutoff guard and
|
||||||
a submit, mark the entire draft as `conflict` and surface a
|
auto-pause that did not exist before; both land together so the
|
||||||
`Turn N closed before your order was accepted. Edit and resubmit.`
|
contract is end-to-end.
|
||||||
banner in the order tab
|
|
||||||
- topic doc `ui/docs/sync-protocol.md` covering queue semantics,
|
|
||||||
retry budgets, and conflict UX
|
|
||||||
|
|
||||||
Dependencies: Phases 14, 24.
|
Decisions baked in during implementation:
|
||||||
|
|
||||||
|
- Turn-cutoff enforcement lives in `backend` (not in `game-engine`).
|
||||||
|
The scheduler flips `runtime_status` to `generation_in_progress`
|
||||||
|
before each engine tick and back to `running` after; the
|
||||||
|
user-games handlers reject every command/order in
|
||||||
|
non-running runtime states.
|
||||||
|
- A failed engine tick auto-pauses the game (`running → paused`)
|
||||||
|
through `lobby.OnRuntimeSnapshot`, and the lobby publishes a
|
||||||
|
matching `game.paused` push event. Admin resume remains the
|
||||||
|
only way out of `paused`.
|
||||||
|
- The wire-level error codes are `turn_already_closed` (cutoff
|
||||||
|
conflict) and `game_paused` (paused / starting / finished / removed).
|
||||||
|
Gateway carries them through `projectUserBackendError` unchanged.
|
||||||
|
- The UI draft store delegates to a new `OrderQueue` (single-slot
|
||||||
|
pending, single retry on reconnect via `onOnline` callback). On
|
||||||
|
`game.turn.ready` after a conflict / pause, the layout calls
|
||||||
|
`OrderDraftStore.resetForNewTurn` which wipes the draft and
|
||||||
|
re-hydrates from the server for the new turn (old commands are
|
||||||
|
preserved server-side and can be read back via
|
||||||
|
`user.games.order.get?turn=N`).
|
||||||
|
|
||||||
|
Backend artifacts:
|
||||||
|
|
||||||
|
- `backend/internal/notification/catalog.go`: new
|
||||||
|
`KindGamePaused = "game.paused"` and `catalog`/`SupportedKinds`
|
||||||
|
entries; matching `NotificationGamePaused` constant in
|
||||||
|
`backend/internal/lobby/lobby.go`; CHECK-constraint widened in
|
||||||
|
`backend/internal/postgres/migrations/00001_init.sql`.
|
||||||
|
- `backend/internal/lobby/runtime_hooks.go`:
|
||||||
|
`nextStatusFromSnapshot` flips `running → paused` on
|
||||||
|
`engine_unreachable` / `generation_failed`; new
|
||||||
|
`publishGamePaused` mirrors `publishTurnReady`, idempotency key
|
||||||
|
`paused:<game_id>:<turn>`, payload `{game_id, turn, reason}`.
|
||||||
|
- `backend/internal/runtime/scheduler.go`: `tick` wraps the engine
|
||||||
|
call with `generation_in_progress` / `running` flips and forwards
|
||||||
|
failure snapshots to lobby through
|
||||||
|
`Service.publishFailureSnapshot`.
|
||||||
|
- `backend/internal/runtime/service.go`: `CheckOrdersAccept` plus
|
||||||
|
the pure `OrdersAcceptStatus` helper used by both `Orders` and
|
||||||
|
`Commands` user-games handlers.
|
||||||
|
- `backend/internal/server/httperr/httperr.go`: new
|
||||||
|
`CodeTurnAlreadyClosed`, `CodeGamePaused`; openapi.yaml
|
||||||
|
`ErrorBody.code` enum extended.
|
||||||
|
- `backend/internal/server/handlers_user_games.go`:
|
||||||
|
`requireOrdersOpen` runs before forwarding, maps sentinels to
|
||||||
|
HTTP 409 + the matching code.
|
||||||
|
|
||||||
|
UI artifacts:
|
||||||
|
|
||||||
|
- `ui/frontend/src/sync/order-queue.svelte.ts` (new) — `OrderQueue`
|
||||||
|
class with offline detection, classification of
|
||||||
|
`turn_already_closed` / `game_paused`, dependency-injected
|
||||||
|
online probe + event listeners. Pure-function helper
|
||||||
|
`classifyResult` reused from tests.
|
||||||
|
- `ui/frontend/src/sync/order-types.ts` — `CommandStatus` gains
|
||||||
|
`conflict`.
|
||||||
|
- `ui/frontend/src/sync/order-draft.svelte.ts` — wires
|
||||||
|
`OrderQueue` through `runSync`, adds `conflict` / `paused` /
|
||||||
|
`offline` to `SyncStatus`, plus `conflictBanner` /
|
||||||
|
`pausedBanner` runes, `markPaused`, `resetForNewTurn`,
|
||||||
|
`clearConflictForMutation`, sticky-`paused` guard in
|
||||||
|
`hydrateFromServer`. `bindClient(client, { getCurrentTurn })`
|
||||||
|
lets the conflict banner interpolate the turn number.
|
||||||
|
- `ui/frontend/src/lib/sidebar/order-tab.svelte` — renders
|
||||||
|
conflict / paused banners and the new `conflict` per-row badge;
|
||||||
|
status bar carries the offline / conflict / paused copy.
|
||||||
|
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new keys for
|
||||||
|
`sync.{offline,conflict,paused}`, `conflict.banner`
|
||||||
|
(with `{turn}` interpolation) plus `banner_no_turn` fallback,
|
||||||
|
`paused.banner`, `status.conflict`.
|
||||||
|
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||||||
|
subscribes to `game.paused`; `game.turn.ready` handler now
|
||||||
|
triggers `resetForNewTurn` when the prior `syncStatus` was
|
||||||
|
`conflict` / `paused`. `bindClient` is invoked with
|
||||||
|
`getCurrentTurn: () => gameState.currentTurn`.
|
||||||
|
- `ui/docs/sync-protocol.md` (new) — send-loop semantics, retry
|
||||||
|
budget, conflict and paused UX, recovery paths.
|
||||||
|
- `ui/docs/order-composer.md` — stale Phase 25 paragraph
|
||||||
|
replaced with a pointer to the new topic doc; state-machine
|
||||||
|
diagram extended with the `conflict` transition.
|
||||||
|
|
||||||
|
Dependencies: Phases 14, 24; backend notification / lobby /
|
||||||
|
runtime modules.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- submitting an order while offline queues it and submits successfully
|
- submitting an order while offline queues it and submits
|
||||||
on reconnect;
|
successfully on reconnect (one attempt on the next `online`
|
||||||
- a turn cutoff between draft and submit produces a visible conflict
|
event, no inline retry storm);
|
||||||
banner with no data loss;
|
- a turn cutoff between draft and submit produces a visible
|
||||||
- the order tab clearly distinguishes `draft`, `submitting`,
|
conflict banner with the turn number; the local draft is
|
||||||
`accepted`, `rejected`, `conflict` states per command.
|
preserved until the next `game.turn.ready`, then the layout
|
||||||
|
wipes it and re-hydrates from the server for `turn = N+1`;
|
||||||
|
- a runtime failure during generation flips the game into
|
||||||
|
`paused`, emits `game.paused`, and the order tab shows the
|
||||||
|
pause banner; submits are blocked until the next
|
||||||
|
`game.turn.ready` clears the state;
|
||||||
|
- the order tab clearly distinguishes `draft`, `valid`,
|
||||||
|
`invalid`, `submitting`, `applied`, `rejected`, and
|
||||||
|
`conflict` states per command.
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest unit tests for `order-queue` covering all state transitions;
|
- Backend: `runtime_hooks_unit_test.go` for
|
||||||
- Playwright e2e: simulate network drop using Playwright's offline
|
`nextStatusFromSnapshot`, `orders_accept_test.go` for the
|
||||||
mode, submit an order, restore network, confirm submission;
|
per-record decision, plus existing testcontainer-backed
|
||||||
- regression test: force a turn cutoff during submit, assert conflict
|
`runtime_hooks_test.go` covering the published intent. Catalog
|
||||||
banner appears.
|
/ event tests extended with `game.paused`.
|
||||||
|
- UI Vitest: `tests/order-queue.test.ts` (classification +
|
||||||
|
offline plumbing), extended `tests/order-draft.test.ts`
|
||||||
|
(conflict marks commands, mutation clears banner, pause
|
||||||
|
blocks sync, offline holds + flushes on `online`,
|
||||||
|
`resetForNewTurn` re-hydrates), extended
|
||||||
|
`tests/order-tab.test.ts` (banner DOM + sync-status
|
||||||
|
attribute), extended `tests/events.test.ts` (`game.paused`
|
||||||
|
dispatch).
|
||||||
|
- Playwright e2e: `tests/e2e/order-sync.spec.ts` — conflict
|
||||||
|
banner on `turn_already_closed` reply and paused banner on
|
||||||
|
the signed `game.paused` frame.
|
||||||
|
|
||||||
## Phase 26. History Mode
|
## ~~Phase 26. History Mode~~
|
||||||
|
|
||||||
Status: pending.
|
Status: done. Verified on local-ci run 6 (`success`, 2d17760).
|
||||||
|
|
||||||
Goal: let the user navigate to past turns and view all data as it was,
|
Goal: let the user navigate to past turns and view all data as it was,
|
||||||
with no order composition allowed.
|
with no order composition allowed.
|
||||||
|
|
||||||
Artifacts:
|
Decisions baked in during implementation:
|
||||||
|
|
||||||
- `ui/frontend/src/lib/header/turn-navigator.svelte` clickable turn
|
1. **History state lives in `GameStateStore`, no separate module.**
|
||||||
counter expansion: popover (desktop) / bottom-sheet (mobile) listing
|
The Phase 12 plan-line "introduce `lib/history-mode.ts`" is
|
||||||
recent turns and a search field for jumping to a turn number
|
superseded: the only consumer needs a one-line derivation
|
||||||
- `ui/frontend/src/lib/history-mode.ts` global toggle wired into every
|
(`historyMode = $derived(gameState.historyMode)`), and the
|
||||||
view's data source: when active, all `state-binding`, table, report,
|
project's compactness rule rejects an abstraction with no second
|
||||||
inspector, and map sources read from the historical snapshot for the
|
caller. The store ships two distinct turn runes — `currentTurn`
|
||||||
selected turn
|
(server's authoritative latest, set by `setGame` /
|
||||||
- `ui/frontend/src/lib/header/history-banner.svelte` persistent banner
|
`advanceToPending`) and `viewedTurn` (what the UI displays, set
|
||||||
reading `Viewing turn N · read-only` with a `Return to current turn`
|
by `viewTurn` / `returnToCurrent`) — plus the derived
|
||||||
action
|
`historyMode` rune that flips when `viewedTurn < currentTurn`.
|
||||||
- order tab hidden in history mode (already prepared in Phase 12)
|
2. **`OrderDraftStore` gates mutations at one chokepoint.**
|
||||||
|
`bindClient` gains an optional `getHistoryMode: () => boolean`
|
||||||
|
alongside the existing `getCurrentTurn`; `add` / `remove` /
|
||||||
|
`move` return early when it reports `true`. Every Phase 14–22
|
||||||
|
inspector that calls `orderDraft.add(...)` becomes inert in
|
||||||
|
history mode without per-component edits.
|
||||||
|
3. **Turn navigator UX.** Header replaces the static `turn N` text
|
||||||
|
with `← turn N →`: arrows step ±1 (disabled at `0` and
|
||||||
|
`currentTurn`), the middle button opens a dropdown of every
|
||||||
|
turn `Turn #0`…`Turn #currentTurn` with the current row carrying
|
||||||
|
a badge. No free-text input. Desktop uses an absolute popover
|
||||||
|
under the header; mobile reuses `view-menu.svelte`'s fixed-
|
||||||
|
drawer pattern (no new primitive). Selecting the current row
|
||||||
|
routes through `returnToCurrent()` so the "leave history" path
|
||||||
|
has one canonical entry.
|
||||||
|
4. **History is ephemeral across reloads.** `last-viewed-turn` is
|
||||||
|
written only when `viewedTurn === currentTurn`; historical
|
||||||
|
excursions never advance the resume bookmark. Page reload exits
|
||||||
|
history mode. The visibility-refresh listener is a no-op while
|
||||||
|
`historyMode` is true so a tab-focus event cannot silently kick
|
||||||
|
the user back onto the live turn. Push events (Phase 24) continue
|
||||||
|
to deliver new-turn notifications, so the pending-turn toast
|
||||||
|
still appears.
|
||||||
|
5. **Past-turn report cache.** New `game-history/{gameId}/turn/{N}`
|
||||||
|
namespace stores past-turn reports; `viewTurn(N)` reads cache
|
||||||
|
first and falls back to the network on miss. Past turns are
|
||||||
|
immutable so the cache has no TTL and no eviction. The current
|
||||||
|
turn deliberately skips the cache (it is mutable until the next
|
||||||
|
tick).
|
||||||
|
6. **Order overlay short-circuits in history mode.**
|
||||||
|
`RenderedReportSource.report` returns the raw server snapshot
|
||||||
|
instead of running `applyOrderOverlay`: the draft is composed
|
||||||
|
against the current turn, projecting it onto a past report would
|
||||||
|
render fictional intent.
|
||||||
|
7. **`game.shell.headline` removed.** The Phase 11 i18n key that
|
||||||
|
formatted `{race} @ {game}, turn {turn}` is deleted; the header
|
||||||
|
composes `race @ game` in plain text and delegates `turn N` to
|
||||||
|
`turn-navigator.svelte`. The existing `game-shell-headline`
|
||||||
|
testid moves to the `.left` wrapper so e2e specs that match
|
||||||
|
`toContainText("turn N")` continue to find the substring inside
|
||||||
|
the navigator's button.
|
||||||
|
|
||||||
|
Artifacts (delivered):
|
||||||
|
|
||||||
|
- `ui/frontend/src/lib/game-state.svelte.ts` — `viewedTurn` rune,
|
||||||
|
derived `historyMode` rune, `viewTurn(turn)` /
|
||||||
|
`returnToCurrent()` public methods, `loadTurn(turn, { isCurrent })`
|
||||||
|
refactor that gates `last-viewed-turn` writes, `readReport` cache
|
||||||
|
layer over the `game-history` namespace, visibility-refresh
|
||||||
|
short-circuit in history mode, `initSynthetic` keeps
|
||||||
|
`currentTurn === viewedTurn`.
|
||||||
|
- `ui/frontend/src/sync/order-draft.svelte.ts` — `bindClient` accepts
|
||||||
|
`getHistoryMode`, `add` / `remove` / `move` no-op when active.
|
||||||
|
- `ui/frontend/src/lib/rendered-report.svelte.ts` — overlay short-
|
||||||
|
circuit when `gameState.historyMode === true`.
|
||||||
|
- `ui/frontend/src/lib/header/turn-navigator.svelte` (new) — header
|
||||||
|
triplet `← turn N →` + dropdown popover / drawer, reuses
|
||||||
|
`view-menu.svelte`'s outside-click / Escape pattern.
|
||||||
|
- `ui/frontend/src/lib/header/history-banner.svelte` (new) — sticky
|
||||||
|
read-only banner under the header with a `Return to current turn`
|
||||||
|
action.
|
||||||
|
- `ui/frontend/src/lib/header/header.svelte` — embeds
|
||||||
|
`<TurnNavigator />` next to the race-and-game identity span;
|
||||||
|
drops the static turn portion.
|
||||||
|
- `ui/frontend/src/routes/games/[id]/+layout.svelte` —
|
||||||
|
`historyMode` derived rune, `getHistoryMode` passed to
|
||||||
|
`orderDraft.bindClient`, `<HistoryBanner />` mounted between
|
||||||
|
header and body.
|
||||||
|
- `ui/frontend/src/lib/i18n/locales/{en,ru}.ts` — new
|
||||||
|
`game.shell.history.*` and `game.shell.turn.*` keys; the now-
|
||||||
|
unused `game.shell.headline` entry is removed.
|
||||||
|
- `ui/docs/storage.md` — `game-history` namespace row; also adds
|
||||||
|
the `game-prefs/{gameId}/last-viewed-turn` row (Phase 11 doc
|
||||||
|
gap).
|
||||||
|
- `ui/docs/game-state.md` — current/viewed-turn rune table, the
|
||||||
|
new History mode section.
|
||||||
|
- `ui/docs/navigation.md` — describes the navigator, the read-only
|
||||||
|
banner, and the `historyMode` derivation wiring.
|
||||||
|
- `ui/docs/order-composer.md` — notes the mutation gate, the
|
||||||
|
overlay short-circuit, and the cross-doc references.
|
||||||
|
- Vitest: `tests/game-state.test.ts` extended with `viewTurn` /
|
||||||
|
`returnToCurrent` / `historyMode` derivation / cache hit /
|
||||||
|
visibility-refresh short-circuit / resume-from-stale-bookmark
|
||||||
|
flips; `tests/order-draft.test.ts` extended with the history-
|
||||||
|
mode gate cases; `tests/turn-navigator.test.ts` and
|
||||||
|
`tests/history-banner.test.ts` (new) cover the components in
|
||||||
|
isolation.
|
||||||
|
- Playwright: `tests/e2e/history-mode.spec.ts` (new) — drives the
|
||||||
|
full chrome flow against `/games/<id>/table/planets`. The map
|
||||||
|
view is deliberately avoided because the Pixi renderer can
|
||||||
|
monopolise the headless Chromium main thread long enough to let
|
||||||
|
the `toContainText` poll find stale "turn ?" content; the table
|
||||||
|
view exercises the same wiring without that rendering tail.
|
||||||
|
|
||||||
Dependencies: Phases 11, 12, 23.
|
Dependencies: Phases 11, 12, 23.
|
||||||
|
|
||||||
@@ -2610,59 +2930,145 @@ Acceptance criteria:
|
|||||||
available;
|
available;
|
||||||
- returning to the current turn restores live data and re-shows the
|
- returning to the current turn restores live data and re-shows the
|
||||||
order tab with the prior draft intact (state preservation);
|
order tab with the prior draft intact (state preservation);
|
||||||
- all UI views (map, tables, report, battle, mail) work in history
|
- battle / mail stub views still render correctly while the
|
||||||
mode.
|
read-only banner is visible (Phases 27 / 28 will replace the
|
||||||
|
stubs with real implementations; the wiring is sufficient
|
||||||
|
today).
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest unit tests for `history-mode` toggle and per-view source
|
- Vitest unit tests for current/viewed turn rune split, view-turn
|
||||||
selection;
|
cache behaviour, visibility-refresh short-circuit, order-draft
|
||||||
- Playwright e2e: enter history mode, navigate three views, return,
|
history-mode gate, turn-navigator interactions, history-banner
|
||||||
confirm the order draft survived.
|
rendering / action;
|
||||||
|
- Playwright e2e: enter history mode via arrow, navigate via
|
||||||
|
dropdown, return via banner action, confirm the order draft
|
||||||
|
survives the round-trip.
|
||||||
|
|
||||||
## Phase 27. Battle Viewer
|
## ~~Phase 27. Battle Viewer~~
|
||||||
|
|
||||||
Status: pending.
|
Status: done (local-ci run 14).
|
||||||
|
|
||||||
Goal: render battles as a dedicated view with playback controls
|
Goal: ship a dedicated Battle Viewer rendering radial scenes from
|
||||||
(play, pause, step forward, step backward, rewind), driven by the
|
`BattleReport` data (planet centred, races on the outer ring, per
|
||||||
server-side combat log; render battle and bombing markers on the map.
|
ship-class clusters, animated shot lines), plus battle and bombing
|
||||||
|
markers on the map. Battles and bombings stay strictly separate —
|
||||||
|
bombings remain a static table in the Reports view, only battles
|
||||||
|
get the animated viewer.
|
||||||
|
|
||||||
Artifacts:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/frontend/src/map/battle-markers.ts` renders markers on the map
|
- engine: `game/internal/router/handler/battle.go` for
|
||||||
for current-turn battles and bombings within visibility, clickable
|
`GET /api/v1/battle/:turn/:uuid` (handler pre-existed; Phase 27
|
||||||
to open the battle viewer
|
added the tests + openapi schemas)
|
||||||
- `ui/frontend/src/routes/games/[id]/battle/[battleId]/+page.svelte`
|
- engine wire: `pkg/model/report/battle.go` ships a new
|
||||||
view with the combatant list, the round-by-round log, and a player
|
`BattleSummary{id, planet, shots}`; `Report.battle` carries a
|
||||||
control bar
|
slice of these summaries so the map can place markers without
|
||||||
- `ui/frontend/src/lib/battle-player/` round timeline, current-round
|
fetching every full report
|
||||||
highlight, per-shot animation
|
- backend: `backend/internal/engineclient/client.go.FetchBattle`
|
||||||
- entry points to the viewer: marker on map, row in the report's
|
and `backend/internal/server/handlers_user_games.go.Battle`
|
||||||
battles section, push-event toast when a battle this turn involved
|
expose `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`
|
||||||
the player
|
- UI viewer: `ui/frontend/src/lib/battle-player/`
|
||||||
- topic doc `ui/docs/battle-viewer-ux.md` covering playback
|
(`radial-layout.ts`, `timeline.ts`, `battle-scene.svelte`,
|
||||||
semantics, accessibility (the combat log must be readable as text
|
`playback-controls.svelte`, `battle-viewer.svelte`); SVG-based,
|
||||||
for users who skip animations)
|
one frame per protocol entry, full controls (play/pause + step
|
||||||
|
back + step forward + rewind + 1x/2x/4x speed switch)
|
||||||
|
- UI route + page wrapper:
|
||||||
|
`ui/frontend/src/routes/games/[id]/battle/[[battleId]]/+page.svelte`
|
||||||
|
feeds `gameId` / `turn` / `battleId` into
|
||||||
|
`ui/frontend/src/lib/active-view/battle.svelte`, which loads the
|
||||||
|
report via `api/battle-fetch.ts` (synthetic-fixture path + real
|
||||||
|
engine fetch through the backend gateway)
|
||||||
|
- UI report link: `lib/active-view/report/section-battles.svelte`
|
||||||
|
now links every battle UUID into
|
||||||
|
`/games/{id}/battle/{uuid}?turn={turn}`
|
||||||
|
- UI map markers: `ui/frontend/src/map/battle-markers.ts` emits a
|
||||||
|
yellow X cross per battle (two `LinePrim` through the planet's
|
||||||
|
bounding-square diagonals; stroke width scales 1px..5px with
|
||||||
|
protocol length) plus a stroke-only ring per bombing (yellow when
|
||||||
|
damaged, red when wiped). Wired into `state-binding.ts`; the map
|
||||||
|
click handler dispatches battle clicks to the viewer and bombing
|
||||||
|
clicks to a scroll-into-view of the matching row in Reports.
|
||||||
|
- topic doc `ui/docs/battle-viewer-ux.md` covers playback
|
||||||
|
semantics, accessibility (the always-visible `<ol>` log), the
|
||||||
|
radial layout, and the marker click behaviour
|
||||||
|
- docs/FUNCTIONAL.md §6.5 (Battle viewer) + mirror in
|
||||||
|
docs/FUNCTIONAL_ru.md
|
||||||
|
|
||||||
Dependencies: Phase 23.
|
Dependencies: Phase 23.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- battle and bombing markers render on the map for the seeded
|
- battle and bombing markers render on the map for the seeded
|
||||||
current-turn report and are clickable to open the viewer;
|
current-turn report and are clickable: battle → Battle Viewer for
|
||||||
- the viewer plays back any battle in the seeded report including
|
the corresponding UUID, bombing → scroll to its row in Reports;
|
||||||
multi-round and one-sided battles;
|
- the Battle Viewer plays back any `BattleReport` end-to-end with
|
||||||
- step controls allow precise inspection;
|
step back / step forward / rewind / 1x-2x-4x speeds; observers
|
||||||
- the same data is accessible as a static text log for accessibility.
|
(`inBattle === false`) are not drawn; eliminated races drop out
|
||||||
|
and survivors re-distribute on the next frame;
|
||||||
|
- the same protocol is mirrored as an always-visible text log under
|
||||||
|
the scene for accessibility;
|
||||||
|
- bombings keep their Phase 23 static table layout in Reports; no
|
||||||
|
Battle Viewer entry-point is wired from them.
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest unit tests for round-state transitions;
|
- Vitest unit: radial layout (1/2/3 races) and timeline frame-
|
||||||
- Vitest unit tests for marker rendering on torus and no-wrap
|
builder (initial state, shot decrement, race-elimination drop-out)
|
||||||
fixtures;
|
in `tests/battle-player.test.ts`
|
||||||
- Playwright e2e: click a battle marker on the map, play through,
|
- Vitest unit: marker primitives + stroke-width formula
|
||||||
step backward, return to the report.
|
(1→1, 50→2.98, 100→5, 200→5) in `tests/battle-markers.test.ts`
|
||||||
|
- Go unit: engine HTTP handler validations (400 / 404 / 500) in
|
||||||
|
`game/internal/router/battle_test.go`
|
||||||
|
- Go contract: openapi freezes for the new endpoint and schemas in
|
||||||
|
`game/openapi_contract_test.go`
|
||||||
|
- Playwright e2e: click battle marker → viewer; play / step back;
|
||||||
|
click battle UUID in Reports → viewer; click bombing marker →
|
||||||
|
Reports bombings row scrolled into view.
|
||||||
|
|
||||||
|
Decisions during stage:
|
||||||
|
|
||||||
|
1. **Bombings stay a static table.** `section-bombings.svelte`
|
||||||
|
already covers the "who bombed, with what power, wiped or not"
|
||||||
|
requirement; nothing in Phase 27 touches it. Bombings explicitly
|
||||||
|
do not open the Battle Viewer.
|
||||||
|
2. **Wire change.** `Report.Battle` switched from `[]uuid.UUID` to
|
||||||
|
`[]BattleSummary{id, planet, shots}` so the map renderer can
|
||||||
|
place markers without N extra fetches and so the cross-marker
|
||||||
|
stroke can scale with protocol length.
|
||||||
|
3. **Battle marker = yellow X cross** drawn as 2 `LinePrim` through
|
||||||
|
the corners of the planet's circumscribed square; stroke width
|
||||||
|
`clamp(1 + (shots - 1) * 4 / 99, 1, 5)` px.
|
||||||
|
4. **Bombing marker = stroke-only ring** slightly larger than the
|
||||||
|
planet circle. Yellow when damaged, red when wiped. Click =
|
||||||
|
scroll to the matching row in Reports (not the viewer).
|
||||||
|
5. **Viewer URL** `/games/[id]/battle/[battleId]?turn=N`. Turn is a
|
||||||
|
query param so the same route works in history mode.
|
||||||
|
6. **SVG, not PixiJS** for the radial scene — isolated component,
|
||||||
|
no need for WebGL; PixiJS stays as the map renderer.
|
||||||
|
7. **Playback controls full set**: play / pause + step back + step
|
||||||
|
forward + rewind + 1x / 2x / 4x switch. 1x = 400 ms per frame.
|
||||||
|
8. **Observer groups (`inBattle: false`)** are filtered out of both
|
||||||
|
the scene and the text log.
|
||||||
|
9. **Cluster aggregation by `(race, className)`** so a race with
|
||||||
|
multiple groups of the same class collapses to one labelled
|
||||||
|
circle. Stable target for shot-line endpoints.
|
||||||
|
10. **Page loader switches on `synthetic-` gameId prefix** —
|
||||||
|
synthetic mode uses `api/synthetic-battle.ts` fixtures; live
|
||||||
|
games hit `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
|
||||||
|
BattleViewer component itself is a logically isolated prop sink.
|
||||||
|
11. **Always-visible `<ol>` text protocol** under the scene satisfies
|
||||||
|
the accessibility requirement without a separate "skip
|
||||||
|
animation" toggle.
|
||||||
|
|
||||||
|
TODO carried into Phase 27 deferred items
|
||||||
|
(see Phase 27 of this PLAN's deferred-followups list, near the
|
||||||
|
bottom):
|
||||||
|
|
||||||
|
- push event `game.battle.new` + toast deep-link;
|
||||||
|
- richer ship-class visuals derived from class characteristics;
|
||||||
|
- animated transitions when survivors re-distribute after an
|
||||||
|
elimination (currently hard-jumps).
|
||||||
|
|
||||||
## Phase 28. Diplomatic Mail View
|
## Phase 28. Diplomatic Mail View
|
||||||
|
|
||||||
@@ -3134,3 +3540,18 @@ phase listed in the parenthesis when that phase lands.
|
|||||||
exercises a unary Connect call and a server-streaming Connect call
|
exercises a unary Connect call and a server-streaming Connect call
|
||||||
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
|
through `testenv.Bootstrap`. (Phase 7+, fold into the phase that
|
||||||
needs it.)
|
needs it.)
|
||||||
|
- **Battle viewer — push event `game.battle.new`** — when a battle
|
||||||
|
involving the current player lands, emit a backend notification
|
||||||
|
intent (idempotency `battle-new:<game_id>:<turn>:<battle_id>`,
|
||||||
|
payload `{game_id, turn, battle_id}`) so the in-game shell
|
||||||
|
surfaces a toast with a deep link into the Battle Viewer.
|
||||||
|
(Phase 27 deferred; needs an engine emit-side change.)
|
||||||
|
- **Battle viewer — richer ship-class visuals** — current MVP draws
|
||||||
|
one small circle plus `<class>:<numLeft>` label per `(race,
|
||||||
|
className)` pair. Future work derives shape / scale from mass,
|
||||||
|
armament, shields, and the number of ships in the group.
|
||||||
|
(Phase 27 deferred.)
|
||||||
|
- **Battle viewer — animated re-distribution on elimination** —
|
||||||
|
current implementation hard-jumps to the new spacing on the next
|
||||||
|
frame; replace with an easing so the survivors visibly slide
|
||||||
|
along the outer ring. (Phase 27 deferred.)
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
# Battle Viewer UX
|
||||||
|
|
||||||
|
Phase 27 ships a dedicated viewer for battles (`/games/<id>/battle/<battleId>`).
|
||||||
|
Bombings stay where they were in Phase 23 — a static table in the
|
||||||
|
Reports view (`section-bombings.svelte`). The two domains are
|
||||||
|
deliberately not mixed in any visual surface or click target.
|
||||||
|
|
||||||
|
## Data shape
|
||||||
|
|
||||||
|
The `BattleViewer` component (`lib/battle-player/battle-viewer.svelte`)
|
||||||
|
is logically isolated. It accepts a `BattleReport` matching
|
||||||
|
`pkg/model/report/battle.go`. The fields it uses:
|
||||||
|
|
||||||
|
- `id`, `planet`, `planetName` — header + the central-planet glyph.
|
||||||
|
- `races: { [raceId]: raceUUID }` — race index space used by the
|
||||||
|
protocol's `a` / `d` fields.
|
||||||
|
- `ships: { [groupKey]: BattleReportGroup }` — ship-group rosters
|
||||||
|
with `race` name, `className`, initial `num`, end-state `numLeft`,
|
||||||
|
and the `inBattle` flag. Observer groups (`inBattle: false`) are
|
||||||
|
never drawn.
|
||||||
|
- `protocol: BattleActionReport[]` — flat list of shots. Each carries
|
||||||
|
attacker `(a, sa)`, defender `(d, sd)`, and `x` (destroyed?).
|
||||||
|
|
||||||
|
The component asks `timeline.ts.buildFrames(report)` to expand the
|
||||||
|
protocol into `protocol.length + 1` frames; frame 0 is the initial
|
||||||
|
state and frame `N` reflects state after action `protocol[N-1]`. The
|
||||||
|
race index per ship group is derived from the protocol itself —
|
||||||
|
every in-battle group appears at least once as attacker or defender,
|
||||||
|
and the engine never crosses these wires.
|
||||||
|
|
||||||
|
## Radial scene
|
||||||
|
|
||||||
|
The scene (`lib/battle-player/battle-scene.svelte`, SVG) places the
|
||||||
|
planet at the centre and arrays the still-active races on an outer
|
||||||
|
ring at equal angular spacing. Each race anchor hosts a *cloud* of
|
||||||
|
class circles arranged on a Vogel sunflower spiral biased toward the
|
||||||
|
planet (the cluster anchor is pushed inward by a quarter step so the
|
||||||
|
rank-0 node — the heaviest group by NumberLeft — sits closest to the
|
||||||
|
planet, and the spiral fans the rest behind it). When a race is
|
||||||
|
wiped out, it drops out of the active list and the survivors are
|
||||||
|
re-spaced on the next frame.
|
||||||
|
|
||||||
|
Each class circle is one *bucket* keyed by `(race, className)`:
|
||||||
|
tech-variants of the same class collapse into one node so the scene
|
||||||
|
stays readable when a race fields a dozen tech levels of the same
|
||||||
|
hull. The per-bucket label `<className>:<numLeft>` sums NumberLeft
|
||||||
|
across the underlying groups; per-tech detail is available in the
|
||||||
|
Reports view (Foreign Ship Classes / My Ship Types).
|
||||||
|
|
||||||
|
Bucket order inside a cluster is **locked at battle start** by the
|
||||||
|
initial ship count (`num` summed across tech variants, descending),
|
||||||
|
together with mass, radius and local position. The static layout
|
||||||
|
lives in `staticBucketsByRace`; the per-frame derivation
|
||||||
|
`renderedByRace` overlays the live `NumberLeft` and drops buckets
|
||||||
|
once they hit zero. The remaining buckets keep their slots in the
|
||||||
|
cloud, so the cluster does not reshuffle when a class empties — the
|
||||||
|
empty bucket simply disappears.
|
||||||
|
|
||||||
|
Vogel positions are reassigned per rank by their inward distance
|
||||||
|
toward the planet, so the rank-0 bucket (the largest by initial
|
||||||
|
ship count) always sits at the most-inward spiral slot.
|
||||||
|
|
||||||
|
When two races remain in battle the radial layout switches to the
|
||||||
|
horizontal duel: race 0 at 9 o'clock, race 1 at 3 o'clock. This
|
||||||
|
keeps both race labels clear of the SVG top edge and reads as the
|
||||||
|
two sides facing off naturally.
|
||||||
|
|
||||||
|
Circle radius scales with per-ship FullMass (Empty + Carrying via
|
||||||
|
the per-ship `LoadQuantity`). The viewer resolves a
|
||||||
|
`(race, className) → ShipClassRef` lookup from the surrounding
|
||||||
|
`GameReport.localShipClass` + `otherShipClass` tables and runs it
|
||||||
|
through the existing wasm bridge to `pkg/calc/ship.go`
|
||||||
|
(`emptyMass` + `carryingMass` + `fullMass`). The radius is then
|
||||||
|
`MIN_RADIUS + (MAX_RADIUS − MIN_RADIUS) × sqrt(mass / maxMassInBattle)`
|
||||||
|
clamped to `[6, 24]` pixels — per-battle normalisation, so the
|
||||||
|
heaviest ship in any given battle renders at the cap. Unknown class
|
||||||
|
or invalid params fall back to MAX_RADIUS so the bucket stays
|
||||||
|
visible.
|
||||||
|
|
||||||
|
The current frame's shot is drawn as a thin line from the attacker's
|
||||||
|
class circle to the defender's class circle. Colour:
|
||||||
|
|
||||||
|
- red (`#ee3344`) when the action's `x === true` (the defender
|
||||||
|
ship was destroyed),
|
||||||
|
- green (`#44dd66`) otherwise.
|
||||||
|
|
||||||
|
Each frame redraws the line in isolation, so continuous playback
|
||||||
|
produces the "shot-shot-shot" pulse the user wanted.
|
||||||
|
|
||||||
|
## Playback controls
|
||||||
|
|
||||||
|
`lib/battle-player/playback-controls.svelte` ships:
|
||||||
|
|
||||||
|
| Control | Effect |
|
||||||
|
| ------------------ | ------------------------------------------------------- |
|
||||||
|
| ⏮ rewind | Stop, jump to frame 0 |
|
||||||
|
| ◀︎◀︎ step back | Stop, frame ← frame − 1 |
|
||||||
|
| ▶︎ / ⏸ play | Toggle continuous playback |
|
||||||
|
| ▶︎▶︎ step forward | Stop, frame ← frame + 1 |
|
||||||
|
| `Nx` cycle speed | Single button, cycles 1x → 2x → 4x → 6x → 1x; the label shows the current speed (400 / 200 / 100 / 67 ms per frame) |
|
||||||
|
| `Log ▲▼` toggle | Collapses / expands the always-visible text protocol so the user can give the scene the full viewer height |
|
||||||
|
|
||||||
|
When the timeline is at its end and the user hits play, the frame
|
||||||
|
counter wraps to 0 and continues. Step buttons disable themselves at
|
||||||
|
their boundary.
|
||||||
|
|
||||||
|
A drag-seek slider sits between the scene and the controls. Dragging
|
||||||
|
pauses playback and lands `frameIndex` on the chosen shot — handy
|
||||||
|
for jumping to the moment a particular race started losing ground.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Below the scene the viewer renders a static `<ol>` text protocol —
|
||||||
|
one line per action, formatted from `BattleReportGroup.race` and
|
||||||
|
`BattleReportGroup.className`. The line for the current frame is
|
||||||
|
highlighted so a non-visual reader can follow along by scrolling
|
||||||
|
the log instead of watching the SVG. The list is always present
|
||||||
|
and never hidden, satisfying the original Phase 27 acceptance "the
|
||||||
|
same data is accessible as a static text log".
|
||||||
|
|
||||||
|
Each log row is also a `<button>`: a click or Enter/Space jumps
|
||||||
|
playback to that shot (pauses and seeks). The list auto-scrolls
|
||||||
|
the current row into view as the timeline advances, so the user
|
||||||
|
does not have to chase the highlight on long battles.
|
||||||
|
|
||||||
|
## Playback details
|
||||||
|
|
||||||
|
On play, the shot line + the defender circle's colour flash gate
|
||||||
|
on a per-frame timer that blinks them off during the last 10 % of
|
||||||
|
the frame's duration. Two consecutive shots from the same attacker
|
||||||
|
on the same defender therefore look like two distinct pulses
|
||||||
|
rather than one continuous line. On pause the line and flash
|
||||||
|
stay drawn so the user can study the current shot.
|
||||||
|
|
||||||
|
## Aggregated ship-class buckets
|
||||||
|
|
||||||
|
Real legacy reports list the same `(race, className)` pair across
|
||||||
|
several roster rows — different tech variants, ships pulled from
|
||||||
|
multiple stacks or planets. The legacy-report parser
|
||||||
|
([parser.go](../../tools/local-dev/legacy-report/parser.go))
|
||||||
|
collapses those rows into a single `BattleReportGroup` keyed by
|
||||||
|
`(race, className)` by SUMMING `Number` and `NumberLeft`; the
|
||||||
|
engine's `TransformBattle`
|
||||||
|
([battle_transform.go](../../game/internal/controller/battle_transform.go))
|
||||||
|
applies the same merge keyed by `ShipClass.ID`, guarded by a
|
||||||
|
processed-group set so the same source `groupId` is not summed
|
||||||
|
twice across multiple protocol references.
|
||||||
|
|
||||||
|
Without this aggregation only the last roster row's counts
|
||||||
|
survived, and the protocol's destroy count against the class
|
||||||
|
would dwarf the recorded initial count — KNNTS041 turn 41 planet
|
||||||
|
\#7 had 7 separate `Nails:pup` rows totalling 1168 ships; the
|
||||||
|
buggy parser stored only the last row's 88, so the 1082 destroys
|
||||||
|
in the protocol looked like phantom hits past the empty bucket.
|
||||||
|
After the fix both sides reconcile: 1168 initial − 86 survivors =
|
||||||
|
1082 destroys.
|
||||||
|
|
||||||
|
`buildFrames`
|
||||||
|
([timeline.ts](../src/lib/battle-player/timeline.ts)) keeps a
|
||||||
|
defence-in-depth clamp `if (left > 0)` on the destroy decrement so
|
||||||
|
a malformed protocol never pushes a race below zero; in normal
|
||||||
|
operation the clamp is a no-op because parser + engine already
|
||||||
|
folded duplicate rows together.
|
||||||
|
|
||||||
|
## Final-frame freeze
|
||||||
|
|
||||||
|
When the last protocol action eliminates a race, the surviving
|
||||||
|
side would otherwise reflow alone to the planet ring at the very
|
||||||
|
last shot — visually jarring and uninformative. `displayFrame`
|
||||||
|
freezes the layout-determining state (`remaining` and
|
||||||
|
`activeRaceIds`) at the penultimate frame's values while keeping
|
||||||
|
the final frame's `shotIndex` and `lastAction`, so the killing
|
||||||
|
shot still renders as a line + flash against the picture the user
|
||||||
|
saw a moment earlier.
|
||||||
|
|
||||||
|
## Header + layout
|
||||||
|
|
||||||
|
The viewer header carries three rows of chrome in a single line:
|
||||||
|
the back-navigation buttons (`back to map` / `back to report`) on
|
||||||
|
the left, a centred title — `Battle on planet <name> (<#number>)`,
|
||||||
|
i18n key `game.battle.header_title` — and the frame counter on the
|
||||||
|
right. Pulling navigation into the header frees the entire viewer
|
||||||
|
area for the scene; the `.viewer` container has no `max-width` cap,
|
||||||
|
so on wide monitors the scene scales up while the log keeps its
|
||||||
|
internal 30 dvh scroll.
|
||||||
|
|
||||||
|
## Height fit
|
||||||
|
|
||||||
|
The viewer is pinned to the viewport: `.active-view` uses
|
||||||
|
`calc(100dvh − 80px)` so the in-game-shell header + optional
|
||||||
|
HistoryBanner do not push the scene below the fold. Inside the
|
||||||
|
viewer, the scene grows (`flex: 1`), the scrubber + controls hold
|
||||||
|
their natural height, and the log (when expanded) shrinks to a
|
||||||
|
30 dvh ceiling with its own internal scroll, so the page itself
|
||||||
|
never scrolls vertically. The 80 px allowance maps to the current
|
||||||
|
Header + HistoryBanner total on desktop; mobile breakpoints reuse
|
||||||
|
the same calc because dvh tracks the dynamic viewport.
|
||||||
|
|
||||||
|
## Map markers
|
||||||
|
|
||||||
|
`map/battle-markers.ts` emits two marker kinds per
|
||||||
|
current-turn report. Both are wired into the binding's
|
||||||
|
`hitLookup` so a click goes through the existing hit-test plumbing.
|
||||||
|
|
||||||
|
### Battle marker — yellow cross
|
||||||
|
|
||||||
|
For every `report.battles[i]` whose `planet` resolves to a visible
|
||||||
|
planet, the marker emits two `LinePrim` lines through the opposite
|
||||||
|
corners of the square circumscribed around the planet circle. The
|
||||||
|
result is an X-shaped cross overlaid on the planet glyph.
|
||||||
|
|
||||||
|
The stroke width is computed by `battleMarkerStrokeWidth(shots)`:
|
||||||
|
1 shot → 1 px, 100 shots → 5 px, linearly interpolated in between
|
||||||
|
(`width = 1 + (shots − 1) × 4 / 99`, clamped). A click on either
|
||||||
|
line navigates to `/games/<id>/battle/<battleId>?turn=<turn>`.
|
||||||
|
|
||||||
|
### Bombing marker — colored ring
|
||||||
|
|
||||||
|
For every `report.bombings[i]`, the marker emits a single
|
||||||
|
stroke-only `CirclePrim` slightly larger than the planet circle.
|
||||||
|
Colour:
|
||||||
|
|
||||||
|
- yellow (`#FFD400`) when `wiped: false`,
|
||||||
|
- red (`#FF3030`) when `wiped: true`.
|
||||||
|
|
||||||
|
A click on the ring navigates to `/games/<id>/report#report-bombings`
|
||||||
|
and scrolls the matching `report-bombing-row` (by `data-planet`)
|
||||||
|
into view. Bombing markers never open the Battle Viewer — the two
|
||||||
|
domains stay separate.
|
||||||
|
|
||||||
|
## Data source
|
||||||
|
|
||||||
|
The Battle Viewer page (`lib/active-view/battle.svelte`) calls
|
||||||
|
`api/battle-fetch.ts.fetchBattle(gameId, turn, battleId)`. The
|
||||||
|
loader has two modes:
|
||||||
|
|
||||||
|
- **Synthetic** — when `gameId` carries the
|
||||||
|
`synthetic-` prefix, the lookup is served from
|
||||||
|
`api/synthetic-battle.ts`. Vitest unit tests and Playwright e2e
|
||||||
|
tests register fixture battles via `registerSyntheticBattle`
|
||||||
|
before mounting the route.
|
||||||
|
- **Production** — otherwise the loader issues
|
||||||
|
`GET /api/v1/user/games/{gameId}/battles/{turn}/{battleId}`
|
||||||
|
against the backend gateway route added in
|
||||||
|
`backend/internal/server/handlers_user_games.go.Battle`. The
|
||||||
|
gateway forwards verbatim to the engine's
|
||||||
|
`GET /api/v1/battle/:turn/:uuid`.
|
||||||
|
|
||||||
|
## TODOs
|
||||||
|
|
||||||
|
- Push event `game.battle.new` + toast → viewer link (deferred —
|
||||||
|
needs an engine emit-side change).
|
||||||
|
- Richer ship-class visuals derived from the class's mass,
|
||||||
|
armament, shields. Current MVP uses a small circle plus
|
||||||
|
`<class>:<numLeft>` label.
|
||||||
|
- Animated transitions when a race drops out and the survivors
|
||||||
|
re-distribute. Current implementation hard-jumps on the next
|
||||||
|
frame.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# UI events stream (`api/events.svelte.ts`)
|
||||||
|
|
||||||
|
This document describes how the SvelteKit frontend consumes the
|
||||||
|
gateway's `SubscribeEvents` server-streaming RPC. The single
|
||||||
|
authenticated session keeps **one** stream open through the
|
||||||
|
`EventStream` singleton declared in `src/api/events.svelte.ts`; the
|
||||||
|
root layout starts it once the session reaches `authenticated` and
|
||||||
|
stops it on sign-out.
|
||||||
|
|
||||||
|
## Why a single consumer
|
||||||
|
|
||||||
|
Before Phase 24, the watcher in `lib/revocation-watcher.ts` opened a
|
||||||
|
parallel stream just to observe session revocation. Phase 24 folds
|
||||||
|
that watcher into `EventStream` so that:
|
||||||
|
|
||||||
|
- there is only **one** SubscribeEvents connection per session
|
||||||
|
(avoids doubling the gateway hub load);
|
||||||
|
- both clean end-of-stream on an authenticated session and an
|
||||||
|
`Unauthenticated` ConnectError funnel through one
|
||||||
|
`session.signOut("revoked")` call site;
|
||||||
|
- per-event-type dispatch (turn-ready toasts, lobby/mail/battle
|
||||||
|
notifications later) shares the same verification path.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
SessionStore.status = "authenticated"
|
||||||
|
↓ (root layout $effect)
|
||||||
|
EventStream.start({ core, keypair, deviceSessionId, gatewayResponsePublicKey })
|
||||||
|
↓
|
||||||
|
loop: open SubscribeEvents → verify each frame → dispatch to handlers
|
||||||
|
↓
|
||||||
|
EventStream.stop() (on logout, unmount, or session id change)
|
||||||
|
```
|
||||||
|
|
||||||
|
`start` is idempotent for the same session: re-calling while the
|
||||||
|
stream is running is a no-op. The root layout detects a session id
|
||||||
|
flip (re-login on the same tab) by storing the bound id and calling
|
||||||
|
`stop()` + `start()` against the fresh credentials.
|
||||||
|
|
||||||
|
## Frame handling
|
||||||
|
|
||||||
|
Every `GatewayEvent` is verified before dispatch:
|
||||||
|
|
||||||
|
1. `core.verifyPayloadHash(payloadBytes, payloadHash)` — the SHA-256
|
||||||
|
digest of `payloadBytes` must equal `payloadHash`. A mismatch is
|
||||||
|
treated as a forgery.
|
||||||
|
2. `core.verifyEvent(gatewayResponsePublicKey, signature, fields)` —
|
||||||
|
Ed25519 verification using the canonical signing input defined in
|
||||||
|
`ui/core/canon/event.go` (mirrored by `gateway/authn/event.go`).
|
||||||
|
3. On success the verified projection (`VerifiedEvent`) is passed to
|
||||||
|
every handler registered via `eventStream.on(eventType, handler)`.
|
||||||
|
|
||||||
|
Any verification failure throws `SignatureError`, which falls into
|
||||||
|
the same retry path as a transport error: the loop classifies it as
|
||||||
|
transient, tears the stream down, and reconnects with full-jitter
|
||||||
|
exponential backoff (base 1 s, ceiling 30 s, unbounded retries).
|
||||||
|
|
||||||
|
## Connection status
|
||||||
|
|
||||||
|
`EventStream.connectionStatus` is a Svelte rune with five values:
|
||||||
|
|
||||||
|
- `idle` — stream not running.
|
||||||
|
- `connecting` — `subscribeEvents()` issued, no frame received yet.
|
||||||
|
- `connected` — first frame verified and dispatched, attempt counter
|
||||||
|
reset to zero.
|
||||||
|
- `reconnecting` — transient failure, backoff in flight.
|
||||||
|
- `offline` — `navigator.onLine === false` at the moment of failure.
|
||||||
|
|
||||||
|
The header connection-state indicator planned in `PLAN.md`
|
||||||
|
cross-cutting shell reads this rune; it is not part of Phase 24 but
|
||||||
|
the rune is wired now so a later phase can add the dot without
|
||||||
|
touching this module.
|
||||||
|
|
||||||
|
## Revocation semantics
|
||||||
|
|
||||||
|
Two paths lead to `session.signOut("revoked")`:
|
||||||
|
|
||||||
|
- a `ConnectError` with code `Unauthenticated`: the gateway rejected
|
||||||
|
the stream credentials (revoked device session);
|
||||||
|
- a clean end-of-stream while `session.status === "authenticated"`:
|
||||||
|
the gateway's documented `session_invalidation` signal closes the
|
||||||
|
stream once the device session flips to revoked.
|
||||||
|
|
||||||
|
Any other error (network drop, gateway 5xx, transient close,
|
||||||
|
signature failure) keeps the session alive and triggers backoff +
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
## Adding a new event type
|
||||||
|
|
||||||
|
1. Register a handler from the consumer module:
|
||||||
|
```ts
|
||||||
|
const off = eventStream.on("mail.received", (event) => {
|
||||||
|
// parse event.payloadBytes
|
||||||
|
});
|
||||||
|
onDestroy(off);
|
||||||
|
```
|
||||||
|
2. If the handler reads scoped data (per-game, per-route), register
|
||||||
|
from a layout that owns that scope and pass the gameId via a
|
||||||
|
closure. The handler should filter events whose payload does not
|
||||||
|
match its scope (see `routes/games/[id]/+layout.svelte` for the
|
||||||
|
`game.turn.ready` filter).
|
||||||
|
3. The payload encoding is owned by the producer side: the
|
||||||
|
`game.turn.ready` event uses JSON `{game_id, turn}`. Document
|
||||||
|
the schema next to the producer (e.g. `backend/README.md` §10).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- Unit (Vitest): `tests/events.test.ts` mocks the transport via
|
||||||
|
`createRouterTransport` and covers verified dispatch, type
|
||||||
|
filtering, bad-signature reconnect, `Unauthenticated` sign-out,
|
||||||
|
clean end-of-stream sign-out, and connection-status transitions.
|
||||||
|
- E2E (Playwright): `tests/e2e/turn-ready.spec.ts` serves a signed
|
||||||
|
`game.turn.ready` frame through `page.route`, asserts the toast
|
||||||
|
surfaces, and verifies manual dismiss without advance. The
|
||||||
|
advance roundtrip (toast → click "view now" → fresh report) is
|
||||||
|
covered by Vitest at the store level because it is sensitive to
|
||||||
|
Playwright-side network ordering.
|
||||||
+64
-11
@@ -37,6 +37,10 @@ The store exposes:
|
|||||||
| `gameId` | `string` | active game id |
|
| `gameId` | `string` | active game id |
|
||||||
| `status` | `idle / loading / ready / error` | current lifecycle state |
|
| `status` | `idle / loading / ready / error` | current lifecycle state |
|
||||||
| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch |
|
| `report` | `GameReport \| null` | latest decoded report, `null` until first fetch |
|
||||||
|
| `currentTurn` | `number` | server's authoritative current turn (live snapshot) |
|
||||||
|
| `viewedTurn` | `number` | turn whose snapshot is in `report`; equals `currentTurn` in live mode |
|
||||||
|
| `historyMode` | `boolean` (derived) | true while `status === "ready"` and `viewedTurn < currentTurn` |
|
||||||
|
| `pendingTurn` | `number \| null` | latest server turn the user has not yet opened |
|
||||||
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
|
| `wrapMode` | `torus / no-wrap` | per-game preference, persisted via `Cache` |
|
||||||
| `error` | `string \| null` | localised error message when `status === "error"` |
|
| `error` | `string \| null` | localised error message when `status === "error"` |
|
||||||
|
|
||||||
@@ -45,8 +49,15 @@ The store exposes:
|
|||||||
- Phase 11 surfaces only the planet subset of the report. Later
|
- Phase 11 surfaces only the planet subset of the report. Later
|
||||||
phases extend `GameReport` and `decodeReport` as their slice of
|
phases extend `GameReport` and `decodeReport` as their slice of
|
||||||
the wire lands (ships, fleets, sciences, routes, battles, mail).
|
the wire lands (ships, fleets, sciences, routes, battles, mail).
|
||||||
- Phase 26 wires history mode through `setTurn(turn)`. The store
|
- Phase 26 splits `currentTurn` from the turn whose snapshot is
|
||||||
already supports it; the navigator UI is what is missing.
|
displayed (`viewedTurn`) and adds `viewTurn(turn)` /
|
||||||
|
`returnToCurrent()` for history navigation. The derived
|
||||||
|
`historyMode` rune flips automatically when `viewedTurn <
|
||||||
|
currentTurn`; the layout passes it to Phase 12's sidebar /
|
||||||
|
bottom-tabs wiring (which hides the order tab) and to
|
||||||
|
`OrderDraftStore.bindClient` (which gates `add` / `remove` /
|
||||||
|
`move`). See "History mode" below for the cache and refresh
|
||||||
|
rules.
|
||||||
- Phase 24 replaces the tab-focus refresh with push-event-driven
|
- Phase 24 replaces the tab-focus refresh with push-event-driven
|
||||||
refreshes; the visibility listener stays as a fallback for
|
refreshes; the visibility listener stays as a fallback for
|
||||||
background tabs that miss a push.
|
background tabs that miss a push.
|
||||||
@@ -88,17 +99,59 @@ result can resolve back to a planet without an extra lookup table.
|
|||||||
|
|
||||||
## Refresh discipline
|
## Refresh discipline
|
||||||
|
|
||||||
`refresh()` re-fetches the same turn snapshot. It is called by the
|
`refresh()` re-fetches the current-turn snapshot. It is called by
|
||||||
`visibilitychange` handler when `document.visibilityState ===
|
the `visibilitychange` handler when `document.visibilityState ===
|
||||||
"visible"` and the store is already in `ready` state. The map view's
|
"visible"` and the store is already in `ready` state. The map
|
||||||
mount effect skips a re-render when the new snapshot's turn matches
|
view's mount effect skips a re-render when the new snapshot's turn
|
||||||
the previously-mounted turn (and the wrap mode is unchanged), so a
|
matches the previously-mounted turn (and the wrap mode is
|
||||||
no-op refresh does not flicker the canvas.
|
unchanged), so a no-op refresh does not flicker the canvas.
|
||||||
|
|
||||||
`setTurn(turn)` is the entry point for Phase 26 history mode:
|
In history mode `refresh()` is a no-op — forcing a reload would
|
||||||
calling it on a different turn loads that snapshot and the same
|
silently bump the user back onto the current turn while they are
|
||||||
mount effect re-creates the renderer with the new world.
|
intentionally viewing a past one. Push events (Phase 24) still
|
||||||
|
deliver new-turn notifications asynchronously while the user
|
||||||
|
explores history, so the pending-turn toast continues to work.
|
||||||
|
|
||||||
`setWrapMode(mode)` writes to `Cache` and updates the rune; the
|
`setWrapMode(mode)` writes to `Cache` and updates the rune; the
|
||||||
map view's effect picks the change up and re-mounts the renderer
|
map view's effect picks the change up and re-mounts the renderer
|
||||||
with the new mode.
|
with the new mode.
|
||||||
|
|
||||||
|
## History mode
|
||||||
|
|
||||||
|
Phase 26 lets the user step backward through the report timeline
|
||||||
|
without losing the live snapshot. The store keeps two turn runes:
|
||||||
|
|
||||||
|
- `currentTurn` — the server's authoritative latest. Only
|
||||||
|
`setGame` and `advanceToPending` write to it.
|
||||||
|
- `viewedTurn` — the turn currently rendered. `viewTurn(N)` flips
|
||||||
|
this rune and the underlying `report` to `N` without touching
|
||||||
|
`currentTurn`. `returnToCurrent()` is a one-line wrapper that
|
||||||
|
navigates back to live.
|
||||||
|
|
||||||
|
The derived `historyMode` rune (`status === "ready" && viewedTurn
|
||||||
|
< currentTurn`) drives every history-aware consumer:
|
||||||
|
|
||||||
|
- the layout passes it to `Sidebar` / `BottomTabs` so the order
|
||||||
|
tab vanishes (Phase 12 prop wiring);
|
||||||
|
- the layout passes a `getHistoryMode` getter to
|
||||||
|
`OrderDraftStore.bindClient` so `add` / `remove` / `move` are
|
||||||
|
no-ops while the user is looking at a past turn;
|
||||||
|
- `RenderedReportSource` returns the raw report (no order overlay)
|
||||||
|
because the draft is composed against the current turn;
|
||||||
|
- the new `HistoryBanner` component renders the sticky "Viewing
|
||||||
|
turn N · read-only" strip when the flag is true.
|
||||||
|
|
||||||
|
`last-viewed-turn` semantics keep their Phase 11 meaning: "the
|
||||||
|
latest turn the user was caught up on". `loadTurn` only writes the
|
||||||
|
cache row when called with `isCurrent === true` (i.e. when the
|
||||||
|
load matches `currentTurn`). Historical excursions are therefore
|
||||||
|
ephemeral: closing the tab and reopening the game resumes on the
|
||||||
|
last caught-up turn, not on the last clicked one.
|
||||||
|
|
||||||
|
Past-turn reports are cached in the `game-history` namespace
|
||||||
|
(`{gameId}/turn/{N}` → `GameReport`). The cache is written by
|
||||||
|
`loadTurn` on every successful historical fetch and read first by
|
||||||
|
`viewTurn(N)` before falling back to the network. Past turns are
|
||||||
|
immutable, so the cache has no TTL and no eviction in Phase 26.
|
||||||
|
The current-turn snapshot is deliberately *not* cached — it is
|
||||||
|
mutable until the next engine tick.
|
||||||
|
|||||||
+35
-6
@@ -24,7 +24,7 @@ separate dispatch component.
|
|||||||
| ------------------------------------------ | ---------------------------------------------- | ----------------------- |
|
| ------------------------------------------ | ---------------------------------------------- | ----------------------- |
|
||||||
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
|
| `/games/:id/map` | `lib/active-view/map.svelte` | Phase 11 |
|
||||||
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
|
| `/games/:id/table/:entity` | `lib/active-view/table.svelte` | Phase 11 / 17 / 19 / 22 |
|
||||||
| `/games/:id/report` | `lib/active-view/report.svelte` | Phase 23 |
|
| `/games/:id/report` | `lib/active-view/report.svelte` (see [report-view.md](report-view.md)) | Phase 23 |
|
||||||
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
|
| `/games/:id/battle/:battleId?` | `lib/active-view/battle.svelte` | Phase 27 |
|
||||||
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
|
| `/games/:id/mail` | `lib/active-view/mail.svelte` | Phase 28 |
|
||||||
| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
|
| `/games/:id/designer/ship-class/:classId?` | `lib/active-view/designer-ship-class.svelte` | Phase 17 (CRUD) / 18 (calc preview) |
|
||||||
@@ -75,17 +75,46 @@ end-to-end command flow) can set it on navigation.
|
|||||||
|
|
||||||
The Order entry is hidden when the layout's `historyMode` flag is
|
The Order entry is hidden when the layout's `historyMode` flag is
|
||||||
true. Phase 12 plumbs the flag end-to-end as a prop —
|
true. Phase 12 plumbs the flag end-to-end as a prop —
|
||||||
`+layout.svelte` passes a constant `false` to `Sidebar`, which
|
`+layout.svelte` forwards a derived value to `Sidebar`, which
|
||||||
forwards `hideOrder` to its `TabBar`; the same flag goes to
|
forwards `hideOrder` to its `TabBar`; the same flag goes to
|
||||||
`BottomTabs` so the mobile `Order` button is also suppressed. A
|
`BottomTabs` so the mobile `Order` button is also suppressed. A
|
||||||
`?sidebar=order` URL seed that arrives while the flag is true falls
|
`?sidebar=order` URL seed that arrives while the flag is true falls
|
||||||
back to `inspector`, and an `$effect` on the sidebar resets
|
back to `inspector`, and an `$effect` on the sidebar resets
|
||||||
`activeTab` away from `order` if the flag flips on mid-session.
|
`activeTab` away from `order` if the flag flips on mid-session.
|
||||||
Phase 26 introduces `lib/history-mode.ts` and replaces the constant
|
|
||||||
with the live signal; the order draft survives the toggle because
|
Phase 26 wires the flag to the live history signal owned by
|
||||||
|
`GameStateStore`. The derivation lives directly in `+layout.svelte`
|
||||||
|
(`const historyMode = $derived(gameState.historyMode)`) — no
|
||||||
|
separate `lib/history-mode.ts` module ships, because the layout is
|
||||||
|
the single consumer and the project's compactness rule rejects a
|
||||||
|
one-line indirection. The order draft survives the toggle because
|
||||||
`OrderDraftStore` lives one level above the sidebar in the layout
|
`OrderDraftStore` lives one level above the sidebar in the layout
|
||||||
hierarchy. See [`order-composer.md`](order-composer.md) for the
|
hierarchy; the same `historyMode` derivation is also fed into
|
||||||
draft-store side of the flow.
|
`OrderDraftStore.bindClient` so inspector-driven mutations
|
||||||
|
(`add` / `remove` / `move`) become no-ops while the user is
|
||||||
|
viewing a past turn. See [`order-composer.md`](order-composer.md)
|
||||||
|
for the draft-store side of the flow and
|
||||||
|
[`game-state.md`](game-state.md) for the rune split between
|
||||||
|
`currentTurn` and `viewedTurn`.
|
||||||
|
|
||||||
|
## Header turn navigator and history banner
|
||||||
|
|
||||||
|
The header replaces the Phase 11 inline `turn N` text with a
|
||||||
|
`← Turn N →` triplet (`lib/header/turn-navigator.svelte`). The
|
||||||
|
arrows step `viewedTurn` by ±1 (disabled at boundaries `0` and
|
||||||
|
`currentTurn`); clicking the middle button opens an absolute
|
||||||
|
popover (desktop) or a fixed full-width drawer (mobile, ≤ 767.98
|
||||||
|
px) listing every turn from `currentTurn` down to `0`. Selecting
|
||||||
|
the current-turn row routes through `gameState.returnToCurrent()`;
|
||||||
|
any other row calls `gameState.viewTurn(N)`. The popover reuses
|
||||||
|
`view-menu.svelte`'s outside-click / Escape pattern.
|
||||||
|
|
||||||
|
`lib/header/history-banner.svelte` renders directly under the
|
||||||
|
header whenever `gameState.historyMode === true`. It shows
|
||||||
|
"Viewing turn {N} · read-only" with a "Return to current turn"
|
||||||
|
button that delegates back to `gameState.returnToCurrent()`. Both
|
||||||
|
the navigator and the banner read `gameState` through context, so
|
||||||
|
the layout is the only place where the wiring lives.
|
||||||
|
|
||||||
## Layout breakpoints
|
## Layout breakpoints
|
||||||
|
|
||||||
|
|||||||
+54
-15
@@ -36,11 +36,18 @@ entry by `cmdId`. Successfully applied entries stay visible in
|
|||||||
the draft (the player keeps composing until turn cutoff);
|
the draft (the player keeps composing until turn cutoff);
|
||||||
rejected entries stay until the player edits or removes them.
|
rejected entries stay until the player edits or removes them.
|
||||||
|
|
||||||
Phase 25 is reserved for one extension on top of this: per-line
|
Phase 25 layers a transport-level policy on top of this baseline
|
||||||
sequencing if a future use case needs to submit commands
|
without changing the batch semantics. The submit pipeline now
|
||||||
individually rather than in one batch. The wire shape is already
|
goes through `OrderQueue` (see
|
||||||
flexible enough — the response carries an array of results — so
|
[`sync-protocol.md`](sync-protocol.md)): the queue holds the
|
||||||
Phase 25 only changes the client-side iteration policy.
|
submit while the browser is offline, classifies
|
||||||
|
`turn_already_closed` and `game_paused` server replies into
|
||||||
|
matching banners on the order tab, and exits the loop on the
|
||||||
|
sticky states so a stream of mutations does not re-elicit the
|
||||||
|
same gateway reply. Recovery from a `conflict` or `paused`
|
||||||
|
banner happens on the next `game.turn.ready` push frame via
|
||||||
|
`OrderDraftStore.resetForNewTurn`, which clears the local draft
|
||||||
|
and re-hydrates from the server for the new turn.
|
||||||
|
|
||||||
## Local-validation invariant
|
## Local-validation invariant
|
||||||
|
|
||||||
@@ -63,8 +70,10 @@ submit pipeline filters the draft to `valid` entries only — any
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
|
draft ──validate──▶ valid ──submit──▶ submitting ──ack──▶ applied
|
||||||
╲ │ ╲
|
╲ │ │ ╲
|
||||||
╲──validate──▶ invalid ╲──nack──▶ rejected
|
╲──validate──▶ invalid │ ╲──nack──▶ rejected
|
||||||
|
│
|
||||||
|
╲────turn_already_closed──▶ conflict
|
||||||
```
|
```
|
||||||
|
|
||||||
Transitions:
|
Transitions:
|
||||||
@@ -76,6 +85,14 @@ Transitions:
|
|||||||
the draft and sends it to the gateway.
|
the draft and sends it to the gateway.
|
||||||
- **`submitting → applied` / `submitting → rejected`**: the gateway
|
- **`submitting → applied` / `submitting → rejected`**: the gateway
|
||||||
responded; the entry is no longer in flight.
|
responded; the entry is no longer in flight.
|
||||||
|
- **`submitting → conflict`** (Phase 25): the gateway returned
|
||||||
|
`resultCode = "turn_already_closed"`. The order tab surfaces a
|
||||||
|
banner above the command list. Any subsequent mutation
|
||||||
|
re-validates the conflict row back to `valid` / `invalid`; a
|
||||||
|
matching `game.turn.ready` push frame triggers
|
||||||
|
`resetForNewTurn`, which wipes the draft entirely. See
|
||||||
|
[`sync-protocol.md`](sync-protocol.md) for the full state
|
||||||
|
table and recovery paths.
|
||||||
|
|
||||||
Phase 14 lands the local validators (`draft → valid | invalid`),
|
Phase 14 lands the local validators (`draft → valid | invalid`),
|
||||||
the submit pipeline (`valid → submitting → applied | rejected`),
|
the submit pipeline (`valid → submitting → applied | rejected`),
|
||||||
@@ -283,13 +300,14 @@ order composer uses the namespace.
|
|||||||
|
|
||||||
## History mode wiring
|
## History mode wiring
|
||||||
|
|
||||||
Phase 26 introduces a global history-mode flag. The IA section
|
Phase 26 implements history mode: the user can step back through
|
||||||
specifies that the Order tab is hidden when history mode is active —
|
past turns and see the report as it was. The IA section specifies
|
||||||
the player is browsing a past turn snapshot, and composing commands
|
that the Order tab is hidden when history mode is active — the
|
||||||
against an immutable snapshot would be confusing.
|
player is browsing an immutable snapshot, and composing commands
|
||||||
|
against it would be confusing.
|
||||||
|
|
||||||
Phase 12 wires the flag end-to-end as a prop. The layout owns the
|
Phase 12 wires the flag end-to-end as a prop. The layout owns the
|
||||||
flag (a constant `false` until Phase 26) and passes it to:
|
flag and passes it to:
|
||||||
|
|
||||||
- `Sidebar` as `historyMode`. The sidebar forwards it to its
|
- `Sidebar` as `historyMode`. The sidebar forwards it to its
|
||||||
`TabBar` as `hideOrder`. The Order entry is filtered out of the
|
`TabBar` as `hideOrder`. The Order entry is filtered out of the
|
||||||
@@ -300,10 +318,31 @@ flag (a constant `false` until Phase 26) and passes it to:
|
|||||||
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
- `BottomTabs` as `hideOrder`. The mobile bottom-tab `Order`
|
||||||
button is suppressed when true.
|
button is suppressed when true.
|
||||||
|
|
||||||
|
Phase 26 turns the constant into a derived value driven by
|
||||||
|
`GameStateStore.historyMode` (`viewedTurn < currentTurn` while
|
||||||
|
`status === "ready"`). The same getter is also passed into
|
||||||
|
`OrderDraftStore.bindClient` as `getHistoryMode`, which short-
|
||||||
|
circuits the `add` / `remove` / `move` mutations to a no-op while
|
||||||
|
the flag is true. This makes every Phase 14–22 inspector affordance
|
||||||
|
that calls `orderDraft.add(...)` inert in history mode without
|
||||||
|
per-component edits — the gate lives in the one chokepoint that
|
||||||
|
all callers go through. The conflict / paused banners and the
|
||||||
|
in-flight sync pipeline are untouched: they describe state that
|
||||||
|
exists independently of the user's current view.
|
||||||
|
|
||||||
The store itself stays alive across history-mode round-trips so
|
The store itself stays alive across history-mode round-trips so
|
||||||
the draft survives. Phase 26 will replace the constant with the
|
the draft survives the toggle. The `RenderedReportSource` overlay
|
||||||
real signal from `lib/history-mode.ts` and exercise the toggle in
|
(`lib/rendered-report.svelte.ts`) additionally short-circuits in
|
||||||
its own test suite.
|
history mode: when `gameState.historyMode === true` it returns
|
||||||
|
the raw report so the map / inspector do not project pending
|
||||||
|
renames composed for the *current* turn onto a *past* report.
|
||||||
|
|
||||||
|
See [`game-state.md`](game-state.md) for the `viewTurn` /
|
||||||
|
`returnToCurrent` API, the cache namespace
|
||||||
|
(`game-history/{gameId}/turn/{N}`), and the visibility-refresh
|
||||||
|
short-circuit; see [`navigation.md`](navigation.md) for the turn
|
||||||
|
navigator and the read-only banner that surface history mode in
|
||||||
|
the chrome.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Report view — Phase 23
|
||||||
|
|
||||||
|
The Phase 23 in-game "turn report" view is a single scrollable
|
||||||
|
layout with twenty sections, one per array on the FBS `Report`
|
||||||
|
table. The route file is the standard two-line wrapper; the
|
||||||
|
orchestrator and the per-section components live under
|
||||||
|
`ui/frontend/src/lib/active-view/report/`.
|
||||||
|
|
||||||
|
## Component layout
|
||||||
|
|
||||||
|
`lib/active-view/report.svelte` is the orchestrator. It owns the
|
||||||
|
section list, instantiates `IntersectionObserver` to track which
|
||||||
|
section is active, and renders the table of contents alongside the
|
||||||
|
section column.
|
||||||
|
|
||||||
|
```
|
||||||
|
report.svelte
|
||||||
|
├── report/report-toc.svelte // anchor list + mobile <select>
|
||||||
|
├── report/section-galaxy-summary.svelte
|
||||||
|
├── report/section-votes.svelte
|
||||||
|
├── report/section-player-status.svelte
|
||||||
|
├── report/section-my-sciences.svelte
|
||||||
|
├── report/section-foreign-sciences.svelte
|
||||||
|
├── report/section-my-ship-classes.svelte
|
||||||
|
├── report/section-foreign-ship-classes.svelte
|
||||||
|
├── report/section-battles.svelte
|
||||||
|
├── report/section-bombings.svelte
|
||||||
|
├── report/section-approaching-groups.svelte
|
||||||
|
├── report/section-my-planets.svelte
|
||||||
|
├── report/section-ships-in-production.svelte
|
||||||
|
├── report/section-cargo-routes.svelte
|
||||||
|
├── report/section-foreign-planets.svelte
|
||||||
|
├── report/section-uninhabited-planets.svelte
|
||||||
|
├── report/section-unknown-planets.svelte
|
||||||
|
├── report/section-my-fleets.svelte
|
||||||
|
├── report/section-my-ship-groups.svelte
|
||||||
|
├── report/section-foreign-ship-groups.svelte
|
||||||
|
└── report/section-unidentified-groups.svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
Each section component is self-contained:
|
||||||
|
- reads `RenderedReportSource` from context;
|
||||||
|
- renders the loading copy when `rendered.report === null`;
|
||||||
|
- renders the empty-state copy when its array is empty;
|
||||||
|
- otherwise emits a `<section id="report-<slug>" data-testid="report-section-<slug>">`
|
||||||
|
containing the relevant grid / list / kv-list.
|
||||||
|
|
||||||
|
No shared `<Section>` wrapper exists. The visual scaffolding (dark
|
||||||
|
grid CSS, header style, status paragraph) is inlined per
|
||||||
|
component. The CLAUDE.md "wait for the third real caller before
|
||||||
|
extracting an abstraction" rule applies; with one shape per
|
||||||
|
section, the per-section inline CSS is the smallest correct
|
||||||
|
solution.
|
||||||
|
|
||||||
|
Shared formatters live in `report/format.ts` (`formatPercent`,
|
||||||
|
`formatCount`, `formatFloat`, `formatVotes`, `planetLabel`).
|
||||||
|
|
||||||
|
## Section order, data sources, empty copy
|
||||||
|
|
||||||
|
| # | Slug | Data | Empty copy (en) |
|
||||||
|
|---|------|------|-----------------|
|
||||||
|
| 1 | `galaxy-summary` | header turn / size / planet count / race | never empty |
|
||||||
|
| 2 | `votes` | `myVotes`, `myVoteFor`, `races[].votesReceived` | "no votes cast yet" |
|
||||||
|
| 3 | `player-status` | `players[]` (full roster, self + extinct) | never empty |
|
||||||
|
| 4 | `my-sciences` | `localScience[]` | "no sciences defined yet" |
|
||||||
|
| 5 | `foreign-sciences` | `otherScience[]`, one sub-table per race | "no foreign sciences observed yet" |
|
||||||
|
| 6 | `my-ship-classes` | `localShipClass[]` | "no ship classes designed yet" |
|
||||||
|
| 7 | `foreign-ship-classes` | `otherShipClass[]`, one sub-table per race | "no foreign ship classes observed yet" |
|
||||||
|
| 8 | `battles` | `battleIds[]` (inactive monospace spans) | "no battles last turn" |
|
||||||
|
| 9 | `bombings` | `bombings[]`, wiped rows visually distinct | "no bombings last turn" |
|
||||||
|
| 10 | `approaching-groups` | `incomingShipGroups[]` | "no approaching groups" |
|
||||||
|
| 11 | `my-planets` | `planets.filter(kind==="local")` | "no planets owned yet" |
|
||||||
|
| 12 | `ships-in-production` | `shipProductions[]` | "no ships in production" |
|
||||||
|
| 13 | `cargo-routes` | `routes[]` (flattened to one row per entry) | "no cargo routes set" |
|
||||||
|
| 14 | `foreign-planets` | `planets.filter(kind==="other")` | "no foreign planets observed" |
|
||||||
|
| 15 | `uninhabited-planets` | `planets.filter(kind==="uninhabited")` | "no uninhabited planets observed" |
|
||||||
|
| 16 | `unknown-planets` | `planets.filter(kind==="unidentified")` | "no unknown planets" |
|
||||||
|
| 17 | `my-fleets` | `localFleets[]` | "no fleets created yet" |
|
||||||
|
| 18 | `my-ship-groups` | `localShipGroups[]` | "no ship groups yet" |
|
||||||
|
| 19 | `foreign-ship-groups` | `otherShipGroups[]` | "no foreign ship groups observed" |
|
||||||
|
| 20 | `unidentified-groups` | `unidentifiedShipGroups[]` | "no unidentified groups" |
|
||||||
|
|
||||||
|
The orchestrator iterates this list once for the TOC and once for
|
||||||
|
the body — both surfaces stay in sync by construction.
|
||||||
|
|
||||||
|
## Table of contents and active highlight
|
||||||
|
|
||||||
|
`report/report-toc.svelte` renders two surfaces driven by the same
|
||||||
|
entry list:
|
||||||
|
|
||||||
|
- **Desktop / tablet sidebar** — sticky `<aside>` with vertical
|
||||||
|
anchor list. The anchor for the currently-visible section gets
|
||||||
|
`aria-current="location"` and an `.active` CSS class.
|
||||||
|
- **Mobile (< 768 px)** — the desktop sidebar is hidden via CSS
|
||||||
|
and a sticky `<select>` takes its place at the top of the body.
|
||||||
|
Picking an option scrolls the matching section into view. The
|
||||||
|
mobile contract intentionally avoids stacking another overlay on
|
||||||
|
top of the existing layout-owned bottom-tabs.
|
||||||
|
|
||||||
|
Both surfaces also expose a "Back to map" affordance
|
||||||
|
(`report-back-to-map`) at the top.
|
||||||
|
|
||||||
|
The active slug is computed by an `IntersectionObserver` rooted on
|
||||||
|
the viewport (`root: null`) with `rootMargin: "-30% 0px -60% 0px"`.
|
||||||
|
The skew biases the active band toward the upper third of the
|
||||||
|
visible area so that scrolling down advances the highlight
|
||||||
|
naturally. The observer is created on mount and torn down on
|
||||||
|
unmount.
|
||||||
|
|
||||||
|
The in-game shell layout (`routes/games/[id]/+layout.svelte`)
|
||||||
|
expands `<main class="active-view-host">` to fit content rather
|
||||||
|
than constraining it, so the document body is the actual scroll
|
||||||
|
container — not the host. The IntersectionObserver root is `null`
|
||||||
|
to match.
|
||||||
|
|
||||||
|
## Scroll save / restore
|
||||||
|
|
||||||
|
`routes/games/[id]/report/+page.svelte` exports a SvelteKit
|
||||||
|
`Snapshot<{ scrollY: number }>`:
|
||||||
|
|
||||||
|
- `capture()` reads `window.scrollY` when SvelteKit's
|
||||||
|
`beforeNavigate` cycle runs.
|
||||||
|
- `restore(value)` schedules a short
|
||||||
|
`requestAnimationFrame` poll that waits for
|
||||||
|
`document.documentElement.scrollHeight` to grow tall enough to
|
||||||
|
honour the saved offset, then calls `window.scrollTo(0, value)`.
|
||||||
|
The poll caps at ~60 frames (≈ 1 second) so a never-tall-enough
|
||||||
|
body never pins a frame loop.
|
||||||
|
|
||||||
|
The capture / restore pair is keyed by route, so:
|
||||||
|
- Forward navigation from `/report` to `/map` lands `/map` at
|
||||||
|
scrollY 0 (no snapshot for `/map` to restore from).
|
||||||
|
- History-back from `/map` to `/report` restores the previously
|
||||||
|
captured scrollY — the user returns to the same section.
|
||||||
|
|
||||||
|
The Snapshot API does not capture the active sidebar slug; the
|
||||||
|
IntersectionObserver re-derives it from the restored scroll
|
||||||
|
position on the next animation frame, which keeps the TOC
|
||||||
|
highlight consistent without a second source of truth.
|
||||||
|
|
||||||
|
## i18n namespace
|
||||||
|
|
||||||
|
All Phase 23 strings live under `game.report.*`:
|
||||||
|
- `game.report.loading` — section loading placeholder.
|
||||||
|
- `game.report.back_to_map`, `game.report.toc.title`,
|
||||||
|
`game.report.toc.mobile_label` — shell-level strings.
|
||||||
|
- `game.report.section.<slug>.title` — section heading.
|
||||||
|
- `game.report.section.<slug>.empty` — empty-state copy (where
|
||||||
|
applicable).
|
||||||
|
- `game.report.section.<slug>.column.<column>` — column headings.
|
||||||
|
- A small number of section-specific keys (`bombings.wiped`,
|
||||||
|
`player_status.local_marker`, `player_status.extinct_marker`,
|
||||||
|
`foreign_sciences.race_header`, `foreign_ship_classes.race_header`,
|
||||||
|
`battles.id_label`, `votes.target_none`).
|
||||||
|
|
||||||
|
The namespace is intentionally separate from `game.table.*` even
|
||||||
|
where the data shape overlaps (e.g. sciences, ship classes); the
|
||||||
|
two surfaces evolve independently and a shared key set would
|
||||||
|
couple them silently.
|
||||||
|
|
||||||
|
## Test seams
|
||||||
|
|
||||||
|
- **Vitest** — four representative specs cover the four section
|
||||||
|
shapes: kv-list (`report-section-galaxy-summary.test.ts`), grid
|
||||||
|
with conditional row state (`report-section-bombings.test.ts`),
|
||||||
|
per-race sub-table (`report-section-foreign-sciences.test.ts`),
|
||||||
|
TOC (`report-toc.test.ts`). Each spec mounts the component
|
||||||
|
against a synthetic `RenderedReportSource`, so the orchestrator
|
||||||
|
/ IntersectionObserver are out of scope.
|
||||||
|
- **Playwright** — `tests/e2e/report-sections.spec.ts` exercises
|
||||||
|
the full integration: every TOC anchor lands its section in
|
||||||
|
view, the snapshot mechanism preserves `window.scrollY` on
|
||||||
|
history navigation, the back-to-map button reaches `/map`, the
|
||||||
|
mobile `<select>` scrolls to the chosen section on a narrow
|
||||||
|
viewport.
|
||||||
|
|
||||||
|
Test IDs follow the pattern `report-section-<slug>` for section
|
||||||
|
roots, `report-toc-<slug>` for TOC anchors, and per-section row
|
||||||
|
identifiers (e.g. `report-bombing-row`, `my-planets-row`).
|
||||||
+7
-5
@@ -112,11 +112,13 @@ wipes every namespace.
|
|||||||
|
|
||||||
Namespaces in current use:
|
Namespaces in current use:
|
||||||
|
|
||||||
| Namespace | Key | Value type | Owner |
|
| Namespace | Key | Value type | Owner |
|
||||||
|-----------------|---------------------|------------------|-----------------------------|
|
|-----------------|--------------------------------|------------------|------------------------------------|
|
||||||
| `session` | `device-session-id` | `string` | Phase 7+ |
|
| `session` | `device-session-id` | `string` | Phase 7+ |
|
||||||
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
|
| `game-prefs` | `{gameId}/wrap-mode` | `WrapMode` | Phase 11+ (`game-state.md`) |
|
||||||
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
|
| `game-prefs` | `{gameId}/last-viewed-turn` | `number` | Phase 11+ (`game-state.md`) |
|
||||||
|
| `order-drafts` | `{gameId}/draft` | `OrderCommand[]` | Phase 12+ (`order-composer.md`) |
|
||||||
|
| `game-history` | `{gameId}/turn/{N}` | `GameReport` | Phase 26+ (`game-state.md`) |
|
||||||
|
|
||||||
Later phases will add more per-feature namespaces (fixtures, lobby
|
Later phases will add more per-feature namespaces (fixtures, lobby
|
||||||
snapshot, etc.). The contract is namespace-strings stay scoped to
|
snapshot, etc.). The contract is namespace-strings stay scoped to
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# UI sync protocol
|
||||||
|
|
||||||
|
Phase 25 wires the transport-level policy that keeps the local
|
||||||
|
order draft consistent with the gateway across two failure modes
|
||||||
|
that Phase 14 punted on: transient network outages and turn
|
||||||
|
cutoffs the player did not anticipate. The wiring also reacts to
|
||||||
|
admin-initiated game pauses signalled by the new `game.paused`
|
||||||
|
push event.
|
||||||
|
|
||||||
|
The contract lives at three layers:
|
||||||
|
|
||||||
|
- **Backend** owns the turn-cutoff guard and the auto-pause on
|
||||||
|
generation failure (`backend/internal/runtime/scheduler.go`,
|
||||||
|
`backend/internal/lobby/runtime_hooks.go`,
|
||||||
|
`backend/internal/server/handlers_user_games.go`); see
|
||||||
|
`docs/FUNCTIONAL.md §6.3` for the user-visible spec.
|
||||||
|
- **Gateway** translates backend's `httperr` envelope into the
|
||||||
|
`ExecuteCommandResponse` envelope without re-interpreting the
|
||||||
|
code; `turn_already_closed` and `game_paused` surface as
|
||||||
|
`resultCode` values verbatim.
|
||||||
|
- **UI** detects those codes in
|
||||||
|
[`src/sync/order-queue.svelte.ts`](../frontend/src/sync/order-queue.svelte.ts)
|
||||||
|
and projects them onto the
|
||||||
|
[`OrderDraftStore`](../frontend/src/sync/order-draft.svelte.ts)
|
||||||
|
state machine consumed by
|
||||||
|
[`order-tab.svelte`](../frontend/src/lib/sidebar/order-tab.svelte).
|
||||||
|
|
||||||
|
This document covers the UI side of the protocol — send-loop
|
||||||
|
semantics, retry budgets, conflict UX, paused UX, and the
|
||||||
|
recovery paths back to a normal `synced` state.
|
||||||
|
|
||||||
|
## Send-loop semantics
|
||||||
|
|
||||||
|
The order draft store no longer calls `submitOrder` directly. Every
|
||||||
|
auto-sync attempt goes through `OrderQueue.send(submitFn)`, which
|
||||||
|
acts as a thin policy gate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mutation ──▶ scheduleSync ──▶ runSync ──▶ queue.send(submitFn)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
classify outcome:
|
||||||
|
- success
|
||||||
|
- rejected
|
||||||
|
- conflict
|
||||||
|
- paused
|
||||||
|
- offline
|
||||||
|
- failed
|
||||||
|
```
|
||||||
|
|
||||||
|
The classification is dispatched into the store:
|
||||||
|
|
||||||
|
| outcome | command status flip | syncStatus | banner side-effect |
|
||||||
|
| ----------- | ---------------------------------- | ---------- | ----------------------------------------- |
|
||||||
|
| `success` | per-command `applied` / `rejected` | `synced` | none |
|
||||||
|
| `rejected` | submitting → `rejected` | `error` | none (the row colour is enough) |
|
||||||
|
| `conflict` | submitting → `conflict` | `conflict` | `conflictBanner = { turn, code, message }` |
|
||||||
|
| `paused` | submitting → `valid` | `paused` | `pausedBanner = { reason, code, message }` |
|
||||||
|
| `offline` | submitting → `valid` | `offline` | none — the status bar carries the copy |
|
||||||
|
| `failed` | submitting → `valid` | `error` | `syncError = reason` |
|
||||||
|
|
||||||
|
Only one submit is in flight at a time. Mutations made during a
|
||||||
|
flight set `pending = true` so the loop runs one more iteration
|
||||||
|
with a fresh snapshot once the active call settles.
|
||||||
|
|
||||||
|
## Offline detection and retry budget
|
||||||
|
|
||||||
|
`OrderQueue.start` subscribes to the browser's `online` / `offline`
|
||||||
|
events and primes `OrderQueue.online` from `navigator.onLine`. The
|
||||||
|
queue intentionally treats offline as a fast-fail:
|
||||||
|
|
||||||
|
- A `send()` invocation with `online === false` returns
|
||||||
|
`{kind: "offline"}` without invoking `submitFn`. The draft store
|
||||||
|
reverts every submitting row back to `valid` and parks the loop
|
||||||
|
with `syncStatus = "offline"`. No further sends fire until the
|
||||||
|
browser re-emits `online`.
|
||||||
|
- When the browser flips back to `online`, the queue invokes the
|
||||||
|
`onOnline` callback supplied at `start()`. The draft store wires
|
||||||
|
this callback to `scheduleSync()` — exactly one new attempt per
|
||||||
|
online flip. The store's existing single-slot pending machinery
|
||||||
|
takes care of the rest (further mutations during that attempt
|
||||||
|
coalesce into one follow-up).
|
||||||
|
|
||||||
|
There is no inline retry inside `OrderQueue.send`. The
|
||||||
|
plan's "retry once on reconnect" budget is therefore literal:
|
||||||
|
|
||||||
|
- offline ⇒ hold (no attempt)
|
||||||
|
- next `online` event ⇒ one attempt
|
||||||
|
- attempt succeeds ⇒ `syncStatus = "synced"`
|
||||||
|
- attempt throws ⇒ `syncStatus = "error"` and the existing
|
||||||
|
manual-retry affordance in the order tab applies
|
||||||
|
|
||||||
|
A throw mid-flight while `navigator.onLine` is still `true` is
|
||||||
|
treated as a regular failure (`failed` outcome). A throw whose
|
||||||
|
`onlineProbe` check returns `false` collapses into `offline`, so
|
||||||
|
flaky connectivity does not get classified as a hard error.
|
||||||
|
|
||||||
|
## Conflict UX — `turn_already_closed`
|
||||||
|
|
||||||
|
When the gateway returns `resultCode = "turn_already_closed"` (or
|
||||||
|
the per-error-body `code` matches it), the queue emits a
|
||||||
|
`conflict` outcome. The store:
|
||||||
|
|
||||||
|
1. Marks every command that was in `submitting` as `conflict` —
|
||||||
|
the matching row shows the new badge.
|
||||||
|
2. Records `conflictBanner = { turn, code, message }`. `turn` is
|
||||||
|
read from the `getCurrentTurn` callback the layout supplied at
|
||||||
|
`bindClient` (the turn the player was composing for); it may be
|
||||||
|
`null` in tests that omit the callback, in which case the
|
||||||
|
banner falls back to a turn-less template.
|
||||||
|
3. Sets `syncStatus = "conflict"`. The loop exits without firing
|
||||||
|
the `pending` follow-up — `scheduleSync` short-circuits while
|
||||||
|
the store is in conflict, so a flurry of keystrokes does not
|
||||||
|
re-elicit the same gateway reply.
|
||||||
|
|
||||||
|
The banner clears on either of two signals:
|
||||||
|
|
||||||
|
- **Any local mutation** (`add`, `remove`, `move`) calls
|
||||||
|
`clearConflictForMutation`, which drops the banner and
|
||||||
|
re-validates every `conflict`-marked command back through the
|
||||||
|
local validator. The mutation then auto-syncs as usual — likely
|
||||||
|
a fresh attempt against the new turn, often resulting in
|
||||||
|
`success`.
|
||||||
|
- **A `game.turn.ready` push** arriving while the store is in
|
||||||
|
`conflict` triggers `resetForNewTurn`. The local draft is
|
||||||
|
wiped, `hydrateFromServer` pulls the new turn's empty order,
|
||||||
|
and the banner clears. Old commands for the prior turn become
|
||||||
|
history (read-only) and live on the server's `user.games.order`
|
||||||
|
for `?turn=N`.
|
||||||
|
|
||||||
|
## Paused UX — `game_paused` / `game.paused`
|
||||||
|
|
||||||
|
The paused-banner has two entry points:
|
||||||
|
|
||||||
|
- **`Orders` handler reply with `code = "game_paused"`** — the
|
||||||
|
player attempted to submit while the game was paused. The
|
||||||
|
queue emits a `paused` outcome; the store reverts submitting
|
||||||
|
rows to `valid`, records `pausedBanner = { reason, code, message }`,
|
||||||
|
and locks `syncStatus = "paused"`.
|
||||||
|
- **`game.paused` push frame** — lobby published the event when
|
||||||
|
it flipped the game to `paused` (see backend §6.5). The layout
|
||||||
|
subscribes via `eventStream.on("game.paused", ...)` and calls
|
||||||
|
`orderDraft.markPaused({ reason })`, which arrives at the same
|
||||||
|
state via a different path.
|
||||||
|
|
||||||
|
While in `paused` the auto-sync loop refuses to fire (the
|
||||||
|
`scheduleSync` early-exit). The store's `hydrateFromServer` also
|
||||||
|
short-circuits if `syncStatus === "paused"` to avoid clobbering
|
||||||
|
the banner with a fresh `synced` flip.
|
||||||
|
|
||||||
|
Recovery is the same as conflict: a `game.turn.ready` push
|
||||||
|
clears the pause via `resetForNewTurn`. The matching admin flow
|
||||||
|
on the backend is an explicit `/resume` followed by a successful
|
||||||
|
scheduler tick that emits the next turn.
|
||||||
|
|
||||||
|
## Recovery via `resetForNewTurn`
|
||||||
|
|
||||||
|
`resetForNewTurn` is the single entry point that wipes both
|
||||||
|
banners and rebuilds the draft against a fresh turn:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async resetForNewTurn(opts: { client; turn }) {
|
||||||
|
this.commands = [];
|
||||||
|
this.statuses = {};
|
||||||
|
this.updatedAt = 0;
|
||||||
|
this.conflictBanner = null;
|
||||||
|
this.pausedBanner = null;
|
||||||
|
this.syncStatus = "idle";
|
||||||
|
this.syncError = null;
|
||||||
|
await this.persist();
|
||||||
|
await this.hydrateFromServer({ client, turn });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The layout calls it from the `game.turn.ready` subscription
|
||||||
|
whenever the prior `syncStatus` was either `conflict` or
|
||||||
|
`paused`. A regular turn advance (no banner active) keeps the
|
||||||
|
existing behaviour: `markPendingTurn` shows the toast and the
|
||||||
|
player chooses when to navigate; the local draft survives the
|
||||||
|
transition unchanged.
|
||||||
|
|
||||||
|
## Test surface
|
||||||
|
|
||||||
|
- **Vitest unit**:
|
||||||
|
[`tests/order-queue.test.ts`](../frontend/tests/order-queue.test.ts)
|
||||||
|
covers the queue's classification + online/offline plumbing in
|
||||||
|
isolation;
|
||||||
|
[`tests/order-draft.test.ts`](../frontend/tests/order-draft.test.ts)
|
||||||
|
covers the store's reaction to each outcome and the
|
||||||
|
`resetForNewTurn` / `markPaused` paths;
|
||||||
|
[`tests/order-tab.test.ts`](../frontend/tests/order-tab.test.ts)
|
||||||
|
asserts the banner DOM;
|
||||||
|
[`tests/events.test.ts`](../frontend/tests/events.test.ts)
|
||||||
|
pins the `game.paused` dispatch.
|
||||||
|
- **Playwright e2e**:
|
||||||
|
[`tests/e2e/order-sync.spec.ts`](../frontend/tests/e2e/order-sync.spec.ts)
|
||||||
|
drives the order tab through the conflict and paused-push
|
||||||
|
scenarios with mocked gateway and signed-event frames.
|
||||||
|
|
||||||
|
The offline-online round-trip is covered at the Vitest level
|
||||||
|
because Playwright's `context.setOffline(true)` is a coarse
|
||||||
|
network mute that conflicts with the dev-server bootstrap and
|
||||||
|
the in-test fixture key wiring; the store-level test uses
|
||||||
|
injected `onlineProbe` / `addEventListener` to drive the same
|
||||||
|
state machine deterministically.
|
||||||
|
|
||||||
|
## Known follow-ups
|
||||||
|
|
||||||
|
- Admin resume currently produces a `running → paused → running`
|
||||||
|
status flip on the lobby side without an explicit push event.
|
||||||
|
The UI relies on the next `game.turn.ready` for recovery; a
|
||||||
|
dedicated `game.resumed` event would let the banner clear
|
||||||
|
immediately without waiting for the next cron tick. Not part
|
||||||
|
of this phase.
|
||||||
|
- The conflict banner shows the player-facing template
|
||||||
|
unmodified; a future revision may interpolate the explicit
|
||||||
|
cutoff timestamp once the server adds it to the error body.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Battle-report fetcher used by the Battle Viewer page.
|
||||||
|
//
|
||||||
|
// Phase 27 ships the BattleViewer as a logically isolated component
|
||||||
|
// that accepts a `BattleReport` matching `pkg/model/report/battle.go`.
|
||||||
|
// This module owns the type mirror and a single `fetchBattle` entry
|
||||||
|
// point. In synthetic mode (development & e2e fixtures), the loader
|
||||||
|
// falls back to a local fixture so the UI tests don't depend on a
|
||||||
|
// running engine; otherwise it issues a real `GET` against the
|
||||||
|
// backend gateway route added in Phase 27 step 3.
|
||||||
|
|
||||||
|
import { isSyntheticGameId } from "./synthetic-report";
|
||||||
|
import { lookupSyntheticBattle } from "./synthetic-battle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BattleReport is the wire shape returned by the engine endpoint
|
||||||
|
* `GET /api/v1/battle/:turn/:uuid` and forwarded by the backend
|
||||||
|
* gateway as `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`.
|
||||||
|
* Fields mirror `pkg/model/report/battle.go`.
|
||||||
|
*/
|
||||||
|
export interface BattleReport {
|
||||||
|
id: string;
|
||||||
|
planet: number;
|
||||||
|
planetName: string;
|
||||||
|
races: Record<string, string>;
|
||||||
|
ships: Record<string, BattleReportGroup>;
|
||||||
|
protocol: BattleActionReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleReportGroup {
|
||||||
|
race: string;
|
||||||
|
className: string;
|
||||||
|
tech: Record<string, number>;
|
||||||
|
num: number;
|
||||||
|
numLeft: number;
|
||||||
|
loadType: string;
|
||||||
|
loadQuantity: number;
|
||||||
|
inBattle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BattleActionReport {
|
||||||
|
a: number;
|
||||||
|
sa: number;
|
||||||
|
d: number;
|
||||||
|
sd: number;
|
||||||
|
x: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BattleFetchError extends Error {
|
||||||
|
constructor(public readonly status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "BattleFetchError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetchBattle returns the `BattleReport` for the supplied game, turn,
|
||||||
|
* and battle id. In synthetic-report mode (DEV / e2e) the lookup is
|
||||||
|
* served from `synthetic-battle.ts`; otherwise the function calls the
|
||||||
|
* backend gateway route. Throws `BattleFetchError` with the upstream
|
||||||
|
* status on validation or transport failure.
|
||||||
|
*/
|
||||||
|
export async function fetchBattle(
|
||||||
|
gameId: string,
|
||||||
|
turn: number,
|
||||||
|
battleId: string,
|
||||||
|
): Promise<BattleReport> {
|
||||||
|
if (isSyntheticGameId(gameId)) {
|
||||||
|
const fixture = lookupSyntheticBattle(battleId);
|
||||||
|
if (fixture === null) {
|
||||||
|
throw new BattleFetchError(404, "battle not found");
|
||||||
|
}
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
const path = `/api/v1/user/games/${encodeURIComponent(gameId)}/battles/${turn}/${encodeURIComponent(battleId)}`;
|
||||||
|
const response = await fetch(path, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new BattleFetchError(404, "battle not found");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BattleFetchError(
|
||||||
|
response.status,
|
||||||
|
`battle fetch failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (await response.json()) as BattleReport;
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// `EventStream` is the single SubscribeEvents consumer for the
|
||||||
|
// authenticated UI session. It opens one server-streaming RPC against
|
||||||
|
// the gateway, verifies every incoming event (payload-hash +
|
||||||
|
// Ed25519 signature) through `ui/core`, dispatches verified events to
|
||||||
|
// type-keyed handlers, and reconnects with full-jitter exponential
|
||||||
|
// backoff on transient failure.
|
||||||
|
//
|
||||||
|
// Phase 24 introduces this module in place of `revocation-watcher.ts`.
|
||||||
|
// The watcher's revocation semantics are absorbed: a clean
|
||||||
|
// end-of-stream while the session is authenticated, or an
|
||||||
|
// `Unauthenticated` ConnectError, both call `session.signOut("revoked")`.
|
||||||
|
// Per-event-type dispatch (turn-ready toasts in this phase; battle and
|
||||||
|
// mail toasts in later phases) is registered through `on(eventType,
|
||||||
|
// handler)`.
|
||||||
|
//
|
||||||
|
// The store exposes `connectionStatus` as a Svelte rune so the
|
||||||
|
// connection-state indicator in the shell header (see PLAN.md
|
||||||
|
// cross-cutting shell) can subscribe without ceremony. The indicator
|
||||||
|
// itself is not part of Phase 24, but the rune is wired here so the
|
||||||
|
// next phase that adds the dot can read it directly.
|
||||||
|
|
||||||
|
import { create } from "@bufbuild/protobuf";
|
||||||
|
import { ConnectError } from "@connectrpc/connect";
|
||||||
|
import type { Core } from "../platform/core/index";
|
||||||
|
import type { DeviceKeypair } from "../platform/store/index";
|
||||||
|
import {
|
||||||
|
GatewayEventSchema,
|
||||||
|
SubscribeEventsRequestSchema,
|
||||||
|
type GatewayEvent,
|
||||||
|
} from "../proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
|
import { GATEWAY_BASE_URL } from "../lib/env";
|
||||||
|
import { session } from "../lib/session-store.svelte";
|
||||||
|
import { createEdgeGatewayClient, type EdgeGatewayClient } from "./connect";
|
||||||
|
|
||||||
|
const PROTOCOL_VERSION = "v1";
|
||||||
|
const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe";
|
||||||
|
|
||||||
|
// Connect error code numerical values used by the watcher. The full
|
||||||
|
// enum lives in `@connectrpc/connect` but importing the runtime enum
|
||||||
|
// would pull a large surface into this small module.
|
||||||
|
const CONNECT_CODE_CANCELED = 1;
|
||||||
|
const CONNECT_CODE_UNAUTHENTICATED = 16;
|
||||||
|
|
||||||
|
const BACKOFF_BASE_MS = 1_000;
|
||||||
|
const BACKOFF_MAX_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VerifiedEvent is the verified projection of a `GatewayEvent` handed
|
||||||
|
* to user handlers. The signature and payload-hash fields are dropped
|
||||||
|
* because verification has already succeeded; consumers only need the
|
||||||
|
* envelope plus the opaque payload bytes.
|
||||||
|
*/
|
||||||
|
export interface VerifiedEvent {
|
||||||
|
eventType: string;
|
||||||
|
eventId: string;
|
||||||
|
timestampMs: bigint;
|
||||||
|
requestId: string;
|
||||||
|
traceId: string;
|
||||||
|
payloadBytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventHandler = (event: VerifiedEvent) => void;
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "reconnecting"
|
||||||
|
| "offline";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventStreamStartOptions carries the live primitives the stream
|
||||||
|
* consumer cannot resolve by itself. Production code reads `core`,
|
||||||
|
* `keypair`, and `deviceSessionId` from the session store and the
|
||||||
|
* gateway public key from `lib/env`; tests inject a fake
|
||||||
|
* `EdgeGatewayClient` and deterministic `sleep`/`random` to drive
|
||||||
|
* backoff in fake-timer mode.
|
||||||
|
*/
|
||||||
|
export interface EventStreamStartOptions {
|
||||||
|
core: Core;
|
||||||
|
keypair: DeviceKeypair;
|
||||||
|
deviceSessionId: string;
|
||||||
|
gatewayResponsePublicKey: Uint8Array;
|
||||||
|
/** Custom transport client. Defaults to `createEdgeGatewayClient(GATEWAY_BASE_URL)`. */
|
||||||
|
client?: EdgeGatewayClient;
|
||||||
|
/** Sleep hook for tests; defaults to a real-time `setTimeout`. */
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
/** Random source for full-jitter backoff; defaults to `Math.random`. */
|
||||||
|
random?: () => number;
|
||||||
|
/** Function reporting `navigator.onLine`; defaults to the browser global. */
|
||||||
|
onlineProbe?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SignatureError marks a verification failure (payload-hash mismatch
|
||||||
|
* or invalid Ed25519 signature). The stream loop classifies it as a
|
||||||
|
* forgery and reconnects through the same backoff path used for
|
||||||
|
* transient transport errors.
|
||||||
|
*/
|
||||||
|
export class SignatureError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SignatureError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventStream {
|
||||||
|
connectionStatus: ConnectionStatus = $state("idle");
|
||||||
|
|
||||||
|
private handlers = new Map<string, Set<EventHandler>>();
|
||||||
|
private controller: AbortController | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* on registers a handler for a specific event type. Returns a
|
||||||
|
* disposer that removes the handler. Multiple handlers per type
|
||||||
|
* are supported so future phases (battle, mail) can subscribe
|
||||||
|
* alongside turn-ready without coordination.
|
||||||
|
*/
|
||||||
|
on(eventType: string, handler: EventHandler): () => void {
|
||||||
|
let bucket = this.handlers.get(eventType);
|
||||||
|
if (bucket === undefined) {
|
||||||
|
bucket = new Set();
|
||||||
|
this.handlers.set(eventType, bucket);
|
||||||
|
}
|
||||||
|
bucket.add(handler);
|
||||||
|
return () => {
|
||||||
|
const current = this.handlers.get(eventType);
|
||||||
|
if (current === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.delete(handler);
|
||||||
|
if (current.size === 0) {
|
||||||
|
this.handlers.delete(eventType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* start opens the stream. Calling start while the stream is
|
||||||
|
* already running is a no-op so the root layout's `$effect`-based
|
||||||
|
* lifecycle stays idempotent across re-renders.
|
||||||
|
*/
|
||||||
|
start(opts: EventStreamStartOptions): void {
|
||||||
|
if (this.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running = true;
|
||||||
|
this.controller = new AbortController();
|
||||||
|
void this.run(opts, this.controller.signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stop tears down the stream. Used by the root layout on logout
|
||||||
|
* or unmount. Re-calling start after stop opens a fresh stream.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.running = false;
|
||||||
|
if (this.controller !== null) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
this.connectionStatus = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetForTests is used by the Vitest harness to forget all
|
||||||
|
* handlers and force the rune back to `idle` between cases.
|
||||||
|
*/
|
||||||
|
resetForTests(): void {
|
||||||
|
this.stop();
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(
|
||||||
|
opts: EventStreamStartOptions,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
const sleep = opts.sleep ?? defaultSleep;
|
||||||
|
const random = opts.random ?? Math.random;
|
||||||
|
const onlineProbe = opts.onlineProbe ?? defaultOnlineProbe;
|
||||||
|
const client = opts.client ?? createEdgeGatewayClient(GATEWAY_BASE_URL);
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (!signal.aborted && this.running) {
|
||||||
|
this.connectionStatus = "connecting";
|
||||||
|
let stream: AsyncIterable<GatewayEvent>;
|
||||||
|
try {
|
||||||
|
stream = await openStream(client, opts, signal);
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleAuthenticationError(err)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectionStatus = onlineProbe() ? "reconnecting" : "offline";
|
||||||
|
await sleep(backoffDelay(attempt, random));
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstEventSeen = false;
|
||||||
|
let terminated = false;
|
||||||
|
try {
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.verifyEvent(event, opts);
|
||||||
|
if (!firstEventSeen) {
|
||||||
|
firstEventSeen = true;
|
||||||
|
this.connectionStatus = "connected";
|
||||||
|
attempt = 0;
|
||||||
|
}
|
||||||
|
this.dispatch(event);
|
||||||
|
}
|
||||||
|
terminated = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleAuthenticationError(err)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectionStatus = onlineProbe() ? "reconnecting" : "offline";
|
||||||
|
await sleep(backoffDelay(attempt, random));
|
||||||
|
attempt += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (terminated) {
|
||||||
|
// Clean end-of-stream on an authenticated session is the
|
||||||
|
// gateway's documented session-invalidation signal.
|
||||||
|
if (session.status === "authenticated") {
|
||||||
|
await session.signOut("revoked");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.connectionStatus = "idle";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyEvent(event: GatewayEvent, opts: EventStreamStartOptions): void {
|
||||||
|
if (!opts.core.verifyPayloadHash(event.payloadBytes, event.payloadHash)) {
|
||||||
|
throw new SignatureError("event payload_hash mismatch");
|
||||||
|
}
|
||||||
|
const ok = opts.core.verifyEvent(
|
||||||
|
opts.gatewayResponsePublicKey,
|
||||||
|
event.signature,
|
||||||
|
{
|
||||||
|
eventType: event.eventType,
|
||||||
|
eventId: event.eventId,
|
||||||
|
timestampMs: event.timestampMs,
|
||||||
|
requestId: event.requestId,
|
||||||
|
traceId: event.traceId,
|
||||||
|
payloadHash: event.payloadHash,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
throw new SignatureError("event signature verification failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dispatch(event: GatewayEvent): void {
|
||||||
|
const bucket = this.handlers.get(event.eventType);
|
||||||
|
if (bucket === undefined || bucket.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const projection: VerifiedEvent = {
|
||||||
|
eventType: event.eventType,
|
||||||
|
eventId: event.eventId,
|
||||||
|
timestampMs: event.timestampMs,
|
||||||
|
requestId: event.requestId,
|
||||||
|
traceId: event.traceId,
|
||||||
|
payloadBytes: event.payloadBytes,
|
||||||
|
};
|
||||||
|
for (const handler of [...bucket]) {
|
||||||
|
try {
|
||||||
|
handler(projection);
|
||||||
|
} catch (err) {
|
||||||
|
console.info("events: handler threw", event.eventType, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openStream(
|
||||||
|
client: EdgeGatewayClient,
|
||||||
|
opts: EventStreamStartOptions,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<AsyncIterable<GatewayEvent>> {
|
||||||
|
const requestId = newRequestId();
|
||||||
|
const timestampMs = BigInt(Date.now());
|
||||||
|
const emptyPayload = new Uint8Array();
|
||||||
|
const payloadHash = await sha256(emptyPayload);
|
||||||
|
const canonical = opts.core.signRequest({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
deviceSessionId: opts.deviceSessionId,
|
||||||
|
messageType: SUBSCRIBE_MESSAGE_TYPE,
|
||||||
|
timestampMs,
|
||||||
|
requestId,
|
||||||
|
payloadHash,
|
||||||
|
});
|
||||||
|
const signature = await opts.keypair.sign(canonical);
|
||||||
|
const request = create(SubscribeEventsRequestSchema, {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
deviceSessionId: opts.deviceSessionId,
|
||||||
|
messageType: SUBSCRIBE_MESSAGE_TYPE,
|
||||||
|
timestampMs,
|
||||||
|
requestId,
|
||||||
|
payloadHash,
|
||||||
|
signature,
|
||||||
|
payloadBytes: emptyPayload,
|
||||||
|
});
|
||||||
|
return client.subscribeEvents(request, { signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthenticationError(err: unknown): boolean {
|
||||||
|
if (!(err instanceof ConnectError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (err.code === CONNECT_CODE_CANCELED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (err.code === CONNECT_CODE_UNAUTHENTICATED) {
|
||||||
|
void session.signOut("revoked");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function backoffDelay(attempt: number, random: () => number): number {
|
||||||
|
const cap = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
||||||
|
return Math.floor(random() * cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultSleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOnlineProbe(): boolean {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return navigator.onLine !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const digest = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
payload as BufferSource,
|
||||||
|
);
|
||||||
|
return new Uint8Array(digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRequestId(): string {
|
||||||
|
if (typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
const buf = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
let hex = "";
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
hex += buf[i]!.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* eventStream is the singleton stream consumer the root layout starts
|
||||||
|
* once the session becomes authenticated and stops on logout. Tests
|
||||||
|
* call `resetForTests()` between cases.
|
||||||
|
*/
|
||||||
|
export const eventStream = new EventStream();
|
||||||
@@ -38,12 +38,16 @@ import { Builder, ByteBuffer } from "flatbuffers";
|
|||||||
import type { GalaxyClient } from "./galaxy-client";
|
import type { GalaxyClient } from "./galaxy-client";
|
||||||
import { UUID } from "../proto/galaxy/fbs/common";
|
import { UUID } from "../proto/galaxy/fbs/common";
|
||||||
import {
|
import {
|
||||||
|
Bombing,
|
||||||
GameReportRequest,
|
GameReportRequest,
|
||||||
IncomingGroup,
|
IncomingGroup,
|
||||||
LocalFleet,
|
LocalFleet,
|
||||||
LocalGroup,
|
LocalGroup,
|
||||||
OtherGroup,
|
OtherGroup,
|
||||||
|
OtherScience,
|
||||||
|
OthersShipClass,
|
||||||
Report,
|
Report,
|
||||||
|
ShipProduction,
|
||||||
UnidentifiedGroup,
|
UnidentifiedGroup,
|
||||||
} from "../proto/galaxy/fbs/report";
|
} from "../proto/galaxy/fbs/report";
|
||||||
import type {
|
import type {
|
||||||
@@ -280,6 +284,125 @@ export interface ReportOtherRace {
|
|||||||
votesReceived: number;
|
votesReceived: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReportPlayer is the per-player projection consumed by the Phase 23
|
||||||
|
* Report View's Player Status section. Unlike `ReportOtherRace`, this
|
||||||
|
* row carries the local player and extinct rows too: the section is a
|
||||||
|
* status overview, not a diplomacy surface. Sorted alphabetically by
|
||||||
|
* name (case-insensitive); `isLocal` flags the calling player's row so
|
||||||
|
* the section can highlight it. The wire `relation` field is
|
||||||
|
* intentionally omitted — the self row carries the engine's "-"
|
||||||
|
* sentinel and the other-race rows already expose it via
|
||||||
|
* `GameReport.races`.
|
||||||
|
*/
|
||||||
|
export interface ReportPlayer {
|
||||||
|
name: string;
|
||||||
|
drive: number;
|
||||||
|
weapons: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
population: number;
|
||||||
|
industry: number;
|
||||||
|
planets: number;
|
||||||
|
votesReceived: number;
|
||||||
|
extinct: boolean;
|
||||||
|
isLocal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReportOtherScience is a single row in the Phase 23 Report View's
|
||||||
|
* Foreign Sciences section. Mirrors the wire `OtherScience` (carries
|
||||||
|
* the owning `race` alongside the four tech proportions). Stable
|
||||||
|
* order: sorted by `(race, name)` so the report's per-race sub-tables
|
||||||
|
* render deterministically.
|
||||||
|
*/
|
||||||
|
export interface ReportOtherScience {
|
||||||
|
race: string;
|
||||||
|
name: string;
|
||||||
|
drive: number;
|
||||||
|
weapons: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReportOtherShipClass is a single row in the Phase 23 Report View's
|
||||||
|
* Foreign Ship Classes section. Mirrors the wire `OthersShipClass`
|
||||||
|
* (carries the owning `race`, the five tech-derived numbers, plus the
|
||||||
|
* `mass` the local ship-classes table does not surface — useful for
|
||||||
|
* fleet-mass comparison against incoming groups). Stable order:
|
||||||
|
* sorted by `(race, name)`.
|
||||||
|
*/
|
||||||
|
export interface ReportOtherShipClass {
|
||||||
|
race: string;
|
||||||
|
name: string;
|
||||||
|
drive: number;
|
||||||
|
armament: number;
|
||||||
|
weapons: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
mass: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReportBombing is a single row in the Phase 23 Report View's
|
||||||
|
* Bombings section. Mirrors the wire `Bombing` (post-bombing planet
|
||||||
|
* snapshot, attacker/owner identity, attack power, and the boolean
|
||||||
|
* `wiped` flag that drives a visually-distinct row state). Sorted by
|
||||||
|
* `planetNumber` for deterministic rendering.
|
||||||
|
*
|
||||||
|
* Field naming follows the existing `ReportPlanet` convention:
|
||||||
|
* `capital → industryStockpile`, `material → materialsStockpile`,
|
||||||
|
* `number → planetNumber`.
|
||||||
|
*/
|
||||||
|
export interface ReportBombing {
|
||||||
|
planetNumber: number;
|
||||||
|
planet: string;
|
||||||
|
owner: string;
|
||||||
|
attacker: string;
|
||||||
|
production: string;
|
||||||
|
industry: number;
|
||||||
|
population: number;
|
||||||
|
colonists: number;
|
||||||
|
industryStockpile: number;
|
||||||
|
materialsStockpile: number;
|
||||||
|
attackPower: number;
|
||||||
|
wiped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReportShipProduction is a single row in the Phase 23 Report View's
|
||||||
|
* Ships In Production section. Mirrors the wire `ShipProduction`.
|
||||||
|
* `planetNumber` resolves against `GameReport.planets` so the section
|
||||||
|
* can render the producing planet's name; `cost` is the per-ship
|
||||||
|
* production cost (`ShipProductionCost(shipMass)`, not including the
|
||||||
|
* per-turn material-farming term); `prodUsed` is the engine's residual
|
||||||
|
* production poured into the partial ship this turn; `percent` is the
|
||||||
|
* cumulative build progress as a fraction in [0, 1]; `freeIndustry`
|
||||||
|
* mirrors the producing planet's free industry. Stable order: sorted
|
||||||
|
* by `(planetNumber, class)`.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* ReportBattle is one battle summary in the current turn. Carries the
|
||||||
|
* battle UUID, planet number, and shot count — enough to render a
|
||||||
|
* battle marker on the map and to link into the Battle Viewer without
|
||||||
|
* fetching the full BattleReport.
|
||||||
|
*/
|
||||||
|
export interface ReportBattle {
|
||||||
|
id: string;
|
||||||
|
planet: number;
|
||||||
|
shots: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportShipProduction {
|
||||||
|
planetNumber: number;
|
||||||
|
class: string;
|
||||||
|
cost: number;
|
||||||
|
prodUsed: number;
|
||||||
|
percent: number;
|
||||||
|
freeIndustry: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameReport {
|
export interface GameReport {
|
||||||
turn: number;
|
turn: number;
|
||||||
mapWidth: number;
|
mapWidth: number;
|
||||||
@@ -389,6 +512,56 @@ export interface GameReport {
|
|||||||
* report always carries a non-empty value.
|
* report always carries a non-empty value.
|
||||||
*/
|
*/
|
||||||
myVoteFor: string;
|
myVoteFor: string;
|
||||||
|
/**
|
||||||
|
* players is the richer per-player projection Phase 23 added for
|
||||||
|
* the Report View's Player Status section. Same data source as
|
||||||
|
* `races[]` (`report.player[]`) but with the local player and
|
||||||
|
* extinct rows included, sorted alphabetically by name and tagged
|
||||||
|
* with `isLocal`. `races[]` stays Phase 22's view (other,
|
||||||
|
* non-extinct) so diplomatic-stance code paths do not churn.
|
||||||
|
*/
|
||||||
|
players: ReportPlayer[];
|
||||||
|
/**
|
||||||
|
* otherScience is the per-race foreign-sciences projection Phase
|
||||||
|
* 23 added for the Report View's Foreign Sciences section. Sorted
|
||||||
|
* by `(race, name)`. Empty when the report has no foreign science
|
||||||
|
* data (boot state, single-race game, legacy synthetic data).
|
||||||
|
*/
|
||||||
|
otherScience: ReportOtherScience[];
|
||||||
|
/**
|
||||||
|
* otherShipClass is the per-race foreign-ship-classes projection
|
||||||
|
* Phase 23 added for the Report View's Foreign Ship Classes
|
||||||
|
* section. Sorted by `(race, name)`. Empty when the report has no
|
||||||
|
* foreign ship-class data.
|
||||||
|
*/
|
||||||
|
otherShipClass: ReportOtherShipClass[];
|
||||||
|
/**
|
||||||
|
* battles is the list of battle summaries the engine recorded for
|
||||||
|
* the current turn. Each entry carries the battle UUID, the planet
|
||||||
|
* it happened on, and the number of shots exchanged. The Reports
|
||||||
|
* View uses `id` to link into the Battle Viewer; the map renderer
|
||||||
|
* uses `planet` to locate the marker and `shots` to scale its
|
||||||
|
* stroke. Empty when no battles occurred last turn.
|
||||||
|
*/
|
||||||
|
battles: ReportBattle[];
|
||||||
|
/**
|
||||||
|
* battleIds is a convenience derived list of UUIDs from `battles`,
|
||||||
|
* preserved for legacy callers (Phase 23 report section, fixtures).
|
||||||
|
*/
|
||||||
|
battleIds: string[];
|
||||||
|
/**
|
||||||
|
* bombings is the per-bombing projection Phase 23 added for the
|
||||||
|
* Report View's Bombings section. Sorted by `planetNumber`. Empty
|
||||||
|
* when no planets were bombed last turn.
|
||||||
|
*/
|
||||||
|
bombings: ReportBombing[];
|
||||||
|
/**
|
||||||
|
* shipProductions is the per-ship-production projection Phase 23
|
||||||
|
* added for the Report View's Ships In Production section.
|
||||||
|
* Sorted by `(planetNumber, class)`. Empty when no planet is
|
||||||
|
* currently producing a ship.
|
||||||
|
*/
|
||||||
|
shipProductions: ReportShipProduction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameReport(
|
export async function fetchGameReport(
|
||||||
@@ -537,11 +710,18 @@ function decodeReport(report: Report): GameReport {
|
|||||||
const localTech = findLocalPlayerTech(report, raceName);
|
const localTech = findLocalPlayerTech(report, raceName);
|
||||||
const otherRaces = collectOtherRaces(report, raceName);
|
const otherRaces = collectOtherRaces(report, raceName);
|
||||||
const races = collectOtherRaceRows(report, raceName);
|
const races = collectOtherRaceRows(report, raceName);
|
||||||
|
const players = decodePlayers(report, raceName);
|
||||||
const localShipGroups = decodeLocalShipGroups(report);
|
const localShipGroups = decodeLocalShipGroups(report);
|
||||||
const otherShipGroups = decodeOtherShipGroups(report);
|
const otherShipGroups = decodeOtherShipGroups(report);
|
||||||
const incomingShipGroups = decodeIncomingShipGroups(report);
|
const incomingShipGroups = decodeIncomingShipGroups(report);
|
||||||
const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
|
const unidentifiedShipGroups = decodeUnidentifiedShipGroups(report);
|
||||||
const localFleets = decodeLocalFleets(report);
|
const localFleets = decodeLocalFleets(report);
|
||||||
|
const otherScience = decodeOtherScience(report);
|
||||||
|
const otherShipClass = decodeOtherShipClass(report);
|
||||||
|
const battles = decodeBattles(report);
|
||||||
|
const battleIds = battles.map((b) => b.id);
|
||||||
|
const bombings = decodeBombings(report);
|
||||||
|
const shipProductions = decodeShipProductions(report);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turn: Number(report.turn()),
|
turn: Number(report.turn()),
|
||||||
@@ -566,6 +746,13 @@ function decodeReport(report: Report): GameReport {
|
|||||||
races,
|
races,
|
||||||
myVotes: report.votes(),
|
myVotes: report.votes(),
|
||||||
myVoteFor: report.voteFor() ?? "",
|
myVoteFor: report.voteFor() ?? "",
|
||||||
|
players,
|
||||||
|
otherScience,
|
||||||
|
otherShipClass,
|
||||||
|
battles,
|
||||||
|
battleIds,
|
||||||
|
bombings,
|
||||||
|
shipProductions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,6 +1090,151 @@ function collectOtherRaceRows(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* decodePlayers walks `report.player[]` and emits the full status
|
||||||
|
* roster the Phase 23 Report View's Player Status section renders:
|
||||||
|
* every named row including the local player and extinct races,
|
||||||
|
* sorted alphabetically (case-insensitive). The local row carries
|
||||||
|
* `isLocal: true` so the section can highlight it; the wire
|
||||||
|
* `relation` field is intentionally dropped (self carries the engine
|
||||||
|
* "-" sentinel, other rows already surface relation through
|
||||||
|
* `GameReport.races`).
|
||||||
|
*/
|
||||||
|
function decodePlayers(report: Report, raceName: string): ReportPlayer[] {
|
||||||
|
const out: ReportPlayer[] = [];
|
||||||
|
for (let i = 0; i < report.playerLength(); i++) {
|
||||||
|
const player = report.player(i);
|
||||||
|
if (player === null) continue;
|
||||||
|
const name = player.name() ?? "";
|
||||||
|
if (name === "") continue;
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
drive: player.drive(),
|
||||||
|
weapons: player.weapons(),
|
||||||
|
shields: player.shields(),
|
||||||
|
cargo: player.cargo(),
|
||||||
|
population: player.population(),
|
||||||
|
industry: player.industry(),
|
||||||
|
planets: player.planets(),
|
||||||
|
votesReceived: player.votes(),
|
||||||
|
extinct: player.extinct(),
|
||||||
|
isLocal: name === raceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) =>
|
||||||
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOtherScience(report: Report): ReportOtherScience[] {
|
||||||
|
const out: ReportOtherScience[] = [];
|
||||||
|
for (let i = 0; i < report.otherScienceLength(); i++) {
|
||||||
|
const s = report.otherScience(i);
|
||||||
|
if (s === null) continue;
|
||||||
|
out.push({
|
||||||
|
race: s.race() ?? "",
|
||||||
|
name: s.name() ?? "",
|
||||||
|
drive: s.drive(),
|
||||||
|
weapons: s.weapons(),
|
||||||
|
shields: s.shields(),
|
||||||
|
cargo: s.cargo(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const byRace = a.race.localeCompare(b.race);
|
||||||
|
if (byRace !== 0) return byRace;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeOtherShipClass(report: Report): ReportOtherShipClass[] {
|
||||||
|
const out: ReportOtherShipClass[] = [];
|
||||||
|
for (let i = 0; i < report.otherShipClassLength(); i++) {
|
||||||
|
const sc = report.otherShipClass(i);
|
||||||
|
if (sc === null) continue;
|
||||||
|
out.push({
|
||||||
|
race: sc.race() ?? "",
|
||||||
|
name: sc.name() ?? "",
|
||||||
|
drive: sc.drive(),
|
||||||
|
armament: Number(sc.armament()),
|
||||||
|
weapons: sc.weapons(),
|
||||||
|
shields: sc.shields(),
|
||||||
|
cargo: sc.cargo(),
|
||||||
|
mass: sc.mass(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const byRace = a.race.localeCompare(b.race);
|
||||||
|
if (byRace !== 0) return byRace;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBattles(report: Report): ReportBattle[] {
|
||||||
|
const out: ReportBattle[] = [];
|
||||||
|
for (let i = 0; i < report.battleLength(); i++) {
|
||||||
|
const summary = report.battle(i);
|
||||||
|
if (summary === null) continue;
|
||||||
|
const id = uuidStringFromFB(summary.id());
|
||||||
|
if (id === null) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
planet: Number(summary.planet()),
|
||||||
|
shots: Number(summary.shots()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBombings(report: Report): ReportBombing[] {
|
||||||
|
const out: ReportBombing[] = [];
|
||||||
|
for (let i = 0; i < report.bombingLength(); i++) {
|
||||||
|
const b = report.bombing(i);
|
||||||
|
if (b === null) continue;
|
||||||
|
out.push({
|
||||||
|
planetNumber: Number(b.number()),
|
||||||
|
planet: b.planet() ?? "",
|
||||||
|
owner: b.owner() ?? "",
|
||||||
|
attacker: b.attacker() ?? "",
|
||||||
|
production: b.production() ?? "",
|
||||||
|
industry: b.industry(),
|
||||||
|
population: b.population(),
|
||||||
|
colonists: b.colonists(),
|
||||||
|
industryStockpile: b.capital(),
|
||||||
|
materialsStockpile: b.material(),
|
||||||
|
attackPower: b.attackPower(),
|
||||||
|
wiped: b.wiped(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.planetNumber - b.planetNumber);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeShipProductions(report: Report): ReportShipProduction[] {
|
||||||
|
const out: ReportShipProduction[] = [];
|
||||||
|
for (let i = 0; i < report.shipProductionLength(); i++) {
|
||||||
|
const sp = report.shipProduction(i);
|
||||||
|
if (sp === null) continue;
|
||||||
|
out.push({
|
||||||
|
planetNumber: Number(sp.planet()),
|
||||||
|
class: sp.class_() ?? "",
|
||||||
|
cost: sp.cost(),
|
||||||
|
prodUsed: sp.prodUsed(),
|
||||||
|
percent: sp.percent(),
|
||||||
|
freeIndustry: sp.free(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => {
|
||||||
|
const byPlanet = a.planetNumber - b.planetNumber;
|
||||||
|
if (byPlanet !== 0) return byPlanet;
|
||||||
|
return a.class.localeCompare(b.class);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* uuidToHiLo splits the canonical 36-character UUID string
|
* uuidToHiLo splits the canonical 36-character UUID string
|
||||||
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
||||||
@@ -1125,6 +1457,17 @@ export function applyOrderOverlay(
|
|||||||
localScience: mutatedScience ?? report.localScience ?? [],
|
localScience: mutatedScience ?? report.localScience ?? [],
|
||||||
races: mutatedRaces ?? report.races ?? [],
|
races: mutatedRaces ?? report.races ?? [],
|
||||||
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
|
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
|
||||||
|
// Phase 23 read-only fields. No overlay branches touch them
|
||||||
|
// today; the `?? []` keeps a stale HMR-instance of `report`
|
||||||
|
// (loaded before the shape bump) from blanking the Report
|
||||||
|
// View when its section components iterate.
|
||||||
|
players: report.players ?? [],
|
||||||
|
otherScience: report.otherScience ?? [],
|
||||||
|
otherShipClass: report.otherShipClass ?? [],
|
||||||
|
battles: report.battles ?? [],
|
||||||
|
battleIds: report.battleIds ?? [],
|
||||||
|
bombings: report.bombings ?? [],
|
||||||
|
shipProductions: report.shipProductions ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Synthetic battle reports for DEV / e2e mode.
|
||||||
|
//
|
||||||
|
// Mirrors the shape of `pkg/model/report/battle.go` so the
|
||||||
|
// BattleViewer can be exercised without a running engine. Fixtures
|
||||||
|
// are registered by battle UUID; the synthetic-report loader fills
|
||||||
|
// the report's `battles[]` with these same UUIDs so the report ↔
|
||||||
|
// battle link is consistent.
|
||||||
|
|
||||||
|
import type { BattleReport } from "./battle-fetch";
|
||||||
|
|
||||||
|
const SYNTHETIC_BATTLES = new Map<string, BattleReport>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* registerSyntheticBattle adds a fixture battle to the in-memory map
|
||||||
|
* keyed by its `id`. Used by the synthetic-report DEV loader and by
|
||||||
|
* Vitest unit tests that need a deterministic BattleReport without a
|
||||||
|
* live engine.
|
||||||
|
*/
|
||||||
|
export function registerSyntheticBattle(report: BattleReport): void {
|
||||||
|
SYNTHETIC_BATTLES.set(report.id, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* lookupSyntheticBattle returns the fixture stored under `battleId`,
|
||||||
|
* or `null` if nothing was registered (mirrors the engine's 404).
|
||||||
|
*/
|
||||||
|
export function lookupSyntheticBattle(battleId: string): BattleReport | null {
|
||||||
|
return SYNTHETIC_BATTLES.get(battleId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resetSyntheticBattles clears every registered fixture. Test
|
||||||
|
* harnesses call this between cases to avoid bleed-through.
|
||||||
|
*/
|
||||||
|
export function resetSyntheticBattles(): void {
|
||||||
|
SYNTHETIC_BATTLES.clear();
|
||||||
|
}
|
||||||
@@ -20,13 +20,18 @@
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
GameReport,
|
GameReport,
|
||||||
|
ReportBombing,
|
||||||
ReportIncomingShipGroup,
|
ReportIncomingShipGroup,
|
||||||
ReportLocalFleet,
|
ReportLocalFleet,
|
||||||
ReportLocalShipGroup,
|
ReportLocalShipGroup,
|
||||||
ReportOtherRace,
|
ReportOtherRace,
|
||||||
|
ReportOtherScience,
|
||||||
|
ReportOtherShipClass,
|
||||||
ReportOtherShipGroup,
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
|
ReportPlayer,
|
||||||
ReportRoute,
|
ReportRoute,
|
||||||
|
ReportShipProduction,
|
||||||
ReportUnidentifiedShipGroup,
|
ReportUnidentifiedShipGroup,
|
||||||
ScienceSummary,
|
ScienceSummary,
|
||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
@@ -34,6 +39,8 @@ import type {
|
|||||||
} from "./game-state";
|
} from "./game-state";
|
||||||
import type { CargoLoadType, Relation } from "../sync/order-types";
|
import type { CargoLoadType, Relation } from "../sync/order-types";
|
||||||
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
||||||
|
import type { BattleReport } from "./battle-fetch";
|
||||||
|
import { registerSyntheticBattle } from "./synthetic-battle";
|
||||||
|
|
||||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||||
|
|
||||||
@@ -54,18 +61,71 @@ export class SyntheticReportError extends Error {
|
|||||||
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
* loadSyntheticReportFromJSON validates the passed payload, decodes
|
||||||
* it into a `GameReport`, registers it in the in-memory map under a
|
* it into a `GameReport`, registers it in the in-memory map under a
|
||||||
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
* fresh `synthetic-<uuid>` id, and returns both the id and the
|
||||||
* decoded report. Throws `SyntheticReportError` for malformed input.
|
* decoded report.
|
||||||
|
*
|
||||||
|
* Accepts two on-disk shapes:
|
||||||
|
*
|
||||||
|
* 1. Envelope (Phase 27 legacy-report CLI):
|
||||||
|
* `{ "version": 1, "report": <Report>, "battles": { <uuid>: <BattleReport> } }`
|
||||||
|
* — battles are forwarded to `registerSyntheticBattle` so the
|
||||||
|
* Battle Viewer can resolve them offline.
|
||||||
|
* 2. Bare Report (pre-envelope synthetic JSON files) — same as
|
||||||
|
* before; battle UUIDs in the report can still be clicked, but
|
||||||
|
* the Viewer page will show "battle not found" because no
|
||||||
|
* fixture was registered.
|
||||||
|
*
|
||||||
|
* Throws `SyntheticReportError` for malformed input in either shape.
|
||||||
*/
|
*/
|
||||||
export function loadSyntheticReportFromJSON(json: unknown): {
|
export function loadSyntheticReportFromJSON(json: unknown): {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
report: GameReport;
|
report: GameReport;
|
||||||
} {
|
} {
|
||||||
const report = decodeSyntheticReport(json);
|
const { reportPayload, battles } = extractEnvelope(json);
|
||||||
|
const report = decodeSyntheticReport(reportPayload);
|
||||||
|
for (const battle of battles) {
|
||||||
|
registerSyntheticBattle(battle);
|
||||||
|
}
|
||||||
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
const gameId = SYNTHETIC_GAME_ID_PREFIX + crypto.randomUUID();
|
||||||
SYNTHETIC_REPORTS.set(gameId, report);
|
SYNTHETIC_REPORTS.set(gameId, report);
|
||||||
return { gameId, report };
|
return { gameId, report };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyntheticEnvelope {
|
||||||
|
version?: number;
|
||||||
|
report?: unknown;
|
||||||
|
battles?: Record<string, BattleReport>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* extractEnvelope distinguishes the v1 envelope shape from a bare
|
||||||
|
* Report payload. The envelope check is `version === 1` to leave room
|
||||||
|
* for future format bumps and to avoid mistaking a bare Report whose
|
||||||
|
* top-level fields happen to include `report`/`battles` (none do
|
||||||
|
* today) for an envelope.
|
||||||
|
*/
|
||||||
|
function extractEnvelope(json: unknown): {
|
||||||
|
reportPayload: unknown;
|
||||||
|
battles: BattleReport[];
|
||||||
|
} {
|
||||||
|
if (typeof json !== "object" || json === null) {
|
||||||
|
// Defer the error to `decodeSyntheticReport`; it already
|
||||||
|
// raises a `SyntheticReportError` with the right message.
|
||||||
|
return { reportPayload: json, battles: [] };
|
||||||
|
}
|
||||||
|
const env = json as SyntheticEnvelope;
|
||||||
|
if (env.version === 1 && env.report !== undefined) {
|
||||||
|
const battlesMap = env.battles ?? {};
|
||||||
|
const battles: BattleReport[] = [];
|
||||||
|
for (const value of Object.values(battlesMap)) {
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
battles.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { reportPayload: env.report, battles };
|
||||||
|
}
|
||||||
|
return { reportPayload: json, battles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
/** getSyntheticReport returns the report registered under `gameId`,
|
/** getSyntheticReport returns the report registered under `gameId`,
|
||||||
* or `undefined` if the entry was lost (e.g. page reload). */
|
* or `undefined` if the entry was lost (e.g. page reload). */
|
||||||
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
export function getSyntheticReport(gameId: string): GameReport | undefined {
|
||||||
@@ -159,6 +219,45 @@ interface SyntheticScience {
|
|||||||
cargo?: number;
|
cargo?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyntheticOtherScience extends SyntheticScience {
|
||||||
|
race?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyntheticOtherShipClass extends SyntheticShipClass {
|
||||||
|
race?: string;
|
||||||
|
mass?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyntheticBattle {
|
||||||
|
id?: string;
|
||||||
|
planet?: number;
|
||||||
|
shots?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyntheticBombing {
|
||||||
|
planet?: number; // wire field "number"
|
||||||
|
planetName?: string; // wire field "planetName"
|
||||||
|
owner?: string;
|
||||||
|
attacker?: string;
|
||||||
|
production?: string;
|
||||||
|
industry?: number;
|
||||||
|
population?: number;
|
||||||
|
colonists?: number;
|
||||||
|
capital?: number;
|
||||||
|
material?: number;
|
||||||
|
attack?: number;
|
||||||
|
wiped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyntheticShipProductionRow {
|
||||||
|
planet?: number;
|
||||||
|
class?: string;
|
||||||
|
cost?: number;
|
||||||
|
prodUsed?: number;
|
||||||
|
percent?: number;
|
||||||
|
free?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SyntheticReportRoot {
|
interface SyntheticReportRoot {
|
||||||
turn?: number;
|
turn?: number;
|
||||||
mapWidth?: number;
|
mapWidth?: number;
|
||||||
@@ -173,12 +272,17 @@ interface SyntheticReportRoot {
|
|||||||
uninhabitedPlanet?: SyntheticPlanet[];
|
uninhabitedPlanet?: SyntheticPlanet[];
|
||||||
unidentifiedPlanet?: SyntheticPlanet[];
|
unidentifiedPlanet?: SyntheticPlanet[];
|
||||||
localShipClass?: SyntheticShipClass[];
|
localShipClass?: SyntheticShipClass[];
|
||||||
|
otherShipClass?: SyntheticOtherShipClass[];
|
||||||
localScience?: SyntheticScience[];
|
localScience?: SyntheticScience[];
|
||||||
|
otherScience?: SyntheticOtherScience[];
|
||||||
localGroup?: SyntheticShipGroup[];
|
localGroup?: SyntheticShipGroup[];
|
||||||
otherGroup?: SyntheticShipGroup[];
|
otherGroup?: SyntheticShipGroup[];
|
||||||
incomingGroup?: SyntheticIncomingGroup[];
|
incomingGroup?: SyntheticIncomingGroup[];
|
||||||
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
unidentifiedGroup?: SyntheticUnidentifiedGroup[];
|
||||||
localFleet?: SyntheticLocalFleet[];
|
localFleet?: SyntheticLocalFleet[];
|
||||||
|
battle?: SyntheticBattle[];
|
||||||
|
bombing?: SyntheticBombing[];
|
||||||
|
shipProduction?: SyntheticShipProductionRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeSyntheticReport(json: unknown): GameReport {
|
function decodeSyntheticReport(json: unknown): GameReport {
|
||||||
@@ -278,6 +382,86 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
state: typeof f.state === "string" ? f.state : "",
|
state: typeof f.state === "string" ? f.state : "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const otherScience: ReportOtherScience[] = (root.otherScience ?? []).map(
|
||||||
|
(sc) => ({
|
||||||
|
race: typeof sc.race === "string" ? sc.race : "",
|
||||||
|
name: typeof sc.name === "string" ? sc.name : "",
|
||||||
|
drive: numOr0(sc.drive),
|
||||||
|
weapons: numOr0(sc.weapons),
|
||||||
|
shields: numOr0(sc.shields),
|
||||||
|
cargo: numOr0(sc.cargo),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
otherScience.sort((a, b) => {
|
||||||
|
const byRace = a.race.localeCompare(b.race);
|
||||||
|
if (byRace !== 0) return byRace;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const otherShipClass: ReportOtherShipClass[] = (root.otherShipClass ?? []).map(
|
||||||
|
(sc) => ({
|
||||||
|
race: typeof sc.race === "string" ? sc.race : "",
|
||||||
|
name: typeof sc.name === "string" ? sc.name : "",
|
||||||
|
drive: numOr0(sc.drive),
|
||||||
|
armament: Math.trunc(numOr0(sc.armament)),
|
||||||
|
weapons: numOr0(sc.weapons),
|
||||||
|
shields: numOr0(sc.shields),
|
||||||
|
cargo: numOr0(sc.cargo),
|
||||||
|
// `mass` is on the wire but synthetic fixtures may omit
|
||||||
|
// it; fall back to 0 rather than reject the row.
|
||||||
|
mass: typeof sc.mass === "number" ? sc.mass : 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
otherShipClass.sort((a, b) => {
|
||||||
|
const byRace = a.race.localeCompare(b.race);
|
||||||
|
if (byRace !== 0) return byRace;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const battles = (root.battle ?? [])
|
||||||
|
.filter(
|
||||||
|
(v): v is SyntheticBattle =>
|
||||||
|
typeof v === "object" && v !== null && typeof v.id === "string" && v.id !== "",
|
||||||
|
)
|
||||||
|
.map((b) => ({
|
||||||
|
id: b.id as string,
|
||||||
|
planet: numOr0(b.planet),
|
||||||
|
shots: numOr0(b.shots),
|
||||||
|
}));
|
||||||
|
const battleIds = battles.map((b) => b.id);
|
||||||
|
|
||||||
|
const bombings: ReportBombing[] = (root.bombing ?? []).map((b) => ({
|
||||||
|
planetNumber: numOr0(b.planet),
|
||||||
|
planet: typeof b.planetName === "string" ? b.planetName : "",
|
||||||
|
owner: typeof b.owner === "string" ? b.owner : "",
|
||||||
|
attacker: typeof b.attacker === "string" ? b.attacker : "",
|
||||||
|
production: typeof b.production === "string" ? b.production : "",
|
||||||
|
industry: numOr0(b.industry),
|
||||||
|
population: numOr0(b.population),
|
||||||
|
colonists: numOr0(b.colonists),
|
||||||
|
industryStockpile: numOr0(b.capital),
|
||||||
|
materialsStockpile: numOr0(b.material),
|
||||||
|
attackPower: numOr0(b.attack),
|
||||||
|
wiped: b.wiped === true,
|
||||||
|
}));
|
||||||
|
bombings.sort((a, b) => a.planetNumber - b.planetNumber);
|
||||||
|
|
||||||
|
const shipProductions: ReportShipProduction[] = (root.shipProduction ?? []).map(
|
||||||
|
(sp) => ({
|
||||||
|
planetNumber: numOr0(sp.planet),
|
||||||
|
class: typeof sp.class === "string" ? sp.class : "",
|
||||||
|
cost: numOr0(sp.cost),
|
||||||
|
prodUsed: numOr0(sp.prodUsed),
|
||||||
|
percent: numOr0(sp.percent),
|
||||||
|
freeIndustry: numOr0(sp.free),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
shipProductions.sort((a, b) => {
|
||||||
|
const byPlanet = a.planetNumber - b.planetNumber;
|
||||||
|
if (byPlanet !== 0) return byPlanet;
|
||||||
|
return a.class.localeCompare(b.class);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
turn: numOr0(root.turn),
|
turn: numOr0(root.turn),
|
||||||
mapWidth: numOr0(root.mapWidth),
|
mapWidth: numOr0(root.mapWidth),
|
||||||
@@ -301,9 +485,44 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
races: collectOtherRaceRowsFromSynthetic(root, race),
|
races: collectOtherRaceRowsFromSynthetic(root, race),
|
||||||
myVotes: numOr0(root.votes),
|
myVotes: numOr0(root.votes),
|
||||||
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
|
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
|
||||||
|
players: collectPlayersFromSynthetic(root, race),
|
||||||
|
otherScience,
|
||||||
|
otherShipClass,
|
||||||
|
battles,
|
||||||
|
battleIds,
|
||||||
|
bombings,
|
||||||
|
shipProductions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectPlayersFromSynthetic(
|
||||||
|
root: SyntheticReportRoot,
|
||||||
|
raceName: string,
|
||||||
|
): ReportPlayer[] {
|
||||||
|
const out: ReportPlayer[] = [];
|
||||||
|
for (const player of root.player ?? []) {
|
||||||
|
const name = typeof player.name === "string" ? player.name : "";
|
||||||
|
if (name === "") continue;
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
drive: numOr0(player.drive),
|
||||||
|
weapons: numOr0(player.weapons),
|
||||||
|
shields: numOr0(player.shields),
|
||||||
|
cargo: numOr0(player.cargo),
|
||||||
|
population: numOr0(player.population),
|
||||||
|
industry: numOr0(player.industry),
|
||||||
|
planets: Math.trunc(numOr0(player.planets)),
|
||||||
|
votesReceived: numOr0(player.votes),
|
||||||
|
extinct: player.extinct === true,
|
||||||
|
isLocal: name === raceName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) =>
|
||||||
|
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function collectOtherRacesFromSynthetic(
|
function collectOtherRacesFromSynthetic(
|
||||||
root: SyntheticReportRoot,
|
root: SyntheticReportRoot,
|
||||||
raceName: string,
|
raceName: string,
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Galaxy</title>
|
<title>Galaxy</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -1,30 +1,172 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 10 stub for the battle-log active view. Phase 27 wires the real
|
Phase 27 — active-view wrapper around the BattleViewer. Loads the
|
||||||
battle viewer.
|
BattleReport for the supplied `gameId`/`turn`/`battleId` and either
|
||||||
|
shows the radial playback (BattleViewer), a loading skeleton, or a
|
||||||
|
not-found state.
|
||||||
|
|
||||||
|
This wrapper also bridges the surrounding GameReport's ship-class
|
||||||
|
tables into a `(race, className) → ShipClassRef` lookup the viewer
|
||||||
|
needs to size class circles by ship mass. The back-navigation
|
||||||
|
buttons (`back to map` / `back to report`) live INSIDE the viewer
|
||||||
|
header now — we just hand the routes down as callbacks so the
|
||||||
|
viewer keeps its prop-driven contract.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { getContext } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
type Props = { battleId: string };
|
import {
|
||||||
let { battleId }: Props = $props();
|
BattleFetchError,
|
||||||
|
fetchBattle,
|
||||||
|
type BattleReport,
|
||||||
|
} from "../../api/battle-fetch";
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import {
|
||||||
|
MapShipClassLookup,
|
||||||
|
type ShipClassLookup,
|
||||||
|
type ShipClassRef,
|
||||||
|
} from "../battle-player/mass";
|
||||||
|
|
||||||
|
import BattleViewer from "../battle-player/battle-viewer.svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
gameId,
|
||||||
|
turn,
|
||||||
|
battleId,
|
||||||
|
}: {
|
||||||
|
gameId: string;
|
||||||
|
turn: number;
|
||||||
|
battleId: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const shipClassLookup = $derived.by<ShipClassLookup>(() => {
|
||||||
|
const map = new Map<string, ShipClassRef>();
|
||||||
|
const report = rendered?.report;
|
||||||
|
if (report) {
|
||||||
|
for (const cls of report.localShipClass) {
|
||||||
|
map.set(`${report.race}::${cls.name}`, {
|
||||||
|
drive: cls.drive,
|
||||||
|
weapons: cls.weapons,
|
||||||
|
armament: cls.armament,
|
||||||
|
shields: cls.shields,
|
||||||
|
cargo: cls.cargo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const cls of report.otherShipClass) {
|
||||||
|
map.set(`${cls.race}::${cls.name}`, {
|
||||||
|
drive: cls.drive,
|
||||||
|
weapons: cls.weapons,
|
||||||
|
armament: cls.armament,
|
||||||
|
shields: cls.shields,
|
||||||
|
cargo: cls.cargo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new MapShipClassLookup(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = $state<
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "ready"; report: BattleReport }
|
||||||
|
| { kind: "not_found" }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
>({ kind: "loading" });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!battleId) {
|
||||||
|
state = { kind: "not_found" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = { kind: "loading" };
|
||||||
|
fetchBattle(gameId, turn, battleId)
|
||||||
|
.then((report) => {
|
||||||
|
state = { kind: "ready", report };
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (err instanceof BattleFetchError && err.status === 404) {
|
||||||
|
state = { kind: "not_found" };
|
||||||
|
} else {
|
||||||
|
state = {
|
||||||
|
kind: "error",
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function backToReport() {
|
||||||
|
goto(`/games/${gameId}/report`);
|
||||||
|
}
|
||||||
|
function backToMap() {
|
||||||
|
goto(`/games/${gameId}/map`);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="active-view" data-testid="active-view-battle" data-battle-id={battleId}>
|
<section
|
||||||
<h2>{i18n.t("game.view.battle")}</h2>
|
class="active-view"
|
||||||
<p>{i18n.t("game.shell.coming_soon")}</p>
|
data-testid="active-view-battle"
|
||||||
|
data-battle-id={battleId}
|
||||||
|
>
|
||||||
|
{#if state.kind === "loading"}
|
||||||
|
<p class="status" data-testid="battle-loading">
|
||||||
|
{i18n.t("game.battle.loading")}
|
||||||
|
</p>
|
||||||
|
{:else if state.kind === "ready"}
|
||||||
|
<BattleViewer
|
||||||
|
report={state.report}
|
||||||
|
{shipClassLookup}
|
||||||
|
onBackToMap={backToMap}
|
||||||
|
onBackToReport={backToReport}
|
||||||
|
/>
|
||||||
|
{:else if state.kind === "not_found"}
|
||||||
|
<p class="status" data-testid="battle-not-found">
|
||||||
|
{i18n.t("game.battle.not_found")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="status error" data-testid="battle-error">{state.message}</p>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.active-view {
|
.active-view {
|
||||||
padding: 1.5rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/*
|
||||||
|
* The in-game shell renders this active view inside an
|
||||||
|
* `.active-view-host` with `flex: 1; overflow-y: auto`, but
|
||||||
|
* the surrounding `.game-shell` uses `min-height: 100vh`, so
|
||||||
|
* without a hard upper bound the viewer pushes the whole
|
||||||
|
* shell past the viewport. We pin the active view to `100dvh`
|
||||||
|
* minus a small allowance for the header chrome (in-game
|
||||||
|
* Header + optional HistoryBanner ≈ 66 px on desktop) so the
|
||||||
|
* internal flex chain can split the remaining height between
|
||||||
|
* the scene, scrubber, controls and log without forcing a
|
||||||
|
* page-level scroll.
|
||||||
|
*/
|
||||||
|
height: calc(100dvh - 80px);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
|
color: #d6dcf2;
|
||||||
}
|
}
|
||||||
.active-view h2 {
|
.status {
|
||||||
margin: 0 0 0.5rem;
|
margin: 2rem auto;
|
||||||
font-size: 1.1rem;
|
max-width: 880px;
|
||||||
|
color: #93a0d0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.active-view p {
|
.status.error {
|
||||||
margin: 0;
|
color: #e08585;
|
||||||
color: #555;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ preference the store already manages.
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
createRenderer,
|
createRenderer,
|
||||||
@@ -402,13 +404,40 @@ preference the store already manages.
|
|||||||
if (selection === undefined) return;
|
if (selection === undefined) return;
|
||||||
const hit = handle.hitAt(cursorPx);
|
const hit = handle.hitAt(cursorPx);
|
||||||
if (hit === null) return;
|
if (hit === null) return;
|
||||||
if (hit.primitive.kind !== "point") return;
|
|
||||||
const target = hitLookup.get(hit.primitive.id);
|
const target = hitLookup.get(hit.primitive.id);
|
||||||
if (target === undefined) return;
|
if (target === undefined) return;
|
||||||
if (target.kind === "planet") {
|
switch (target.kind) {
|
||||||
selection.selectPlanet(target.number);
|
case "planet":
|
||||||
} else {
|
if (hit.primitive.kind !== "point") return;
|
||||||
selection.selectShipGroup(target.ref);
|
selection.selectPlanet(target.number);
|
||||||
|
break;
|
||||||
|
case "shipGroup":
|
||||||
|
if (hit.primitive.kind !== "point") return;
|
||||||
|
selection.selectShipGroup(target.ref);
|
||||||
|
break;
|
||||||
|
case "battle": {
|
||||||
|
const gameId = page.params.id ?? "";
|
||||||
|
const turn = store?.report?.turn ?? 0;
|
||||||
|
void goto(
|
||||||
|
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "bombing": {
|
||||||
|
const gameId = page.params.id ?? "";
|
||||||
|
void goto(
|
||||||
|
`/games/${gameId}/report#report-bombings`,
|
||||||
|
).then(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const row = document.querySelector(
|
||||||
|
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
||||||
|
);
|
||||||
|
if (row && row.scrollIntoView) {
|
||||||
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,181 @@
|
|||||||
<!--
|
<!--
|
||||||
Phase 10 stub for the turn-report active view. Phase 23 replaces the
|
Phase 23 turn-report active view.
|
||||||
body with the per-turn sections (cargo deliveries, completed sciences,
|
|
||||||
mail, etc.).
|
Composes the table of contents (`report/report-toc.svelte`) and the
|
||||||
|
twenty section components that render each `GameReport` array. Each
|
||||||
|
section is its own component under `lib/active-view/report/` — the
|
||||||
|
data shapes are too varied for one generic table, and the
|
||||||
|
component-per-section seam matches Phase 23's targeted-test contract.
|
||||||
|
|
||||||
|
Active-section highlighting and scroll save/restore land here:
|
||||||
|
- `IntersectionObserver` rooted on the active-view-host element
|
||||||
|
(`bind:this` in `+layout.svelte`, plumbed through
|
||||||
|
`ACTIVE_VIEW_HOST_CONTEXT_KEY`) watches every `<section
|
||||||
|
id="report-<slug>">` and updates a local `activeSlug` rune.
|
||||||
|
- The matching `+page.svelte` exports a SvelteKit `Snapshot` that
|
||||||
|
captures and restores `host.element.scrollTop`, so navigating to
|
||||||
|
/map and back lands on the same scroll position. The save lives in
|
||||||
|
`+page.svelte` because SvelteKit binds snapshots per route.
|
||||||
|
|
||||||
|
The 20-section list lives here as a single source of truth so the
|
||||||
|
TOC and the body iterate the same data.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
import ReportToc, {
|
||||||
|
type TocEntry,
|
||||||
|
} from "./report/report-toc.svelte";
|
||||||
|
import SectionGalaxySummary from "./report/section-galaxy-summary.svelte";
|
||||||
|
import SectionVotes from "./report/section-votes.svelte";
|
||||||
|
import SectionPlayerStatus from "./report/section-player-status.svelte";
|
||||||
|
import SectionMySciences from "./report/section-my-sciences.svelte";
|
||||||
|
import SectionForeignSciences from "./report/section-foreign-sciences.svelte";
|
||||||
|
import SectionMyShipClasses from "./report/section-my-ship-classes.svelte";
|
||||||
|
import SectionForeignShipClasses from "./report/section-foreign-ship-classes.svelte";
|
||||||
|
import SectionBattles from "./report/section-battles.svelte";
|
||||||
|
import SectionBombings from "./report/section-bombings.svelte";
|
||||||
|
import SectionApproachingGroups from "./report/section-approaching-groups.svelte";
|
||||||
|
import SectionMyPlanets from "./report/section-my-planets.svelte";
|
||||||
|
import SectionShipsInProduction from "./report/section-ships-in-production.svelte";
|
||||||
|
import SectionCargoRoutes from "./report/section-cargo-routes.svelte";
|
||||||
|
import SectionForeignPlanets from "./report/section-foreign-planets.svelte";
|
||||||
|
import SectionUninhabitedPlanets from "./report/section-uninhabited-planets.svelte";
|
||||||
|
import SectionUnknownPlanets from "./report/section-unknown-planets.svelte";
|
||||||
|
import SectionMyFleets from "./report/section-my-fleets.svelte";
|
||||||
|
import SectionMyShipGroups from "./report/section-my-ship-groups.svelte";
|
||||||
|
import SectionForeignShipGroups from "./report/section-foreign-ship-groups.svelte";
|
||||||
|
import SectionUnidentifiedGroups from "./report/section-unidentified-groups.svelte";
|
||||||
|
|
||||||
|
const ENTRIES: readonly TocEntry[] = [
|
||||||
|
{ slug: "galaxy-summary", titleKey: "game.report.section.galaxy_summary.title" },
|
||||||
|
{ slug: "votes", titleKey: "game.report.section.votes.title" },
|
||||||
|
{ slug: "player-status", titleKey: "game.report.section.player_status.title" },
|
||||||
|
{ slug: "my-sciences", titleKey: "game.report.section.my_sciences.title" },
|
||||||
|
{ slug: "foreign-sciences", titleKey: "game.report.section.foreign_sciences.title" },
|
||||||
|
{ slug: "my-ship-classes", titleKey: "game.report.section.my_ship_classes.title" },
|
||||||
|
{ slug: "foreign-ship-classes", titleKey: "game.report.section.foreign_ship_classes.title" },
|
||||||
|
{ slug: "battles", titleKey: "game.report.section.battles.title" },
|
||||||
|
{ slug: "bombings", titleKey: "game.report.section.bombings.title" },
|
||||||
|
{ slug: "approaching-groups", titleKey: "game.report.section.approaching_groups.title" },
|
||||||
|
{ slug: "my-planets", titleKey: "game.report.section.my_planets.title" },
|
||||||
|
{ slug: "ships-in-production", titleKey: "game.report.section.ships_in_production.title" },
|
||||||
|
{ slug: "cargo-routes", titleKey: "game.report.section.cargo_routes.title" },
|
||||||
|
{ slug: "foreign-planets", titleKey: "game.report.section.foreign_planets.title" },
|
||||||
|
{ slug: "uninhabited-planets", titleKey: "game.report.section.uninhabited_planets.title" },
|
||||||
|
{ slug: "unknown-planets", titleKey: "game.report.section.unknown_planets.title" },
|
||||||
|
{ slug: "my-fleets", titleKey: "game.report.section.my_fleets.title" },
|
||||||
|
{ slug: "my-ship-groups", titleKey: "game.report.section.my_ship_groups.title" },
|
||||||
|
{ slug: "foreign-ship-groups", titleKey: "game.report.section.foreign_ship_groups.title" },
|
||||||
|
{ slug: "unidentified-groups", titleKey: "game.report.section.unidentified_groups.title" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const gameId = $derived(page.params.id ?? "");
|
||||||
|
|
||||||
|
let activeSlug = $state<string>(ENTRIES[0]?.slug ?? "");
|
||||||
|
let bodyEl: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
// `IntersectionObserver` rooted on the viewport (`root: null`)
|
||||||
|
// lets the TOC highlight follow the section currently in the
|
||||||
|
// upper portion of the visible area. The in-game shell layout
|
||||||
|
// expands the active-view-host to fit content rather than
|
||||||
|
// constraining it, so the document body scrolls — not the host.
|
||||||
|
// Targeting the viewport with a top-skewed `rootMargin` advances
|
||||||
|
// the highlight as a section enters the upper third of what the
|
||||||
|
// reader sees, without coupling to the layout's internal sizing.
|
||||||
|
onMount(() => {
|
||||||
|
if (typeof IntersectionObserver === "undefined") return;
|
||||||
|
const body = bodyEl;
|
||||||
|
if (body === null) return;
|
||||||
|
const targets = body.querySelectorAll<HTMLElement>("section[id^='report-']");
|
||||||
|
if (targets.length === 0) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let pick: { slug: string; ratio: number } | null = null;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue;
|
||||||
|
const slug = entry.target.id.replace(/^report-/, "");
|
||||||
|
if (pick === null || entry.intersectionRatio > pick.ratio) {
|
||||||
|
pick = { slug, ratio: entry.intersectionRatio };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pick !== null) {
|
||||||
|
activeSlug = pick.slug;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null,
|
||||||
|
rootMargin: "-30% 0px -60% 0px",
|
||||||
|
threshold: [0, 0.25, 0.5, 0.75, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
targets.forEach((t) => observer.observe(t));
|
||||||
|
return () => observer.disconnect();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="active-view" data-testid="active-view-report">
|
<div class="report-view" data-testid="active-view-report">
|
||||||
<h2>{i18n.t("game.view.report")}</h2>
|
<ReportToc entries={ENTRIES} {activeSlug} {gameId} />
|
||||||
<p>{i18n.t("game.shell.coming_soon")}</p>
|
|
||||||
</section>
|
<div class="report-body" bind:this={bodyEl}>
|
||||||
|
<SectionGalaxySummary />
|
||||||
|
<SectionVotes />
|
||||||
|
<SectionPlayerStatus />
|
||||||
|
<SectionMySciences />
|
||||||
|
<SectionForeignSciences />
|
||||||
|
<SectionMyShipClasses />
|
||||||
|
<SectionForeignShipClasses />
|
||||||
|
<SectionBattles />
|
||||||
|
<SectionBombings />
|
||||||
|
<SectionApproachingGroups />
|
||||||
|
<SectionMyPlanets />
|
||||||
|
<SectionShipsInProduction />
|
||||||
|
<SectionCargoRoutes />
|
||||||
|
<SectionForeignPlanets />
|
||||||
|
<SectionUninhabitedPlanets />
|
||||||
|
<SectionUnknownPlanets />
|
||||||
|
<SectionMyFleets />
|
||||||
|
<SectionMyShipGroups />
|
||||||
|
<SectionForeignShipGroups />
|
||||||
|
<SectionUnidentifiedGroups />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.active-view {
|
.report-view {
|
||||||
padding: 1.5rem;
|
display: grid;
|
||||||
|
grid-template-columns: 14rem 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 1rem 1.25rem 2rem;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.active-view h2 {
|
.report-view > :global(.report-toc) {
|
||||||
margin: 0 0 0.5rem;
|
position: sticky;
|
||||||
font-size: 1.1rem;
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.active-view p {
|
.report-body {
|
||||||
margin: 0;
|
min-width: 0;
|
||||||
color: #555;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.report-view {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.report-view > :global(.report-toc) {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #0a0e1a;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Shared number / planet formatters for the Phase 23 Report View
|
||||||
|
// sections. Inlined in 10+ components, so factoring keeps each
|
||||||
|
// section component focused on its data shape. The formatters
|
||||||
|
// match the conventions of the per-entity tables (tabular numerals,
|
||||||
|
// one-decimal percent without a `%` suffix — the header carries the
|
||||||
|
// unit) so the report's grids read the same way as the
|
||||||
|
// table-races / table-sciences views.
|
||||||
|
|
||||||
|
import type { ReportPlanet } from "../../../api/game-state";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatPercent renders a `[0, 1]` fraction as a one-decimal
|
||||||
|
* percent (without a `%` suffix — the column header carries the
|
||||||
|
* unit). Matches the convention used by `table-races.svelte` and
|
||||||
|
* `table-sciences.svelte`.
|
||||||
|
*/
|
||||||
|
export function formatPercent(fraction: number): string {
|
||||||
|
return (fraction * 100).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatCount renders an integer-ish value (population, industry,
|
||||||
|
* planet count, …) without fractional digits and with locale-aware
|
||||||
|
* thousand separators.
|
||||||
|
*/
|
||||||
|
export function formatCount(value: number): string {
|
||||||
|
return value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatFloat renders a floating-point value with up to two
|
||||||
|
* fractional digits. Used for stockpiles, distances, cost, mass —
|
||||||
|
* everything the engine emits as a `Float` that is not a fraction.
|
||||||
|
*/
|
||||||
|
export function formatFloat(value: number): string {
|
||||||
|
return value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* formatVotes renders a vote weight with up to two decimal digits —
|
||||||
|
* mirrors the races table's column convention so the cumulative
|
||||||
|
* vote totals line up across views.
|
||||||
|
*/
|
||||||
|
export function formatVotes(value: number): string {
|
||||||
|
return value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* planetLabel renders a planet reference as `#<number> (<name>)` if
|
||||||
|
* the planet is known in the report, or just `#<number>` if the
|
||||||
|
* lookup fails (visibility lost between turns, foreign-only data).
|
||||||
|
* Sections that show planet numbers without a name column —
|
||||||
|
* Ships in Production, Bombings — rely on this resolver to keep
|
||||||
|
* cell width tight.
|
||||||
|
*/
|
||||||
|
export function planetLabel(
|
||||||
|
number: number,
|
||||||
|
planets: readonly ReportPlanet[],
|
||||||
|
): string {
|
||||||
|
const p = planets.find((row) => row.number === number);
|
||||||
|
if (p === undefined || p.name === "") return `#${number}`;
|
||||||
|
return `#${number} (${p.name})`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View table of contents.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- "Back to map" button at the top — visible on both desktop sidebar
|
||||||
|
and mobile sticky toolbar. Navigates via `$app/navigation.goto` so
|
||||||
|
active-view-host scroll restoration plays through SvelteKit's
|
||||||
|
history machinery and the layout's `mobileTool` resets naturally.
|
||||||
|
- Desktop / tablet sidebar: a vertical list of anchor links, one per
|
||||||
|
section. The active link gets `aria-current="location"` and a
|
||||||
|
`.active` style. Click scrolls the active-view-host (not the
|
||||||
|
window) by calling `scrollIntoView` on the matching section.
|
||||||
|
- Mobile (`max-width: 767.98px`): the sidebar collapses to a sticky
|
||||||
|
`<select>` at the top of the body — a minimal contract that does
|
||||||
|
not stack with the layout's bottom-tab bar. The same option list
|
||||||
|
drives both surfaces.
|
||||||
|
|
||||||
|
The active section is computed by the orchestrator
|
||||||
|
(`report.svelte`) via `IntersectionObserver` and passed in via the
|
||||||
|
`activeSlug` prop. The TOC itself owns no observers.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
|
|
||||||
|
export interface TocEntry {
|
||||||
|
slug: string;
|
||||||
|
titleKey: TranslationKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
entries: readonly TocEntry[];
|
||||||
|
activeSlug: string;
|
||||||
|
gameId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { entries, activeSlug, gameId }: Props = $props();
|
||||||
|
|
||||||
|
function scrollToSlug(slug: string): void {
|
||||||
|
const target = document.getElementById(`report-${slug}`);
|
||||||
|
if (target === null) return;
|
||||||
|
const reduced = window.matchMedia(
|
||||||
|
"(prefers-reduced-motion: reduce)",
|
||||||
|
).matches;
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: reduced ? "auto" : "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAnchorClick(event: MouseEvent, slug: string): void {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollToSlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectChange(event: Event): void {
|
||||||
|
const select = event.currentTarget as HTMLSelectElement;
|
||||||
|
const slug = select.value;
|
||||||
|
if (slug === "") return;
|
||||||
|
scrollToSlug(slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backToMap(): Promise<void> {
|
||||||
|
await goto(`/games/${gameId}/map`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="report-toc"
|
||||||
|
data-testid="report-toc"
|
||||||
|
aria-label={i18n.t("game.report.toc.title")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back-to-map"
|
||||||
|
data-testid="report-back-to-map"
|
||||||
|
onclick={() => void backToMap()}
|
||||||
|
>
|
||||||
|
← {i18n.t("game.report.back_to_map")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="desktop" aria-label={i18n.t("game.report.toc.title")}>
|
||||||
|
<ul>
|
||||||
|
{#each entries as entry (entry.slug)}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={`#report-${entry.slug}`}
|
||||||
|
class:active={activeSlug === entry.slug}
|
||||||
|
aria-current={activeSlug === entry.slug
|
||||||
|
? "location"
|
||||||
|
: undefined}
|
||||||
|
data-testid="report-toc-{entry.slug}"
|
||||||
|
onclick={(e) => onAnchorClick(e, entry.slug)}
|
||||||
|
>
|
||||||
|
{i18n.t(entry.titleKey)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<label class="mobile">
|
||||||
|
<span class="visually-hidden">
|
||||||
|
{i18n.t("game.report.toc.mobile_label")}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
data-testid="report-toc-mobile"
|
||||||
|
value={activeSlug}
|
||||||
|
onchange={onSelectChange}
|
||||||
|
>
|
||||||
|
{#each entries as entry (entry.slug)}
|
||||||
|
<option value={entry.slug}>{i18n.t(entry.titleKey)}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.report-toc {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.back-to-map {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: #11172a;
|
||||||
|
color: #cfd7ff;
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.back-to-map:hover {
|
||||||
|
background: #1a2240;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.desktop {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.desktop ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.desktop a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
color: #aab;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
border-radius: 0 3px 3px 0;
|
||||||
|
}
|
||||||
|
.desktop a:hover {
|
||||||
|
color: #e8eaf6;
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
.desktop a.active {
|
||||||
|
color: #e8eaf6;
|
||||||
|
background: #11172a;
|
||||||
|
border-left-color: #4a6cf7;
|
||||||
|
}
|
||||||
|
.mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile select {
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: #0a0e1a;
|
||||||
|
color: #e8eaf6;
|
||||||
|
border: 1px solid #2a3150;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — approaching groups section. Renders the wire
|
||||||
|
`incomingGroup[]` projection as a compact grid: origin → destination
|
||||||
|
along with distance / speed / mass. The wire field carries no
|
||||||
|
ship-class info (a true blip on radar); the player only learns the
|
||||||
|
class when the group lands and a battle roster forms.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat, planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.incomingShipGroups ?? []);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-approaching-groups"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-approaching-groups"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.approaching_groups.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="approaching-groups-empty">
|
||||||
|
{i18n.t("game.report.section.approaching_groups.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="approaching-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.approaching_groups.column.from")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.approaching_groups.column.to")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.approaching_groups.column.distance")}
|
||||||
|
</th>
|
||||||
|
<th>{i18n.t("game.report.section.approaching_groups.column.speed")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.approaching_groups.column.mass")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r, i (i)}
|
||||||
|
<tr data-testid="approaching-groups-row">
|
||||||
|
<td>{planetLabel(r.origin, planets)}</td>
|
||||||
|
<td>{planetLabel(r.destination, planets)}</td>
|
||||||
|
<td>{formatFloat(r.distance)}</td>
|
||||||
|
<td>{formatFloat(r.speed)}</td>
|
||||||
|
<td>{formatFloat(r.mass)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<!--
|
||||||
|
Phase 27 Report View — battles section. Each row is a link into the
|
||||||
|
Battle Viewer at `/games/<id>/battle/<uuid>?turn=<turn>` where
|
||||||
|
`turn` follows the current report's turn so history-mode views land
|
||||||
|
on the right battle. Phase 23 rendered the same rows as inactive
|
||||||
|
monospace `<span>`; the rewire here is the one-liner the Phase 23
|
||||||
|
decision log called out.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const battles = $derived(report?.battles ?? []);
|
||||||
|
const gameId = $derived(page.params.id ?? "");
|
||||||
|
const turn = $derived(report?.turn ?? 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-battles"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-battles"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.battles.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if battles.length === 0}
|
||||||
|
<p class="status" data-testid="battles-empty">
|
||||||
|
{i18n.t("game.report.section.battles.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="ids" data-testid="battles-list">
|
||||||
|
{#each battles as b (b.id)}
|
||||||
|
<li>
|
||||||
|
<span class="label">
|
||||||
|
{i18n.t("game.report.section.battles.id_label")}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
class="uuid"
|
||||||
|
href={`/games/${gameId}/battle/${b.id}?turn=${turn}`}
|
||||||
|
data-testid="report-battle-row"
|
||||||
|
data-id={b.id}
|
||||||
|
>{b.id}</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.ids {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.ids li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.uuid {
|
||||||
|
color: #cfd7ff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.uuid:hover {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — bombings section. One row per bombing
|
||||||
|
event; wiped planets get a visually-distinct row state plus a
|
||||||
|
"wiped" badge so the boolean is explicit for e2e assertions.
|
||||||
|
Decoder sorts by `planetNumber` already.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatCount, formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.bombings ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-bombings"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-bombings"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.bombings.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="bombings-empty">
|
||||||
|
{i18n.t("game.report.section.bombings.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="bombings-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.planet")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.owner")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.attacker")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.production")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.industry")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.population")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.colonists")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.bombings.column.industry_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.bombings.column.materials_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>{i18n.t("game.report.section.bombings.column.attack_power")}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as b (`${b.planetNumber}/${b.attacker}/${b.owner}`)}
|
||||||
|
<tr
|
||||||
|
data-testid="report-bombing-row"
|
||||||
|
data-planet={b.planetNumber}
|
||||||
|
data-wiped={b.wiped ? "true" : "false"}
|
||||||
|
class:wiped={b.wiped}
|
||||||
|
>
|
||||||
|
<td>#{b.planetNumber} ({b.planet})</td>
|
||||||
|
<td>{b.owner}</td>
|
||||||
|
<td>{b.attacker}</td>
|
||||||
|
<td>{b.production}</td>
|
||||||
|
<td>{formatFloat(b.industry)}</td>
|
||||||
|
<td>{formatFloat(b.population)}</td>
|
||||||
|
<td>{formatFloat(b.colonists)}</td>
|
||||||
|
<td>{formatFloat(b.industryStockpile)}</td>
|
||||||
|
<td>{formatFloat(b.materialsStockpile)}</td>
|
||||||
|
<td>{formatCount(b.attackPower)}</td>
|
||||||
|
<td>
|
||||||
|
{#if b.wiped}
|
||||||
|
<span
|
||||||
|
class="wiped-badge"
|
||||||
|
data-testid="report-bombing-wiped-badge"
|
||||||
|
>
|
||||||
|
{i18n.t("game.report.section.bombings.wiped")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
.wiped td {
|
||||||
|
color: #c97a7a;
|
||||||
|
}
|
||||||
|
.wiped-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: #4a1010;
|
||||||
|
color: #ffcaca;
|
||||||
|
border: 1px solid #8a3030;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — cargo routes section. The wire `routes[]`
|
||||||
|
groups by source planet; each entry inside a route is one
|
||||||
|
(loadType, destination) pair. The section flattens both to a single
|
||||||
|
table — anchor jumps into a single visual unit even when the player
|
||||||
|
has many routes.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
|
||||||
|
const rows = $derived.by(() => {
|
||||||
|
const out: {
|
||||||
|
sourcePlanetNumber: number;
|
||||||
|
loadType: string;
|
||||||
|
destinationPlanetNumber: number;
|
||||||
|
}[] = [];
|
||||||
|
for (const route of report?.routes ?? []) {
|
||||||
|
for (const entry of route.entries) {
|
||||||
|
out.push({
|
||||||
|
sourcePlanetNumber: route.sourcePlanetNumber,
|
||||||
|
loadType: entry.loadType,
|
||||||
|
destinationPlanetNumber: entry.destinationPlanetNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-cargo-routes"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-cargo-routes"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.cargo_routes.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="cargo-routes-empty">
|
||||||
|
{i18n.t("game.report.section.cargo_routes.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="cargo-routes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.cargo_routes.column.source")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.cargo_routes.column.load")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.cargo_routes.column.destination")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (`${r.sourcePlanetNumber}/${r.loadType}`)}
|
||||||
|
<tr
|
||||||
|
data-testid="cargo-routes-row"
|
||||||
|
data-source={r.sourcePlanetNumber}
|
||||||
|
data-load={r.loadType}
|
||||||
|
>
|
||||||
|
<td>{planetLabel(r.sourcePlanetNumber, planets)}</td>
|
||||||
|
<td>{r.loadType}</td>
|
||||||
|
<td>{planetLabel(r.destinationPlanetNumber, planets)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — foreign planets section. Filters `planets[]`
|
||||||
|
to the `kind === "other"` entries and renders the same column set
|
||||||
|
as the local planets table plus an `owner` column.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(
|
||||||
|
(report?.planets ?? []).filter((p) => p.kind === "other"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-foreign-planets"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-foreign-planets"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.foreign_planets.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="foreign-planets-empty">
|
||||||
|
{i18n.t("game.report.section.foreign_planets.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="foreign-planets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.foreign_planets.column.owner")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as p (p.number)}
|
||||||
|
<tr data-testid="foreign-planets-row" data-number={p.number}>
|
||||||
|
<td>{p.number}</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>{p.owner ?? ""}</td>
|
||||||
|
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
|
||||||
|
<td>{formatFloat(p.size ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.resources ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.population ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.industry ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.colonists ?? 0)}</td>
|
||||||
|
<td>{p.production ?? "—"}</td>
|
||||||
|
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — foreign sciences section. Renders one
|
||||||
|
sub-table per race, mirroring the legacy "<Race> Sciences" layout.
|
||||||
|
Sorted alphabetically by race name (the decoder already produces
|
||||||
|
the (race, name) order); the sub-table groups are built here so
|
||||||
|
that anchor navigation to the section lands on a single visual
|
||||||
|
unit even when the section spans many races.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import type { ReportOtherScience } from "../../../api/game-state";
|
||||||
|
import { formatPercent } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.otherScience ?? []);
|
||||||
|
|
||||||
|
// Decoder already sorts by (race, name); a simple linear walk
|
||||||
|
// builds an array of {race, rows[]} groups.
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
const out: { race: string; entries: ReportOtherScience[] }[] = [];
|
||||||
|
let current: { race: string; entries: ReportOtherScience[] } | null = null;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (current === null || current.race !== row.race) {
|
||||||
|
current = { race: row.race, entries: [] };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.entries.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-foreign-sciences"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-foreign-sciences"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.foreign_sciences.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if grouped.length === 0}
|
||||||
|
<p class="status" data-testid="foreign-sciences-empty">
|
||||||
|
{i18n.t("game.report.section.foreign_sciences.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group (group.race)}
|
||||||
|
<h3
|
||||||
|
class="race-header"
|
||||||
|
data-testid="report-other-science-race"
|
||||||
|
data-race={group.race}
|
||||||
|
>
|
||||||
|
{i18n.t("game.report.section.foreign_sciences.race_header", {
|
||||||
|
race: group.race,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<table class="grid" data-testid="foreign-sciences-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each group.entries as r (`${r.race}/${r.name}`)}
|
||||||
|
<tr
|
||||||
|
data-testid="foreign-sciences-row"
|
||||||
|
data-race={r.race}
|
||||||
|
data-name={r.name}
|
||||||
|
>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatPercent(r.drive)}</td>
|
||||||
|
<td>{formatPercent(r.weapons)}</td>
|
||||||
|
<td>{formatPercent(r.shields)}</td>
|
||||||
|
<td>{formatPercent(r.cargo)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.race-header {
|
||||||
|
margin: 0.75rem 0 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — foreign ship classes section. One sub-table
|
||||||
|
per race (decoder sorts `(race, name)`); columns extend the local
|
||||||
|
ship-class layout with `mass`, which is exposed on the wire's
|
||||||
|
`OthersShipClass` and useful for fleet-mass comparison against
|
||||||
|
incoming groups.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import type { ReportOtherShipClass } from "../../../api/game-state";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.otherShipClass ?? []);
|
||||||
|
|
||||||
|
const grouped = $derived.by(() => {
|
||||||
|
const out: { race: string; entries: ReportOtherShipClass[] }[] = [];
|
||||||
|
let current: { race: string; entries: ReportOtherShipClass[] } | null =
|
||||||
|
null;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (current === null || current.race !== row.race) {
|
||||||
|
current = { race: row.race, entries: [] };
|
||||||
|
out.push(current);
|
||||||
|
}
|
||||||
|
current.entries.push(row);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-foreign-ship-classes"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-foreign-ship-classes"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.foreign_ship_classes.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if grouped.length === 0}
|
||||||
|
<p class="status" data-testid="foreign-ship-classes-empty">
|
||||||
|
{i18n.t("game.report.section.foreign_ship_classes.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group (group.race)}
|
||||||
|
<h3
|
||||||
|
class="race-header"
|
||||||
|
data-testid="report-other-ship-class-race"
|
||||||
|
data-race={group.race}
|
||||||
|
>
|
||||||
|
{i18n.t("game.report.section.foreign_ship_classes.race_header", {
|
||||||
|
race: group.race,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<table class="grid" data-testid="foreign-ship-classes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.foreign_ship_classes.column.mass")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each group.entries as r (`${r.race}/${r.name}`)}
|
||||||
|
<tr
|
||||||
|
data-testid="foreign-ship-classes-row"
|
||||||
|
data-race={r.race}
|
||||||
|
data-name={r.name}
|
||||||
|
>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatFloat(r.drive)}</td>
|
||||||
|
<td>{r.armament}</td>
|
||||||
|
<td>{formatFloat(r.weapons)}</td>
|
||||||
|
<td>{formatFloat(r.shields)}</td>
|
||||||
|
<td>{formatFloat(r.cargo)}</td>
|
||||||
|
<td>{formatFloat(r.mass)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.race-header {
|
||||||
|
margin: 0.75rem 0 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — foreign ship groups section. `otherShipGroups[]`
|
||||||
|
omits the local-only fields (id, state, fleet) — those don't apply
|
||||||
|
to groups the player doesn't own.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat, planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.otherShipGroups ?? []);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
|
||||||
|
function cargoCell(cargo: string, load: number): string {
|
||||||
|
if (cargo === "NONE") return "—";
|
||||||
|
return `${cargo} (${formatFloat(load)})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-foreign-ship-groups"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-foreign-ship-groups"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.foreign_ship_groups.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="foreign-ship-groups-empty">
|
||||||
|
{i18n.t("game.report.section.foreign_ship_groups.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="foreign-ship-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as g, i (i)}
|
||||||
|
<tr data-testid="foreign-ship-groups-row">
|
||||||
|
<td>{g.class}</td>
|
||||||
|
<td>{g.count}</td>
|
||||||
|
<td>{cargoCell(g.cargo, g.load)}</td>
|
||||||
|
<td>{planetLabel(g.destination, planets)}</td>
|
||||||
|
<td>
|
||||||
|
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
|
||||||
|
</td>
|
||||||
|
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
|
||||||
|
<td>{formatFloat(g.speed)}</td>
|
||||||
|
<td>{formatFloat(g.mass)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — galaxy summary section. Renders the per-turn
|
||||||
|
header data (turn, map dimensions, planet count, calling race name)
|
||||||
|
as a definition-list. The data lives on `GameReport` directly; the
|
||||||
|
section is never empty as long as the report has loaded.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-galaxy-summary"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-galaxy-summary"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.galaxy_summary.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else}
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>{i18n.t("game.report.section.galaxy_summary.field.turn")}</dt>
|
||||||
|
<dd data-testid="galaxy-summary-field-turn">{report.turn}</dd>
|
||||||
|
<dt>{i18n.t("game.report.section.galaxy_summary.field.size")}</dt>
|
||||||
|
<dd data-testid="galaxy-summary-field-size">
|
||||||
|
{report.mapWidth} × {report.mapHeight}
|
||||||
|
</dd>
|
||||||
|
<dt>{i18n.t("game.report.section.galaxy_summary.field.planets")}</dt>
|
||||||
|
<dd data-testid="galaxy-summary-field-planets">{report.planetCount}</dd>
|
||||||
|
<dt>{i18n.t("game.report.section.galaxy_summary.field.race")}</dt>
|
||||||
|
<dd data-testid="galaxy-summary-field-race">{report.race}</dd>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.kv dt {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #e8eaf6;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — my fleets section. Renders `localFleets[]`
|
||||||
|
with the wire fields. `origin` and `range` are nullable (a fleet
|
||||||
|
in orbit has neither); empty cells in those columns are normal.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat, planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.localFleets ?? []);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-my-fleets"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-my-fleets"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.my_fleets.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="my-fleets-empty">
|
||||||
|
{i18n.t("game.report.section.my_fleets.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="my-fleets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.groups")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.state")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.destination")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.origin")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.range")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_fleets.column.speed")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as f (f.name)}
|
||||||
|
<tr data-testid="my-fleets-row" data-name={f.name}>
|
||||||
|
<td>{f.name}</td>
|
||||||
|
<td>{f.groupCount}</td>
|
||||||
|
<td>{f.state}</td>
|
||||||
|
<td>{planetLabel(f.destination, planets)}</td>
|
||||||
|
<td>
|
||||||
|
{f.origin === null ? "—" : planetLabel(f.origin, planets)}
|
||||||
|
</td>
|
||||||
|
<td>{f.range === null ? "—" : formatFloat(f.range)}</td>
|
||||||
|
<td>{formatFloat(f.speed)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — my planets section. Filters `planets[]` to
|
||||||
|
the `kind === "local"` entries and renders the full local-planet
|
||||||
|
column set (matches `ReportPlanet` shape).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(
|
||||||
|
(report?.planets ?? []).filter((p) => p.kind === "local"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-my-planets"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-my-planets"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.my_planets.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="my-planets-empty">
|
||||||
|
{i18n.t("game.report.section.my_planets.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="my-planets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.population")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.industry")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.colonists")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.production")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.free_industry")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as p (p.number)}
|
||||||
|
<tr data-testid="my-planets-row" data-number={p.number}>
|
||||||
|
<td>{p.number}</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
|
||||||
|
<td>{formatFloat(p.size ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.resources ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.population ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.industry ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.colonists ?? 0)}</td>
|
||||||
|
<td>{p.production ?? "—"}</td>
|
||||||
|
<td>{formatFloat(p.freeIndustry ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — my sciences section. Reads `localScience[]`
|
||||||
|
from the overlay-applied report (which means pending CreateScience
|
||||||
|
/ RemoveScience drafts surface here just like on the sciences
|
||||||
|
table).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatPercent } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.localScience ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-my-sciences"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-my-sciences"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.my_sciences.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="my-sciences-empty">
|
||||||
|
{i18n.t("game.report.section.my_sciences.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="my-sciences-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.drive")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.weapons")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.shields")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_sciences.column.cargo")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (r.name)}
|
||||||
|
<tr data-testid="my-sciences-row" data-name={r.name}>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatPercent(r.drive)}</td>
|
||||||
|
<td>{formatPercent(r.weapons)}</td>
|
||||||
|
<td>{formatPercent(r.shields)}</td>
|
||||||
|
<td>{formatPercent(r.cargo)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — my ship classes section. Mirrors the
|
||||||
|
sciences section's layout for `localShipClass[]`, with the
|
||||||
|
ship-class numeric columns (drive / armament / weapons / shields /
|
||||||
|
cargo). The overlay-applied report surfaces pending create/remove
|
||||||
|
drafts immediately, matching the ship-class designer's behaviour.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.localShipClass ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-my-ship-classes"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-my-ship-classes"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.my_ship_classes.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="my-ship-classes-empty">
|
||||||
|
{i18n.t("game.report.section.my_ship_classes.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="my-ship-classes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.drive")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.armament")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.weapons")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.shields")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_classes.column.cargo")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (r.name)}
|
||||||
|
<tr data-testid="my-ship-classes-row" data-name={r.name}>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatFloat(r.drive)}</td>
|
||||||
|
<td>{r.armament}</td>
|
||||||
|
<td>{formatFloat(r.weapons)}</td>
|
||||||
|
<td>{formatFloat(r.shields)}</td>
|
||||||
|
<td>{formatFloat(r.cargo)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — my ship groups section. Renders the local
|
||||||
|
ship groups with a short-form id (first 8 hex chars; the full UUID
|
||||||
|
is in `data-id` for tests and copy-paste lookups). `cargo` is
|
||||||
|
shown together with `load` when carrying.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat, planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.localShipGroups ?? []);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cargoCell(
|
||||||
|
cargo: string,
|
||||||
|
load: number,
|
||||||
|
): string {
|
||||||
|
if (cargo === "NONE") return "—";
|
||||||
|
return `${cargo} (${formatFloat(load)})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-my-ship-groups"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-my-ship-groups"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.my_ship_groups.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="my-ship-groups-empty">
|
||||||
|
{i18n.t("game.report.section.my_ship_groups.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="my-ship-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.id")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.class")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.count")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.cargo")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.state")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.destination")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.origin")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.range")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.speed")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.mass")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_ship_groups.column.fleet")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as g (g.id)}
|
||||||
|
<tr data-testid="my-ship-groups-row" data-id={g.id}>
|
||||||
|
<td><span class="uuid">{shortId(g.id)}</span></td>
|
||||||
|
<td>{g.class}</td>
|
||||||
|
<td>{g.count}</td>
|
||||||
|
<td>{cargoCell(g.cargo, g.load)}</td>
|
||||||
|
<td>{g.state}</td>
|
||||||
|
<td>{planetLabel(g.destination, planets)}</td>
|
||||||
|
<td>
|
||||||
|
{g.origin === null ? "—" : planetLabel(g.origin, planets)}
|
||||||
|
</td>
|
||||||
|
<td>{g.range === null ? "—" : formatFloat(g.range)}</td>
|
||||||
|
<td>{formatFloat(g.speed)}</td>
|
||||||
|
<td>{formatFloat(g.mass)}</td>
|
||||||
|
<td>{g.fleet ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
.uuid {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
color: #cfd7ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — player status section. Mirrors the legacy
|
||||||
|
"Status of Players" table: every named row in the FBS player block,
|
||||||
|
local player included, extinct rows marked with the RIP suffix.
|
||||||
|
Rows are sorted alphabetically (case-insensitive) by the decoder.
|
||||||
|
The local player's row gets a "(you)" marker and a visual
|
||||||
|
highlight so the user can locate themselves quickly.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatCount, formatPercent, formatVotes } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const players = $derived(report?.players ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-player-status"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-player-status"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.player_status.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="player-status-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.drive")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.weapons")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.shields")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.cargo")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.population")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.industry")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.planets")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.player_status.column.votes")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each players as p (p.name)}
|
||||||
|
<tr
|
||||||
|
data-testid="player-status-row"
|
||||||
|
data-name={p.name}
|
||||||
|
data-local={p.isLocal ? "true" : "false"}
|
||||||
|
data-extinct={p.extinct ? "true" : "false"}
|
||||||
|
class:local={p.isLocal}
|
||||||
|
class:extinct={p.extinct}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span>{p.name}</span>
|
||||||
|
{#if p.isLocal}
|
||||||
|
<span class="marker local-marker">
|
||||||
|
({i18n.t("game.report.section.player_status.local_marker")})
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if p.extinct}
|
||||||
|
<span
|
||||||
|
class="marker extinct-marker"
|
||||||
|
data-testid="player-status-extinct-marker"
|
||||||
|
>
|
||||||
|
{i18n.t("game.report.section.player_status.extinct_marker")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>{formatPercent(p.drive)}</td>
|
||||||
|
<td>{formatPercent(p.weapons)}</td>
|
||||||
|
<td>{formatPercent(p.shields)}</td>
|
||||||
|
<td>{formatPercent(p.cargo)}</td>
|
||||||
|
<td>{formatCount(p.population)}</td>
|
||||||
|
<td>{formatCount(p.industry)}</td>
|
||||||
|
<td>{formatCount(p.planets)}</td>
|
||||||
|
<td>{formatVotes(p.votesReceived)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
.local td {
|
||||||
|
background: #11203d;
|
||||||
|
}
|
||||||
|
.extinct td {
|
||||||
|
color: #889;
|
||||||
|
}
|
||||||
|
.marker {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aab;
|
||||||
|
}
|
||||||
|
.extinct-marker {
|
||||||
|
color: #c97a7a;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — ships in production section. Sort follows
|
||||||
|
the decoder: `(planetNumber, class)` for a stable "find planet N"
|
||||||
|
scan. The planet name is resolved against `planets[]` so the row
|
||||||
|
reads `#17 (Castle)` rather than just `#17`.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat, planetLabel } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.shipProductions ?? []);
|
||||||
|
const planets = $derived(report?.planets ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-ships-in-production"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-ships-in-production"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.ships_in_production.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="ships-in-production-empty">
|
||||||
|
{i18n.t("game.report.section.ships_in_production.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="ships-in-production-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.ships_in_production.column.planet")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.ships_in_production.column.class")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.ships_in_production.column.cost")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.ships_in_production.column.prod_used")}
|
||||||
|
</th>
|
||||||
|
<th>{i18n.t("game.report.section.ships_in_production.column.percent")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.ships_in_production.column.free")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as r (`${r.planetNumber}/${r.class}`)}
|
||||||
|
<tr
|
||||||
|
data-testid="ships-in-production-row"
|
||||||
|
data-planet={r.planetNumber}
|
||||||
|
data-class={r.class}
|
||||||
|
>
|
||||||
|
<td>{planetLabel(r.planetNumber, planets)}</td>
|
||||||
|
<td>{r.class}</td>
|
||||||
|
<td>{formatFloat(r.cost)}</td>
|
||||||
|
<td>{formatFloat(r.prodUsed)}</td>
|
||||||
|
<td>{(r.percent * 100).toFixed(1)}</td>
|
||||||
|
<td>{formatFloat(r.freeIndustry)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — unidentified groups section. The wire's
|
||||||
|
`UnidentifiedGroup` carries only absolute coordinates — a blip on
|
||||||
|
radar that doesn't even resolve to a planet.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(report?.unidentifiedShipGroups ?? []);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-unidentified-groups"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-unidentified-groups"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.unidentified_groups.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="unidentified-groups-empty">
|
||||||
|
{i18n.t("game.report.section.unidentified_groups.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="unidentified-groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.unidentified_groups.column.x")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.unidentified_groups.column.y")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as g, i (i)}
|
||||||
|
<tr data-testid="unidentified-groups-row">
|
||||||
|
<td>{formatFloat(g.x)}</td>
|
||||||
|
<td>{formatFloat(g.y)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — uninhabited planets section. The wire's
|
||||||
|
`UninhabitedPlanet` carries number / coordinates / size / resources /
|
||||||
|
stockpiles, but no production / population / industry — those columns
|
||||||
|
are intentionally omitted.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(
|
||||||
|
(report?.planets ?? []).filter((p) => p.kind === "uninhabited"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-uninhabited-planets"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-uninhabited-planets"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.uninhabited_planets.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="uninhabited-planets-empty">
|
||||||
|
{i18n.t("game.report.section.uninhabited_planets.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="uninhabited-planets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.name")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.size")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.resources")}</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.industry_stockpile")}
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
{i18n.t("game.report.section.my_planets.column.materials_stockpile")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as p (p.number)}
|
||||||
|
<tr data-testid="uninhabited-planets-row" data-number={p.number}>
|
||||||
|
<td>{p.number}</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
|
||||||
|
<td>{formatFloat(p.size ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.resources ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.industryStockpile ?? 0)}</td>
|
||||||
|
<td>{formatFloat(p.materialsStockpile ?? 0)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — unknown planets section. The wire's
|
||||||
|
`UnidentifiedPlanet` carries only coordinates and number; nothing
|
||||||
|
else is known.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatFloat } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const rows = $derived(
|
||||||
|
(report?.planets ?? []).filter((p) => p.kind === "unidentified"),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-unknown-planets"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-unknown-planets"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.unknown_planets.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<p class="status" data-testid="unknown-planets-empty">
|
||||||
|
{i18n.t("game.report.section.unknown_planets.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="unknown-planets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.number")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.my_planets.column.coordinates")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as p (p.number)}
|
||||||
|
<tr data-testid="unknown-planets-row" data-number={p.number}>
|
||||||
|
<td>{p.number}</td>
|
||||||
|
<td>{formatFloat(p.x)}, {formatFloat(p.y)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.grid tbody tr:hover {
|
||||||
|
background: #11172a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<!--
|
||||||
|
Phase 23 Report View — votes section. Surfaces the local player's
|
||||||
|
total vote weight (`myVotes`), the recipient they cast their vote
|
||||||
|
for (`myVoteFor`), and the per-other-race table of votes received
|
||||||
|
in the last tally. The full vote graph is not reconstructable from
|
||||||
|
the client side because each race's outgoing vote target is
|
||||||
|
private; the section shows only the public datums and mirrors the
|
||||||
|
explanatory text on the races table.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
type RenderedReportSource,
|
||||||
|
} from "$lib/rendered-report.svelte";
|
||||||
|
import { formatVotes } from "./format";
|
||||||
|
|
||||||
|
const rendered = getContext<RenderedReportSource | undefined>(
|
||||||
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
const report = $derived(rendered?.report ?? null);
|
||||||
|
const races = $derived(report?.races ?? []);
|
||||||
|
const empty = $derived(report !== null && races.length === 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
id="report-votes"
|
||||||
|
class="grid-section"
|
||||||
|
data-testid="report-section-votes"
|
||||||
|
>
|
||||||
|
<h2>{i18n.t("game.report.section.votes.title")}</h2>
|
||||||
|
|
||||||
|
{#if report === null}
|
||||||
|
<p class="status">{i18n.t("game.report.loading")}</p>
|
||||||
|
{:else}
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>{i18n.t("game.report.section.votes.mine")}</dt>
|
||||||
|
<dd data-testid="votes-mine">{formatVotes(report.myVotes)}</dd>
|
||||||
|
<dt>{i18n.t("game.report.section.votes.target")}</dt>
|
||||||
|
<dd data-testid="votes-target">
|
||||||
|
{#if report.myVoteFor === ""}
|
||||||
|
{i18n.t("game.report.section.votes.target_none")}
|
||||||
|
{:else}
|
||||||
|
{report.myVoteFor}
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{#if empty}
|
||||||
|
<p class="status" data-testid="votes-empty">
|
||||||
|
{i18n.t("game.report.section.votes.empty")}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<h3>{i18n.t("game.report.section.votes.received_header")}</h3>
|
||||||
|
<table class="grid" data-testid="votes-received-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{i18n.t("game.report.section.votes.column.race")}</th>
|
||||||
|
<th>{i18n.t("game.report.section.votes.column.votes")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each races as r (r.name)}
|
||||||
|
<tr data-testid="votes-received-row" data-race={r.name}>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{formatVotes(r.votesReceived)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.grid-section h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e8eaf6;
|
||||||
|
}
|
||||||
|
.grid-section h3 {
|
||||||
|
margin: 1rem 0 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.kv {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: 0.3rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.kv dt {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.kv dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #e8eaf6;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.grid th,
|
||||||
|
.grid td {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.grid th {
|
||||||
|
color: #aab;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
<!--
|
||||||
|
BattleScene — radial SVG visualisation of one battle frame.
|
||||||
|
|
||||||
|
Layout: planet at the centre, race anchors equally spaced on an
|
||||||
|
outer ring, each race rendered as a *cloud* of class circles
|
||||||
|
arranged on a Vogel sunflower spiral. Spiral positions are
|
||||||
|
reassigned per rank by their inward distance toward the planet so
|
||||||
|
the rank-0 bucket (the bucket with the largest initial ship count)
|
||||||
|
always sits at the most-inward Vogel slot.
|
||||||
|
|
||||||
|
Tech-variant groups of the same `(race, className)` collapse to one
|
||||||
|
visual node — per-tech detail lives in Reports. Each circle's
|
||||||
|
radius scales with the per-ship FullMass (sqrt) so heavy ships
|
||||||
|
visually dominate. Order, position, radius and mass are locked at
|
||||||
|
battle start; only NumberLeft (the label number) and per-bucket
|
||||||
|
visibility change per frame. Empty buckets are hidden so the
|
||||||
|
remaining ones keep their original spots without reshuffling.
|
||||||
|
|
||||||
|
Observer groups (`inBattle === false`) are filtered out by
|
||||||
|
`buildFrames`. Same-race opponents are forbidden by the engine's
|
||||||
|
combat filter, so a shot never collapses to a single visual node.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
|
import {
|
||||||
|
CORE_CONTEXT_KEY,
|
||||||
|
type CoreHandle,
|
||||||
|
} from "$lib/core-context.svelte";
|
||||||
|
import { layoutRaces } from "./radial-layout";
|
||||||
|
import {
|
||||||
|
computeBattleGroupMass,
|
||||||
|
radiusForMass,
|
||||||
|
MAX_RADIUS,
|
||||||
|
type ShipClassLookup,
|
||||||
|
} from "./mass";
|
||||||
|
import {
|
||||||
|
buildGroupRaceMap,
|
||||||
|
normaliseGroups,
|
||||||
|
type Frame,
|
||||||
|
} from "./timeline";
|
||||||
|
|
||||||
|
let {
|
||||||
|
report,
|
||||||
|
frame,
|
||||||
|
shipClassLookup,
|
||||||
|
shotVisible = true,
|
||||||
|
}: {
|
||||||
|
report: BattleReport;
|
||||||
|
frame: Frame;
|
||||||
|
shipClassLookup?: ShipClassLookup;
|
||||||
|
shotVisible?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const VIEW_BOX = 800;
|
||||||
|
const CENTER = { x: VIEW_BOX / 2, y: VIEW_BOX / 2 };
|
||||||
|
const PLANET_RADIUS = 60;
|
||||||
|
const RACE_RING_RADIUS = 280;
|
||||||
|
const BASE_STEP = 1.8 * MAX_RADIUS;
|
||||||
|
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
|
||||||
|
const MAX_CLUSTER_RADIUS = 0.6 * (RACE_RING_RADIUS - PLANET_RADIUS);
|
||||||
|
const LABEL_MIN_Y = 24;
|
||||||
|
|
||||||
|
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||||
|
|
||||||
|
const groupRace = $derived(buildGroupRaceMap(report.protocol));
|
||||||
|
const allGroups = $derived(normaliseGroups(report));
|
||||||
|
|
||||||
|
type StaticBucket = {
|
||||||
|
bucketKey: string;
|
||||||
|
className: string;
|
||||||
|
race: string;
|
||||||
|
raceId: number;
|
||||||
|
groupKeys: number[];
|
||||||
|
initialNum: number;
|
||||||
|
mass: number;
|
||||||
|
radius: number;
|
||||||
|
// Local offsets in the cluster's (u, v) basis. `u` always
|
||||||
|
// points from the race anchor toward the planet, so a
|
||||||
|
// constant local-frame layout produces the same "inward" feel
|
||||||
|
// regardless of which slot on the outer ring the race
|
||||||
|
// currently occupies (races rotate when peers die).
|
||||||
|
offsetU: number;
|
||||||
|
offsetV: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// staticBucketsByRace locks the bucket roster, ordering, masses,
|
||||||
|
// radii and local positions for the lifetime of this viewer. The
|
||||||
|
// derivation only re-runs when `report` or the wasm `core` flip
|
||||||
|
// (initial mount and core boot completion). Per-frame NumberLeft
|
||||||
|
// changes do not touch this map — they live in `renderedByRace`.
|
||||||
|
const staticBucketsByRace = $derived.by(() => {
|
||||||
|
const core = coreHandle?.core ?? null;
|
||||||
|
const out = new Map<number, StaticBucket[]>();
|
||||||
|
const bucketIndex = new Map<string, StaticBucket>();
|
||||||
|
for (const g of allGroups) {
|
||||||
|
const bucketKey = `${g.raceId}::${g.group.className}`;
|
||||||
|
let bucket = bucketIndex.get(bucketKey);
|
||||||
|
if (bucket === undefined) {
|
||||||
|
const classDef =
|
||||||
|
shipClassLookup?.get(g.group.race, g.group.className) ?? null;
|
||||||
|
const mass = core
|
||||||
|
? computeBattleGroupMass(g.group, classDef, core)
|
||||||
|
: 0;
|
||||||
|
bucket = {
|
||||||
|
bucketKey,
|
||||||
|
className: g.group.className,
|
||||||
|
race: g.group.race,
|
||||||
|
raceId: g.raceId,
|
||||||
|
groupKeys: [],
|
||||||
|
initialNum: 0,
|
||||||
|
mass,
|
||||||
|
radius: MAX_RADIUS,
|
||||||
|
offsetU: 0,
|
||||||
|
offsetV: 0,
|
||||||
|
};
|
||||||
|
bucketIndex.set(bucketKey, bucket);
|
||||||
|
const list = out.get(g.raceId) ?? [];
|
||||||
|
list.push(bucket);
|
||||||
|
out.set(g.raceId, list);
|
||||||
|
}
|
||||||
|
bucket.groupKeys.push(g.key);
|
||||||
|
bucket.initialNum += g.group.num;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-battle mass normalisation: the heaviest bucket renders
|
||||||
|
// at MAX_RADIUS; lighter ones scale by sqrt(m/max).
|
||||||
|
let maxMass = 0;
|
||||||
|
for (const bucket of bucketIndex.values()) {
|
||||||
|
if (bucket.mass > maxMass) maxMass = bucket.mass;
|
||||||
|
}
|
||||||
|
for (const bucket of bucketIndex.values()) {
|
||||||
|
bucket.radius = radiusForMass(bucket.mass, maxMass);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each race's buckets by initial count (descending) +
|
||||||
|
// className as a stable tie-break, then assign Vogel positions
|
||||||
|
// reordered by inward dot product (offsetU desc) so the
|
||||||
|
// largest-by-num bucket lands at the most-inward Vogel slot.
|
||||||
|
for (const list of out.values()) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (b.initialNum !== a.initialNum) return b.initialNum - a.initialNum;
|
||||||
|
return a.className.localeCompare(b.className);
|
||||||
|
});
|
||||||
|
const N = list.length;
|
||||||
|
const denom = Math.max(1, Math.sqrt(Math.max(N, 1)));
|
||||||
|
const step = Math.min(BASE_STEP, MAX_CLUSTER_RADIUS / denom);
|
||||||
|
const positions = Array.from({ length: N }, (_, r) => {
|
||||||
|
const radius = step * Math.sqrt(r);
|
||||||
|
const angle = r * GOLDEN_ANGLE;
|
||||||
|
return {
|
||||||
|
offsetU: radius * Math.cos(angle),
|
||||||
|
offsetV: radius * Math.sin(angle),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
positions.sort((a, b) => {
|
||||||
|
if (b.offsetU !== a.offsetU) return b.offsetU - a.offsetU;
|
||||||
|
return a.offsetV - b.offsetV;
|
||||||
|
});
|
||||||
|
for (let r = 0; r < N; r++) {
|
||||||
|
list[r].offsetU = positions[r].offsetU;
|
||||||
|
list[r].offsetV = positions[r].offsetV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
type RenderedBucket = StaticBucket & { numLeft: number };
|
||||||
|
|
||||||
|
// renderedByRace overlays the per-frame `remaining` map onto the
|
||||||
|
// static cluster: only buckets with `numLeft > 0` survive into
|
||||||
|
// the render list, so an emptied class disappears from the cloud
|
||||||
|
// while its neighbours keep their slots.
|
||||||
|
const renderedByRace = $derived.by(() => {
|
||||||
|
const out = new Map<number, RenderedBucket[]>();
|
||||||
|
for (const [raceId, list] of staticBucketsByRace) {
|
||||||
|
const filtered: RenderedBucket[] = [];
|
||||||
|
for (const bucket of list) {
|
||||||
|
let numLeft = 0;
|
||||||
|
for (const key of bucket.groupKeys) {
|
||||||
|
numLeft += frame.remaining.get(key) ?? 0;
|
||||||
|
}
|
||||||
|
if (numLeft > 0) filtered.push({ ...bucket, numLeft });
|
||||||
|
}
|
||||||
|
if (filtered.length > 0) out.set(raceId, filtered);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// visibleBucketByGroupKey lets shot endpoints resolve to a node
|
||||||
|
// only when the bucket is currently rendered. A phantom shot
|
||||||
|
// against an already-empty bucket therefore returns `null` and
|
||||||
|
// no line is drawn.
|
||||||
|
const visibleBucketByGroupKey = $derived.by(() => {
|
||||||
|
const out = new Map<number, RenderedBucket>();
|
||||||
|
for (const list of renderedByRace.values()) {
|
||||||
|
for (const bucket of list) {
|
||||||
|
for (const key of bucket.groupKeys) {
|
||||||
|
out.set(key, bucket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const raceLayout = $derived(
|
||||||
|
layoutRaces(frame.activeRaceIds, {
|
||||||
|
center: CENTER,
|
||||||
|
radius: RACE_RING_RADIUS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
type ClusterBasis = {
|
||||||
|
anchorX: number;
|
||||||
|
anchorY: number;
|
||||||
|
ux: number;
|
||||||
|
uy: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clusterBasisById = $derived.by(() => {
|
||||||
|
const out = new Map<number, ClusterBasis>();
|
||||||
|
for (const anchor of raceLayout) {
|
||||||
|
const dx = CENTER.x - anchor.x;
|
||||||
|
const dy = CENTER.y - anchor.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const vx = uy;
|
||||||
|
const vy = -ux;
|
||||||
|
out.set(anchor.raceId, {
|
||||||
|
anchorX: anchor.x,
|
||||||
|
anchorY: anchor.y,
|
||||||
|
ux,
|
||||||
|
uy,
|
||||||
|
vx,
|
||||||
|
vy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function worldPosition(basis: ClusterBasis, bucket: StaticBucket) {
|
||||||
|
return {
|
||||||
|
x: basis.anchorX + bucket.offsetU * basis.ux + bucket.offsetV * basis.vx,
|
||||||
|
y: basis.anchorY + bucket.offsetU * basis.uy + bucket.offsetV * basis.vy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClassCircleCenter(groupKey: number) {
|
||||||
|
const bucket = visibleBucketByGroupKey.get(groupKey);
|
||||||
|
if (!bucket) return null;
|
||||||
|
const basis = clusterBasisById.get(bucket.raceId);
|
||||||
|
if (!basis) return null;
|
||||||
|
return worldPosition(basis, bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shotLine = $derived.by(() => {
|
||||||
|
const action = frame.lastAction;
|
||||||
|
if (!action) return null;
|
||||||
|
const from = findClassCircleCenter(action.sa);
|
||||||
|
const to = findClassCircleCenter(action.sd);
|
||||||
|
if (!from || !to) return null;
|
||||||
|
return { from, to, destroyed: action.x, defenderKey: action.sd };
|
||||||
|
});
|
||||||
|
|
||||||
|
const flashDefenderBucketKey = $derived.by(() => {
|
||||||
|
if (!shotLine || !shotVisible) return null;
|
||||||
|
const bucket = visibleBucketByGroupKey.get(shotLine.defenderKey);
|
||||||
|
return bucket?.bucketKey ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const raceLabelById = $derived.by(() => {
|
||||||
|
const out = new Map<number, string>();
|
||||||
|
for (const g of allGroups) {
|
||||||
|
out.set(g.raceId, g.group.race);
|
||||||
|
}
|
||||||
|
for (const [, raceId] of groupRace) {
|
||||||
|
if (!out.has(raceId)) out.set(raceId, `race ${raceId}`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// raceLabelYById finds a y just above the visible cluster's top
|
||||||
|
// edge and clamps it to the SVG viewport so the north race
|
||||||
|
// (anchor near the top) never has its label clipped off-canvas.
|
||||||
|
const raceLabelYById = $derived.by(() => {
|
||||||
|
const out = new Map<number, number>();
|
||||||
|
for (const [raceId, list] of renderedByRace) {
|
||||||
|
const basis = clusterBasisById.get(raceId);
|
||||||
|
if (!basis || list.length === 0) continue;
|
||||||
|
let topY = basis.anchorY;
|
||||||
|
for (const bucket of list) {
|
||||||
|
const world = worldPosition(basis, bucket);
|
||||||
|
const top = world.y - bucket.radius;
|
||||||
|
if (top < topY) topY = top;
|
||||||
|
}
|
||||||
|
const fallback = basis.anchorY - MAX_RADIUS - 12;
|
||||||
|
const target = Math.min(topY - 12, fallback);
|
||||||
|
out.set(raceId, Math.max(target, LABEL_MIN_Y));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
class="battle-scene"
|
||||||
|
viewBox="0 0 {VIEW_BOX} {VIEW_BOX}"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
role="img"
|
||||||
|
aria-label="battle scene"
|
||||||
|
data-testid="battle-scene"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={CENTER.x}
|
||||||
|
cy={CENTER.y}
|
||||||
|
r={PLANET_RADIUS}
|
||||||
|
class="planet"
|
||||||
|
data-testid="battle-scene-planet"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={CENTER.x}
|
||||||
|
y={CENTER.y + PLANET_RADIUS + 24}
|
||||||
|
text-anchor="middle"
|
||||||
|
class="planet-label"
|
||||||
|
>{report.planetName} (#{report.planet})</text>
|
||||||
|
|
||||||
|
{#each raceLayout as anchor (anchor.raceId)}
|
||||||
|
{@const cluster = renderedByRace.get(anchor.raceId) ?? []}
|
||||||
|
{@const basis = clusterBasisById.get(anchor.raceId)}
|
||||||
|
{#if basis && cluster.length > 0}
|
||||||
|
<g
|
||||||
|
class="race-cluster"
|
||||||
|
data-testid="battle-race-cluster"
|
||||||
|
data-race-id={anchor.raceId}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
x={anchor.x}
|
||||||
|
y={raceLabelYById.get(anchor.raceId) ?? anchor.y - MAX_RADIUS - 12}
|
||||||
|
text-anchor="middle"
|
||||||
|
class="race-label"
|
||||||
|
>{raceLabelById.get(anchor.raceId) ?? `race ${anchor.raceId}`}</text>
|
||||||
|
{#each cluster as entry (entry.bucketKey)}
|
||||||
|
{@const pos = worldPosition(basis, entry)}
|
||||||
|
{@const flash =
|
||||||
|
entry.bucketKey === flashDefenderBucketKey
|
||||||
|
? shotLine?.destroyed
|
||||||
|
? "destroyed"
|
||||||
|
: "shielded"
|
||||||
|
: null}
|
||||||
|
<g
|
||||||
|
class="class-marker"
|
||||||
|
data-testid="battle-class-marker"
|
||||||
|
data-bucket-key={entry.bucketKey}
|
||||||
|
data-class-name={entry.className}
|
||||||
|
data-flash={flash}
|
||||||
|
>
|
||||||
|
<circle cx={pos.x} cy={pos.y} r={entry.radius} />
|
||||||
|
<text
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y + entry.radius + 12}
|
||||||
|
text-anchor="middle"
|
||||||
|
class="class-label"
|
||||||
|
>{entry.className}:{entry.numLeft}</text>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</g>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if shotLine && shotVisible}
|
||||||
|
<line
|
||||||
|
x1={shotLine.from.x}
|
||||||
|
y1={shotLine.from.y}
|
||||||
|
x2={shotLine.to.x}
|
||||||
|
y2={shotLine.to.y}
|
||||||
|
class="shot"
|
||||||
|
class:destroyed={shotLine.destroyed}
|
||||||
|
data-testid="battle-shot"
|
||||||
|
data-destroyed={shotLine.destroyed ? "true" : "false"}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.battle-scene {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0a0d1a;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.planet {
|
||||||
|
fill: #2a2f40;
|
||||||
|
stroke: #4a5066;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.planet-label {
|
||||||
|
fill: #6d7388;
|
||||||
|
font-size: 18px;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.race-label {
|
||||||
|
fill: #e2e6ff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.class-marker circle {
|
||||||
|
fill: #1a2042;
|
||||||
|
stroke: #6d7bb5;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
transition:
|
||||||
|
fill 80ms ease-in,
|
||||||
|
stroke 80ms ease-in;
|
||||||
|
}
|
||||||
|
.class-marker[data-flash="destroyed"] circle {
|
||||||
|
fill: #ee3344;
|
||||||
|
stroke: #ee3344;
|
||||||
|
}
|
||||||
|
.class-marker[data-flash="shielded"] circle {
|
||||||
|
fill: #44dd66;
|
||||||
|
stroke: #44dd66;
|
||||||
|
}
|
||||||
|
.class-label {
|
||||||
|
fill: #b8c0e6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.shot {
|
||||||
|
stroke: #44dd66;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
.shot.destroyed {
|
||||||
|
stroke: #ee3344;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<!--
|
||||||
|
BattleViewer — orchestrates the radial battle scene, the playback
|
||||||
|
controls, and the accessibility text log for one BattleReport. Owns
|
||||||
|
the playback state (`frameIndex`, `playing`, `speed`, `logOpen`).
|
||||||
|
Layout reorganisation (latest iteration):
|
||||||
|
|
||||||
|
- The header carries the planet title, the back-navigation links and
|
||||||
|
the frame counter so the scene captures the full viewer width and
|
||||||
|
height beneath them.
|
||||||
|
- A drag-seek slider sits between the scene and the controls; the
|
||||||
|
user can scrub the playback timeline at any speed.
|
||||||
|
- The text log collapses behind a toggle in the controls bar so a
|
||||||
|
user who wants the biggest scene possible can hide it entirely.
|
||||||
|
|
||||||
|
The component is logically isolated: feed it any `BattleReport`
|
||||||
|
matching `pkg/model/report/battle.go` and it plays back.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
import type { BattleReport } from "../../api/battle-fetch";
|
||||||
|
import type { ShipClassLookup } from "./mass";
|
||||||
|
|
||||||
|
import BattleScene from "./battle-scene.svelte";
|
||||||
|
import PlaybackControls, {
|
||||||
|
type PlaybackSpeed,
|
||||||
|
} from "./playback-controls.svelte";
|
||||||
|
import { buildFrames } from "./timeline";
|
||||||
|
|
||||||
|
let {
|
||||||
|
report,
|
||||||
|
shipClassLookup,
|
||||||
|
onBackToMap,
|
||||||
|
onBackToReport,
|
||||||
|
}: {
|
||||||
|
report: BattleReport;
|
||||||
|
shipClassLookup?: ShipClassLookup;
|
||||||
|
onBackToMap?: () => void;
|
||||||
|
onBackToReport?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const frames = $derived(buildFrames(report));
|
||||||
|
let frameIndex = $state(0);
|
||||||
|
let playing = $state(false);
|
||||||
|
let speed = $state<PlaybackSpeed>(1);
|
||||||
|
let logOpen = $state(true);
|
||||||
|
let shotVisible = $state(true);
|
||||||
|
let logEl = $state<HTMLOListElement | null>(null);
|
||||||
|
|
||||||
|
const rawFrame = $derived(frames[Math.min(frameIndex, frames.length - 1)]);
|
||||||
|
|
||||||
|
// displayFrame freezes the layout at the penultimate frame's
|
||||||
|
// state once the protocol's last action eliminates a race, so
|
||||||
|
// the surviving cluster does not suddenly reflow onto the
|
||||||
|
// planet ring on the very last shot. The frame counter still
|
||||||
|
// advances to the final shot and `lastAction` still drives the
|
||||||
|
// killing line + flash; only `remaining` and `activeRaceIds`
|
||||||
|
// (the layout-determining state) freeze.
|
||||||
|
const displayFrame = $derived.by(() => {
|
||||||
|
const last = frames.length - 1;
|
||||||
|
if (
|
||||||
|
frameIndex === last &&
|
||||||
|
last >= 1 &&
|
||||||
|
frames[last].activeRaceIds.length < frames[last - 1].activeRaceIds.length
|
||||||
|
) {
|
||||||
|
const prev = frames[last - 1];
|
||||||
|
const cur = frames[last];
|
||||||
|
return {
|
||||||
|
shotIndex: cur.shotIndex,
|
||||||
|
lastAction: cur.lastAction,
|
||||||
|
remaining: prev.remaining,
|
||||||
|
activeRaceIds: prev.activeRaceIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return rawFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
// One tick per frame: blink the shot line off during the last
|
||||||
|
// 10 % of the frame's interval, then advance. Effect re-arms
|
||||||
|
// whenever frameIndex / playing / speed changes; previous
|
||||||
|
// timers clean up through the return.
|
||||||
|
$effect(() => {
|
||||||
|
void frameIndex;
|
||||||
|
void speed;
|
||||||
|
shotVisible = true;
|
||||||
|
if (!playing) return;
|
||||||
|
const intervalMs = 400 / speed;
|
||||||
|
const blinkOff = setTimeout(() => {
|
||||||
|
shotVisible = false;
|
||||||
|
}, intervalMs * 0.9);
|
||||||
|
const advance = setTimeout(() => {
|
||||||
|
if (frameIndex >= frames.length - 1) {
|
||||||
|
playing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frameIndex = frameIndex + 1;
|
||||||
|
}, intervalMs);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(blinkOff);
|
||||||
|
clearTimeout(advance);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-scroll the visible log row into view so the highlight
|
||||||
|
// keeps up with the timeline on long battles.
|
||||||
|
$effect(() => {
|
||||||
|
void displayFrame.shotIndex;
|
||||||
|
if (!logOpen || logEl === null) return;
|
||||||
|
const current = logEl.querySelector(
|
||||||
|
'li[data-current="true"]',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (current !== null) {
|
||||||
|
current.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function seekToShot(actionIndex: number) {
|
||||||
|
playing = false;
|
||||||
|
frameIndex = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(frames.length - 1, actionIndex + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScrub(event: Event) {
|
||||||
|
const target = event.currentTarget as HTMLInputElement;
|
||||||
|
const value = Number(target.value);
|
||||||
|
if (!Number.isFinite(value)) return;
|
||||||
|
playing = false;
|
||||||
|
frameIndex = Math.max(0, Math.min(frames.length - 1, Math.trunc(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeAction(index: number): string {
|
||||||
|
const action = report.protocol[index];
|
||||||
|
const attackerGroup = report.ships[String(action.sa)];
|
||||||
|
const defenderGroup = report.ships[String(action.sd)];
|
||||||
|
const attackerRace = attackerGroup?.race ?? `race ${action.a}`;
|
||||||
|
const attackerClass = attackerGroup?.className ?? `class ${action.sa}`;
|
||||||
|
const defenderRace = defenderGroup?.race ?? `race ${action.d}`;
|
||||||
|
const defenderClass = defenderGroup?.className ?? `class ${action.sd}`;
|
||||||
|
const key = action.x
|
||||||
|
? "game.battle.log.destroyed"
|
||||||
|
: "game.battle.log.shielded";
|
||||||
|
return i18n.t(key, {
|
||||||
|
attacker_race: attackerRace,
|
||||||
|
attacker_class: attackerClass,
|
||||||
|
defender_race: defenderRace,
|
||||||
|
defender_class: defenderClass,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="viewer" data-testid="battle-viewer">
|
||||||
|
<header class="header">
|
||||||
|
<div class="back-row">
|
||||||
|
{#if onBackToMap}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back-btn"
|
||||||
|
onclick={onBackToMap}
|
||||||
|
data-testid="battle-back-to-map"
|
||||||
|
>{i18n.t("game.battle.back_to_map")}</button>
|
||||||
|
{/if}
|
||||||
|
{#if onBackToReport}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="back-btn"
|
||||||
|
onclick={onBackToReport}
|
||||||
|
data-testid="battle-back-to-report"
|
||||||
|
>{i18n.t("game.battle.back_to_report")}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<h2 data-testid="battle-viewer-title">
|
||||||
|
{i18n.t("game.battle.header_title", {
|
||||||
|
planet_name: report.planetName,
|
||||||
|
planet_number: String(report.planet),
|
||||||
|
})}
|
||||||
|
</h2>
|
||||||
|
<span class="progress" data-testid="battle-frame-index">
|
||||||
|
{displayFrame.shotIndex} / {report.protocol.length}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="scene">
|
||||||
|
<BattleScene {report} frame={displayFrame} {shipClassLookup} {shotVisible} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="scrubber"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={Math.max(0, frames.length - 1)}
|
||||||
|
step="1"
|
||||||
|
value={frameIndex}
|
||||||
|
oninput={onScrub}
|
||||||
|
aria-label={i18n.t("game.battle.controls.scrub")}
|
||||||
|
data-testid="battle-scrubber"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PlaybackControls
|
||||||
|
bind:playing
|
||||||
|
bind:frameIndex
|
||||||
|
bind:speed
|
||||||
|
bind:logOpen
|
||||||
|
frameCount={frames.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if logOpen}
|
||||||
|
<section
|
||||||
|
class="log"
|
||||||
|
aria-label={i18n.t("game.battle.accessibility.protocol_heading")}
|
||||||
|
>
|
||||||
|
<h3>{i18n.t("game.battle.accessibility.protocol_heading")}</h3>
|
||||||
|
<ol bind:this={logEl} data-testid="battle-protocol-log">
|
||||||
|
{#each report.protocol as _action, i (i)}
|
||||||
|
<li
|
||||||
|
data-testid="battle-protocol-log-item"
|
||||||
|
data-current={i + 1 === displayFrame.shotIndex ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="log-row-btn"
|
||||||
|
onclick={() => seekToShot(i)}
|
||||||
|
>{describeAction(i)}</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: #d6dcf2;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.back-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
appearance: none;
|
||||||
|
background: #1f2748;
|
||||||
|
color: #d6dcf2;
|
||||||
|
border: 1px solid #2c3568;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #2a3463;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
color: #93a0d0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 5rem;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.scene {
|
||||||
|
background: #0a0d1a;
|
||||||
|
border: 1px solid #1e264a;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.scrubber {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
accent-color: #6d7bb5;
|
||||||
|
}
|
||||||
|
.log {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 4rem;
|
||||||
|
max-height: 30vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.log h3 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
color: #93a0d0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.log ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: #c6cdf0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.log li {
|
||||||
|
border-bottom: 1px solid #1c2240;
|
||||||
|
}
|
||||||
|
.log-row-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.log-row-btn:hover,
|
||||||
|
.log-row-btn:focus-visible {
|
||||||
|
background: #131a36;
|
||||||
|
}
|
||||||
|
.log li[data-current="true"] .log-row-btn {
|
||||||
|
color: #ffe27a;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #1a2240;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Mass helpers for the Battle Viewer.
|
||||||
|
//
|
||||||
|
// Phase 27 refinement: ship-class circles are sized by per-ship
|
||||||
|
// FullMass (Empty + carrying), with a 6..24 px range. The viewer
|
||||||
|
// resolves a `(race, className) → ShipClassRef` lookup from the
|
||||||
|
// surrounding GameReport's `localShipClass` / `otherShipClass`
|
||||||
|
// tables and feeds it through the wasm bridge to `pkg/calc/ship.go`.
|
||||||
|
//
|
||||||
|
// Pure utilities live here; the Svelte components consume them.
|
||||||
|
|
||||||
|
import type { Core } from "../../platform/core/index";
|
||||||
|
import type { BattleReportGroup } from "../../api/battle-fetch";
|
||||||
|
|
||||||
|
/** Smallest visible ship circle. Picked so the `<class>:<n>` label
|
||||||
|
* stays legible on every viewport. */
|
||||||
|
export const MIN_RADIUS = 6;
|
||||||
|
|
||||||
|
/** Largest ship circle. Matches the Phase-27 baseline so heavy
|
||||||
|
* ships keep their previous visual prominence. */
|
||||||
|
export const MAX_RADIUS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShipClassRef is the minimum slice of a ship class needed to
|
||||||
|
* compute its mass. Mirrors the relevant fields of
|
||||||
|
* `ShipClassSummary` (own classes) and `ReportOtherShipClass`
|
||||||
|
* (foreign classes) without coupling the viewer to either type.
|
||||||
|
*/
|
||||||
|
export interface ShipClassRef {
|
||||||
|
drive: number;
|
||||||
|
weapons: number;
|
||||||
|
armament: number;
|
||||||
|
shields: number;
|
||||||
|
cargo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ShipClassLookup resolves `(race, className)` to a ship-class
|
||||||
|
* descriptor. Returns `null` when the class is not in the parent
|
||||||
|
* report — happens with legacy-mode foreign races that lack a
|
||||||
|
* `<Race> Ship Types` block.
|
||||||
|
*/
|
||||||
|
export interface ShipClassLookup {
|
||||||
|
get(race: string, className: string): ShipClassRef | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* computeBattleGroupMass returns the per-ship FullMass for a given
|
||||||
|
* battle group. Mass=0 means "unknown" — either the wasm bridge
|
||||||
|
* rejected the ship-class params (degenerate weapons/armament pair)
|
||||||
|
* or the class did not resolve in the lookup. Either way the
|
||||||
|
* caller's downstream `radiusForMass` falls back to MAX_RADIUS so
|
||||||
|
* the node stays visible.
|
||||||
|
*
|
||||||
|
* Cargo never changes during a battle, so this can be cached per
|
||||||
|
* `(race, className)` bucket for the lifetime of the viewer
|
||||||
|
* session.
|
||||||
|
*/
|
||||||
|
export function computeBattleGroupMass(
|
||||||
|
group: BattleReportGroup,
|
||||||
|
classDef: ShipClassRef | null,
|
||||||
|
core: Core,
|
||||||
|
): number {
|
||||||
|
if (classDef === null) return 0;
|
||||||
|
const empty = core.emptyMass({
|
||||||
|
drive: classDef.drive,
|
||||||
|
weapons: classDef.weapons,
|
||||||
|
armament: classDef.armament,
|
||||||
|
shields: classDef.shields,
|
||||||
|
cargo: classDef.cargo,
|
||||||
|
});
|
||||||
|
if (empty === null) return 0;
|
||||||
|
const cargoTech = classDef.cargo * (group.tech.CARGO ?? 0);
|
||||||
|
const carrying = core.carryingMass({
|
||||||
|
load: group.loadQuantity,
|
||||||
|
cargoTech,
|
||||||
|
});
|
||||||
|
return core.fullMass({ emptyMass: empty, carryingMass: carrying });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* radiusForMass maps an absolute ship mass to a circle radius via
|
||||||
|
* a per-battle normalisation: the heaviest visual node always
|
||||||
|
* renders at MAX_RADIUS, lighter ones scale by sqrt(mass /
|
||||||
|
* maxMassInBattle) so the smallest ships don't disappear and the
|
||||||
|
* heaviest ones don't dominate the scene at >MAX_RADIUS. mass<=0
|
||||||
|
* falls back to MAX_RADIUS so unresolved/invalid classes stay
|
||||||
|
* visible.
|
||||||
|
*/
|
||||||
|
export function radiusForMass(mass: number, maxMassInBattle: number): number {
|
||||||
|
if (maxMassInBattle <= 0 || mass <= 0) return MAX_RADIUS;
|
||||||
|
const scaled = MIN_RADIUS + (MAX_RADIUS - MIN_RADIUS) * Math.sqrt(mass / maxMassInBattle);
|
||||||
|
if (scaled < MIN_RADIUS) return MIN_RADIUS;
|
||||||
|
if (scaled > MAX_RADIUS) return MAX_RADIUS;
|
||||||
|
return scaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapShipClassLookup is a `Map<string, ShipClassRef>`-backed
|
||||||
|
* implementation of `ShipClassLookup`. Key encoding mirrors the
|
||||||
|
* one battle.svelte uses when populating the lookup from the
|
||||||
|
* parent GameReport.
|
||||||
|
*/
|
||||||
|
export class MapShipClassLookup implements ShipClassLookup {
|
||||||
|
constructor(private readonly map: Map<string, ShipClassRef>) {}
|
||||||
|
|
||||||
|
get(race: string, className: string): ShipClassRef | null {
|
||||||
|
return this.map.get(`${race}::${className}`) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<!--
|
||||||
|
PlaybackControls — rewind / step-back / play-pause / step-forward
|
||||||
|
plus a single cycling speed button (1x → 2x → 4x → 6x → 1x) and a
|
||||||
|
"log" toggle that the orchestrator uses to collapse the always-on
|
||||||
|
text protocol when the user wants more space for the scene. Owns no
|
||||||
|
state of its own; binds `playing`, `frameIndex`, `speed`, and
|
||||||
|
`logOpen` from the orchestrator. Disables step/rewind when there's
|
||||||
|
nowhere to go and step-forward when the timeline is at its end.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
|
|
||||||
|
export type PlaybackSpeed = 1 | 2 | 4 | 6;
|
||||||
|
|
||||||
|
const SPEED_CYCLE: PlaybackSpeed[] = [1, 2, 4, 6];
|
||||||
|
|
||||||
|
let {
|
||||||
|
playing = $bindable(),
|
||||||
|
frameIndex = $bindable(),
|
||||||
|
speed = $bindable(),
|
||||||
|
logOpen = $bindable(),
|
||||||
|
frameCount,
|
||||||
|
}: {
|
||||||
|
playing: boolean;
|
||||||
|
frameIndex: number;
|
||||||
|
speed: PlaybackSpeed;
|
||||||
|
logOpen: boolean;
|
||||||
|
frameCount: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function rewind() {
|
||||||
|
playing = false;
|
||||||
|
frameIndex = 0;
|
||||||
|
}
|
||||||
|
function stepBack() {
|
||||||
|
playing = false;
|
||||||
|
if (frameIndex > 0) frameIndex = frameIndex - 1;
|
||||||
|
}
|
||||||
|
function togglePlay() {
|
||||||
|
if (frameIndex >= frameCount - 1) {
|
||||||
|
frameIndex = 0;
|
||||||
|
}
|
||||||
|
playing = !playing;
|
||||||
|
}
|
||||||
|
function stepForward() {
|
||||||
|
playing = false;
|
||||||
|
if (frameIndex < frameCount - 1) frameIndex = frameIndex + 1;
|
||||||
|
}
|
||||||
|
function cycleSpeed() {
|
||||||
|
const idx = SPEED_CYCLE.indexOf(speed);
|
||||||
|
const next = SPEED_CYCLE[(idx + 1) % SPEED_CYCLE.length];
|
||||||
|
speed = next;
|
||||||
|
}
|
||||||
|
function toggleLog() {
|
||||||
|
logOpen = !logOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedLabel = $derived(`${speed}x`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="controls" data-testid="battle-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={rewind}
|
||||||
|
disabled={frameIndex === 0}
|
||||||
|
aria-label={i18n.t("game.battle.controls.rewind")}
|
||||||
|
data-testid="battle-control-rewind"
|
||||||
|
>⏮</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={stepBack}
|
||||||
|
disabled={frameIndex === 0}
|
||||||
|
aria-label={i18n.t("game.battle.controls.step_backward")}
|
||||||
|
data-testid="battle-control-step-back"
|
||||||
|
>◀︎◀︎</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePlay}
|
||||||
|
aria-label={playing
|
||||||
|
? i18n.t("game.battle.controls.pause")
|
||||||
|
: i18n.t("game.battle.controls.play")}
|
||||||
|
data-testid="battle-control-play"
|
||||||
|
data-playing={playing ? "true" : "false"}
|
||||||
|
>{playing ? "⏸" : "▶︎"}</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={stepForward}
|
||||||
|
disabled={frameIndex >= frameCount - 1}
|
||||||
|
aria-label={i18n.t("game.battle.controls.step_forward")}
|
||||||
|
data-testid="battle-control-step-forward"
|
||||||
|
>▶︎▶︎</button>
|
||||||
|
|
||||||
|
<div class="spacer" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="speed-btn"
|
||||||
|
onclick={cycleSpeed}
|
||||||
|
title={i18n.t("game.battle.controls.speed_label")}
|
||||||
|
aria-label={i18n.t("game.battle.controls.speed_label")}
|
||||||
|
data-testid="battle-control-speed"
|
||||||
|
data-speed={speed}
|
||||||
|
>{speedLabel}</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="log-toggle"
|
||||||
|
class:active={logOpen}
|
||||||
|
onclick={toggleLog}
|
||||||
|
aria-pressed={logOpen}
|
||||||
|
aria-label={i18n.t("game.battle.controls.log_toggle")}
|
||||||
|
data-testid="battle-control-log-toggle"
|
||||||
|
>{i18n.t("game.battle.controls.log_toggle")} {logOpen ? "▲" : "▼"}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #131934;
|
||||||
|
border: 1px solid #1e264a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
appearance: none;
|
||||||
|
background: #1f2748;
|
||||||
|
color: #d6dcf2;
|
||||||
|
border: 1px solid #2c3568;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #2a3463;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.speed-btn {
|
||||||
|
min-width: 3rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.log-toggle.active {
|
||||||
|
background: #2a3463;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Radial layout for the BattleViewer.
|
||||||
|
//
|
||||||
|
// Places race anchors on a circle of radius `radius` around `center`
|
||||||
|
// at equal angular spacing. For three or more races the first anchor
|
||||||
|
// sits at the top (12 o'clock) and subsequent anchors march
|
||||||
|
// clockwise. For exactly two races the pair is rotated 90° so they
|
||||||
|
// face each other horizontally (3 o'clock vs 9 o'clock) — that keeps
|
||||||
|
// every race label clear of the SVG top edge when only two clusters
|
||||||
|
// remain, and reads as "the two sides facing off" naturally.
|
||||||
|
//
|
||||||
|
// When a race is eliminated mid-battle the caller filters it out of
|
||||||
|
// `activeRaceIds` and the survivors are re-spaced on the next frame
|
||||||
|
// through the same helper.
|
||||||
|
|
||||||
|
export interface RaceAnchor {
|
||||||
|
raceId: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Angle in radians measured from the positive Y axis clockwise. */
|
||||||
|
angle: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadialLayoutOptions {
|
||||||
|
center: { x: number; y: number };
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* layoutRaces returns anchor positions for each `activeRaceIds`
|
||||||
|
* entry, placed at equal angular spacing on a circle. The input
|
||||||
|
* order is preserved so consumers get a stable mapping across
|
||||||
|
* frames; eliminated entries should simply be filtered out before
|
||||||
|
* the call.
|
||||||
|
*/
|
||||||
|
export function layoutRaces(
|
||||||
|
activeRaceIds: number[],
|
||||||
|
options: RadialLayoutOptions,
|
||||||
|
): RaceAnchor[] {
|
||||||
|
const count = activeRaceIds.length;
|
||||||
|
if (count === 0) return [];
|
||||||
|
const { center, radius } = options;
|
||||||
|
const out: RaceAnchor[] = [];
|
||||||
|
// For two participants we want a horizontal duel layout: race 0
|
||||||
|
// at 9 o'clock, race 1 at 3 o'clock. For any other count the
|
||||||
|
// first anchor lands at the top (12 o'clock) and the rest march
|
||||||
|
// clockwise at equal spacing.
|
||||||
|
const startAngle = count === 2 ? Math.PI : -Math.PI / 2;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const step = (2 * Math.PI) / count;
|
||||||
|
const angle = startAngle + i * step;
|
||||||
|
out.push({
|
||||||
|
raceId: activeRaceIds[i],
|
||||||
|
x: center.x + radius * Math.cos(angle),
|
||||||
|
y: center.y + radius * Math.sin(angle),
|
||||||
|
angle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Timeline builder for the BattleViewer.
|
||||||
|
//
|
||||||
|
// Given a `BattleReport`, expands the flat `protocol` into a
|
||||||
|
// sequence of frames. Frame 0 carries the initial state; frame N
|
||||||
|
// (1 ≤ N ≤ protocol.length) reflects the state right after the
|
||||||
|
// (N-1)-th action has been applied. Each frame is self-contained so
|
||||||
|
// stepping forward and backward is a constant-time index lookup, no
|
||||||
|
// rewind logic needed.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BattleActionReport,
|
||||||
|
BattleReport,
|
||||||
|
BattleReportGroup,
|
||||||
|
} from "../../api/battle-fetch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frame is one tick of the battle playback. `remaining` carries the
|
||||||
|
* surviving ship count for each ship-group key from
|
||||||
|
* `BattleReport.ships`; `activeRaceIds` are the race indices with at
|
||||||
|
* least one surviving in-battle group. `lastAction` is the action
|
||||||
|
* applied to produce this frame, or `null` for the initial frame.
|
||||||
|
*/
|
||||||
|
export interface Frame {
|
||||||
|
shotIndex: number;
|
||||||
|
remaining: Map<number, number>;
|
||||||
|
activeRaceIds: number[];
|
||||||
|
lastAction: BattleActionReport | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalisedGroup {
|
||||||
|
key: number;
|
||||||
|
group: BattleReportGroup;
|
||||||
|
raceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* normaliseGroups returns the in-battle ship groups from a
|
||||||
|
* BattleReport indexed by their integer key. Observer groups
|
||||||
|
* (`inBattle === false`) are skipped because they are neither
|
||||||
|
* targeted nor drawn. The race index per group is derived from the
|
||||||
|
* protocol — every in-battle group appears at least once as
|
||||||
|
* attacker or defender, and the engine's pairing (a, sa) / (d, sd)
|
||||||
|
* defines the relationship.
|
||||||
|
*/
|
||||||
|
export function normaliseGroups(report: BattleReport): NormalisedGroup[] {
|
||||||
|
const raceByKey = buildGroupRaceMap(report.protocol);
|
||||||
|
const out: NormalisedGroup[] = [];
|
||||||
|
for (const [keyRaw, group] of Object.entries(report.ships)) {
|
||||||
|
if (!group.inBattle) continue;
|
||||||
|
const key = Number(keyRaw);
|
||||||
|
if (!Number.isFinite(key)) continue;
|
||||||
|
const raceId = raceByKey.get(key);
|
||||||
|
if (raceId === undefined) continue;
|
||||||
|
out.push({ key, group, raceId });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildGroupRaceMap extracts the `ship-group key → race index`
|
||||||
|
* mapping from a battle protocol. Same key appearing twice always
|
||||||
|
* carries the same race index — protocol entries are emitted by the
|
||||||
|
* engine, which never crosses these wires.
|
||||||
|
*/
|
||||||
|
export function buildGroupRaceMap(
|
||||||
|
protocol: BattleActionReport[],
|
||||||
|
): Map<number, number> {
|
||||||
|
const out = new Map<number, number>();
|
||||||
|
for (const action of protocol) {
|
||||||
|
if (!out.has(action.sa)) out.set(action.sa, action.a);
|
||||||
|
if (!out.has(action.sd)) out.set(action.sd, action.d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildFrames walks the protocol once and emits a frame after each
|
||||||
|
* applied action plus the initial frame. The remaining-ships map is
|
||||||
|
* cloned per frame so callers can step backward without manual
|
||||||
|
* bookkeeping. Eliminated races drop out of `activeRaceIds` as soon
|
||||||
|
* as their last in-battle group hits zero.
|
||||||
|
*/
|
||||||
|
export function buildFrames(report: BattleReport): Frame[] {
|
||||||
|
const groups = normaliseGroups(report);
|
||||||
|
const initialRemaining = new Map<number, number>();
|
||||||
|
const raceTotals = new Map<number, number>();
|
||||||
|
for (const g of groups) {
|
||||||
|
initialRemaining.set(g.key, g.group.num);
|
||||||
|
raceTotals.set(g.raceId, (raceTotals.get(g.raceId) ?? 0) + g.group.num);
|
||||||
|
}
|
||||||
|
|
||||||
|
const frames: Frame[] = [];
|
||||||
|
frames.push({
|
||||||
|
shotIndex: 0,
|
||||||
|
remaining: new Map(initialRemaining),
|
||||||
|
activeRaceIds: collectActiveRaces(raceTotals),
|
||||||
|
lastAction: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupRaceByKey = new Map<number, number>();
|
||||||
|
for (const g of groups) groupRaceByKey.set(g.key, g.raceId);
|
||||||
|
|
||||||
|
const current = new Map(initialRemaining);
|
||||||
|
const runningRaceTotals = new Map(raceTotals);
|
||||||
|
for (let i = 0; i < report.protocol.length; i++) {
|
||||||
|
const action = report.protocol[i];
|
||||||
|
if (action.x) {
|
||||||
|
// Defence in depth: a malformed protocol that fires more
|
||||||
|
// `Destroyed` rows than the group has ships would push
|
||||||
|
// `runningRaceTotals` below zero and drop the race from
|
||||||
|
// `activeRaceIds` prematurely. Real legacy data folds
|
||||||
|
// duplicate `(race, className)` roster rows into the
|
||||||
|
// same `BattleReportGroup` (parser + engine), so this
|
||||||
|
// branch is hit only on a real shrink — but the clamp
|
||||||
|
// keeps the math from going negative either way.
|
||||||
|
const left = current.get(action.sd) ?? 0;
|
||||||
|
if (left > 0) {
|
||||||
|
current.set(action.sd, left - 1);
|
||||||
|
const raceId = groupRaceByKey.get(action.sd);
|
||||||
|
if (raceId !== undefined) {
|
||||||
|
const t = (runningRaceTotals.get(raceId) ?? 0) - 1;
|
||||||
|
runningRaceTotals.set(raceId, Math.max(0, t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frames.push({
|
||||||
|
shotIndex: i + 1,
|
||||||
|
remaining: new Map(current),
|
||||||
|
activeRaceIds: collectActiveRaces(runningRaceTotals),
|
||||||
|
lastAction: action,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectActiveRaces(totals: Map<number, number>): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const [raceId, total] of totals.entries()) {
|
||||||
|
if (total > 0) out.push(raceId);
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
@@ -24,6 +24,12 @@ import type { WrapMode } from "../map/world";
|
|||||||
|
|
||||||
const PREF_NAMESPACE = "game-prefs";
|
const PREF_NAMESPACE = "game-prefs";
|
||||||
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
|
const PREF_KEY_WRAP_MODE = (gameId: string) => `${gameId}/wrap-mode`;
|
||||||
|
const PREF_KEY_LAST_VIEWED_TURN = (gameId: string) =>
|
||||||
|
`${gameId}/last-viewed-turn`;
|
||||||
|
|
||||||
|
const HISTORY_NAMESPACE = "game-history";
|
||||||
|
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
|
||||||
|
`${gameId}/turn/${turn}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
|
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||||
@@ -53,9 +59,30 @@ export class GameStateStore {
|
|||||||
* game (lifted from the lobby record on `setGame`). Phase 14
|
* game (lifted from the lobby record on `setGame`). Phase 14
|
||||||
* exposes it so the layout can pass it to
|
* exposes it so the layout can pass it to
|
||||||
* `OrderDraftStore.hydrateFromServer` after both stores boot;
|
* `OrderDraftStore.hydrateFromServer` after both stores boot;
|
||||||
* later phases (history mode, calc) will read it directly.
|
* Phase 26 keeps the "authoritative server-side turn" meaning —
|
||||||
|
* only `setGame`, `advanceToPending`, and the visibility-listener
|
||||||
|
* lobby re-query update it. History navigation (`viewTurn`) leaves
|
||||||
|
* it alone so the "Return to current turn" affordance keeps a
|
||||||
|
* reliable target.
|
||||||
*/
|
*/
|
||||||
currentTurn = $state(0);
|
currentTurn = $state(0);
|
||||||
|
/**
|
||||||
|
* viewedTurn is the turn whose snapshot is currently displayed.
|
||||||
|
* In live mode it equals `currentTurn`. Phase 26 history mode
|
||||||
|
* decouples the two: `viewTurn(N)` flips this rune (and `report`)
|
||||||
|
* to N without touching `currentTurn` or `last-viewed-turn`.
|
||||||
|
*/
|
||||||
|
viewedTurn = $state(0);
|
||||||
|
/**
|
||||||
|
* historyMode is the derived "user is viewing a past turn" rune
|
||||||
|
* consumed by Phase 12 sidebar / bottom-tabs wiring, the Phase 26
|
||||||
|
* history banner, the rendered-report overlay short-circuit, and
|
||||||
|
* the order-draft mutation gate. It depends only on the rune state
|
||||||
|
* above, so every consumer reacts to a single source of truth.
|
||||||
|
*/
|
||||||
|
historyMode = $derived(
|
||||||
|
this.status === "ready" && this.viewedTurn < this.currentTurn,
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* synthetic is set by `initSynthetic` for DEV-only sessions backed
|
* synthetic is set by `initSynthetic` for DEV-only sessions backed
|
||||||
* by a hand-loaded report (lobby's "Load synthetic report"
|
* by a hand-loaded report (lobby's "Load synthetic report"
|
||||||
@@ -66,6 +93,17 @@ export class GameStateStore {
|
|||||||
* this flag is enough to keep the network silent.
|
* this flag is enough to keep the network silent.
|
||||||
*/
|
*/
|
||||||
synthetic = $state(false);
|
synthetic = $state(false);
|
||||||
|
/**
|
||||||
|
* pendingTurn carries the latest server-side turn the user has not
|
||||||
|
* yet opened: it is `> currentTurn` whenever the server reports a
|
||||||
|
* new turn (either through a `game.turn.ready` push event after
|
||||||
|
* boot, or through the boot-time discovery that the persisted
|
||||||
|
* `lastViewedTurn` is behind the lobby's `current_turn`). The
|
||||||
|
* layout's `$effect` renders a toast/banner when it is non-null;
|
||||||
|
* `advanceToPending()` refreshes the store onto the new turn and
|
||||||
|
* clears the rune.
|
||||||
|
*/
|
||||||
|
pendingTurn: number | null = $state(null);
|
||||||
|
|
||||||
private client: GalaxyClient | null = null;
|
private client: GalaxyClient | null = null;
|
||||||
private cache: Cache | null = null;
|
private cache: Cache | null = null;
|
||||||
@@ -98,12 +136,21 @@ export class GameStateStore {
|
|||||||
if (this.client === null || this.cache === null) {
|
if (this.client === null || this.cache === null) {
|
||||||
throw new Error("game-state: setGame called before init");
|
throw new Error("game-state: setGame called before init");
|
||||||
}
|
}
|
||||||
|
// Only forget the pending indicator when the consumer is
|
||||||
|
// actually switching games. On the initial `setGame` after
|
||||||
|
// `init` the previous `gameId` is the empty string, and a
|
||||||
|
// concurrent `markPendingTurn` from a push event arriving
|
||||||
|
// while we were still bootstrapping must not be erased.
|
||||||
|
if (this.gameId !== "" && this.gameId !== gameId) {
|
||||||
|
this.pendingTurn = null;
|
||||||
|
}
|
||||||
this.gameId = gameId;
|
this.gameId = gameId;
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.report = null;
|
this.report = null;
|
||||||
|
|
||||||
this.wrapMode = await readWrapMode(this.cache, gameId);
|
this.wrapMode = await readWrapMode(this.cache, gameId);
|
||||||
|
const lastViewed = await readLastViewedTurn(this.cache, gameId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const summary = await this.findGame(gameId);
|
const summary = await this.findGame(gameId);
|
||||||
@@ -114,7 +161,23 @@ export class GameStateStore {
|
|||||||
}
|
}
|
||||||
this.gameName = summary.gameName;
|
this.gameName = summary.gameName;
|
||||||
this.currentTurn = summary.currentTurn;
|
this.currentTurn = summary.currentTurn;
|
||||||
await this.loadTurn(summary.currentTurn);
|
// If the persisted last-viewed turn is older than the
|
||||||
|
// server-side current turn, open the user on their last-seen
|
||||||
|
// snapshot and surface the gap through `pendingTurn` so the
|
||||||
|
// shell can render a "new turn available" affordance instead
|
||||||
|
// of silently auto-advancing. After Phase 26 the same gap
|
||||||
|
// also flips `historyMode` to true (viewedTurn < currentTurn),
|
||||||
|
// so the read-only banner appears alongside the toast.
|
||||||
|
if (
|
||||||
|
lastViewed !== null &&
|
||||||
|
lastViewed >= 0 &&
|
||||||
|
lastViewed < summary.currentTurn
|
||||||
|
) {
|
||||||
|
this.pendingTurn = summary.currentTurn;
|
||||||
|
await this.loadTurn(lastViewed, { isCurrent: false });
|
||||||
|
} else {
|
||||||
|
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
@@ -123,29 +186,104 @@ export class GameStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* setTurn loads a different turn snapshot — used by Phase 26 history
|
* markPendingTurn records a server-reported new turn (typically
|
||||||
* mode. The current turn stays at whatever `setGame` discovered;
|
* delivered through `game.turn.ready`). Values that are not
|
||||||
* calling without an argument refetches the same turn.
|
* strictly ahead of the latest known turn (current or already
|
||||||
|
* pending) are ignored so a replayed event cannot regress the
|
||||||
|
* indicator.
|
||||||
*/
|
*/
|
||||||
async setTurn(turn: number): Promise<void> {
|
markPendingTurn(turn: number): void {
|
||||||
if (this.client === null) return;
|
const latest = this.pendingTurn ?? this.currentTurn;
|
||||||
|
if (turn > latest) {
|
||||||
|
this.pendingTurn = turn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* advanceToPending re-queries the lobby record and loads the
|
||||||
|
* report at the server's latest `current_turn`, then clears the
|
||||||
|
* pending indicator. Unlike `setGame`, this skips the
|
||||||
|
* `lastViewedTurn` lookup — the user has explicitly asked to
|
||||||
|
* jump to the new turn, so any persisted bookmark from the
|
||||||
|
* previous session is irrelevant. Failures keep the indicator
|
||||||
|
* set so the user can retry from the same affordance.
|
||||||
|
*/
|
||||||
|
async advanceToPending(): Promise<void> {
|
||||||
|
if (this.pendingTurn === null || this.client === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.status = "loading";
|
this.status = "loading";
|
||||||
this.error = null;
|
this.error = null;
|
||||||
try {
|
try {
|
||||||
await this.loadTurn(turn);
|
const summary = await this.findGame(this.gameId);
|
||||||
|
if (summary === null) {
|
||||||
|
this.status = "error";
|
||||||
|
this.error = `game ${this.gameId} is not in your list`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameName = summary.gameName;
|
||||||
|
this.currentTurn = summary.currentTurn;
|
||||||
|
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||||
|
this.pendingTurn = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (this.destroyed) return;
|
||||||
this.status = "error";
|
this.status = "error";
|
||||||
this.error = describe(err);
|
this.error = describe(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* refresh re-fetches the report at the current turn. Called on
|
* viewTurn loads the historical snapshot for `turn` and switches the
|
||||||
* window `visibilitychange` so the map and the turn counter stay
|
* UI into history mode (Phase 26). The current turn is untouched —
|
||||||
* fresh after the user returns to the tab.
|
* `historyMode` flips on automatically through the derived rune, and
|
||||||
|
* the `last-viewed-turn` cache is only refreshed when the caller
|
||||||
|
* happens to ask for the currentTurn (e.g. `returnToCurrent`). A
|
||||||
|
* cache hit on `game-history/{gameId}/turn/{N}` skips the network;
|
||||||
|
* past turns are immutable so the cache never goes stale.
|
||||||
*/
|
*/
|
||||||
refresh(): Promise<void> {
|
async viewTurn(turn: number): Promise<void> {
|
||||||
return this.setTurn(this.currentTurn);
|
if (this.client === null) return;
|
||||||
|
if (!Number.isFinite(turn) || turn < 0 || turn > this.currentTurn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = "loading";
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
await this.loadTurn(turn, { isCurrent: turn === this.currentTurn });
|
||||||
|
} catch (err) {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.status = "error";
|
||||||
|
this.error = describe(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returnToCurrent jumps back to the server's current turn after a
|
||||||
|
* history excursion. Thin wrapper around `viewTurn(currentTurn)` so
|
||||||
|
* the banner / popover share the same call site.
|
||||||
|
*/
|
||||||
|
returnToCurrent(): Promise<void> {
|
||||||
|
return this.viewTurn(this.currentTurn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* refresh is fired from the `visibilitychange` listener. In live
|
||||||
|
* mode it re-fetches the report at the current turn so the map and
|
||||||
|
* the counter catch up after the user returns to the tab. In
|
||||||
|
* history mode it is a no-op: the user is intentionally viewing a
|
||||||
|
* past turn, push events (Phase 24) deliver new-turn notifications
|
||||||
|
* asynchronously, and forcing a reload would silently bump the
|
||||||
|
* user out of history mode.
|
||||||
|
*/
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
if (this.client === null) return;
|
||||||
|
if (this.historyMode) return;
|
||||||
|
try {
|
||||||
|
await this.loadTurn(this.currentTurn, { isCurrent: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
console.warn("game-state: refresh failed", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,6 +331,7 @@ export class GameStateStore {
|
|||||||
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
|
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
|
||||||
this.report = opts.report;
|
this.report = opts.report;
|
||||||
this.currentTurn = opts.report.turn;
|
this.currentTurn = opts.report.turn;
|
||||||
|
this.viewedTurn = opts.report.turn;
|
||||||
this.status = "ready";
|
this.status = "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +351,57 @@ export class GameStateStore {
|
|||||||
return games.find((g) => g.gameId === gameId) ?? null;
|
return games.find((g) => g.gameId === gameId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadTurn(turn: number): Promise<void> {
|
private async loadTurn(
|
||||||
|
turn: number,
|
||||||
|
opts: { isCurrent: boolean },
|
||||||
|
): Promise<void> {
|
||||||
if (this.client === null) return;
|
if (this.client === null) return;
|
||||||
const report = await fetchGameReport(this.client, this.gameId, turn);
|
const report = await this.readReport(turn, opts.isCurrent);
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.report = report;
|
this.report = report;
|
||||||
this.currentTurn = turn;
|
this.viewedTurn = turn;
|
||||||
this.status = "ready";
|
this.status = "ready";
|
||||||
|
if (this.cache === null) return;
|
||||||
|
if (opts.isCurrent) {
|
||||||
|
// Persist last-viewed-turn only when the user is caught up
|
||||||
|
// on the live snapshot. Historical excursions are ephemeral
|
||||||
|
// (Phase 26 decision): the resume-on-open affordance from
|
||||||
|
// Phase 11 must keep meaning "the latest turn this player
|
||||||
|
// was caught up on", not "wherever they last clicked".
|
||||||
|
await this.cache.put(
|
||||||
|
PREF_NAMESPACE,
|
||||||
|
PREF_KEY_LAST_VIEWED_TURN(this.gameId),
|
||||||
|
turn,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Past turns are immutable, so the snapshot is safe to cache
|
||||||
|
// for fast re-entry. The current-turn snapshot deliberately
|
||||||
|
// skips the cache — it is mutable until the next tick.
|
||||||
|
await this.cache.put(
|
||||||
|
HISTORY_NAMESPACE,
|
||||||
|
HISTORY_KEY_TURN(this.gameId, turn),
|
||||||
|
report,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readReport(
|
||||||
|
turn: number,
|
||||||
|
isCurrent: boolean,
|
||||||
|
): Promise<GameReport> {
|
||||||
|
if (this.client === null) {
|
||||||
|
throw new Error("game-state: readReport called without client");
|
||||||
|
}
|
||||||
|
if (!isCurrent && this.cache !== null) {
|
||||||
|
const cached = await this.cache.get<GameReport>(
|
||||||
|
HISTORY_NAMESPACE,
|
||||||
|
HISTORY_KEY_TURN(this.gameId, turn),
|
||||||
|
);
|
||||||
|
if (cached !== undefined && cached.turn === turn) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await fetchGameReport(this.client, this.gameId, turn);
|
||||||
}
|
}
|
||||||
|
|
||||||
private installVisibilityListener(): void {
|
private installVisibilityListener(): void {
|
||||||
@@ -239,6 +422,20 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
|
|||||||
return "torus";
|
return "torus";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readLastViewedTurn(
|
||||||
|
cache: Cache,
|
||||||
|
gameId: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const stored = await cache.get<number>(
|
||||||
|
PREF_NAMESPACE,
|
||||||
|
PREF_KEY_LAST_VIEWED_TURN(gameId),
|
||||||
|
);
|
||||||
|
if (typeof stored !== "number" || !Number.isFinite(stored)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
function describe(err: unknown): string {
|
function describe(err: unknown): string {
|
||||||
if (err instanceof GameStateError) {
|
if (err instanceof GameStateError) {
|
||||||
return err.message;
|
return err.message;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
Top header for the in-game shell. Composes the in-game ID strip
|
Top header for the in-game shell. Composes the in-game ID strip
|
||||||
(race name @ game name, turn N), view dropdown / hamburger, and the
|
(race name @ game name) followed by the Phase 26 turn navigator (a
|
||||||
account menu. The sidebar-toggle slot to its left appears only on
|
`← Turn N →` triplet with a popover of every turn), the view
|
||||||
tablet viewports (768–1024 px) and is wired by `+layout.svelte`.
|
dropdown / hamburger, and the account menu. The sidebar-toggle slot
|
||||||
|
to its left appears only on tablet viewports (768–1024 px) and is
|
||||||
|
wired by `+layout.svelte`.
|
||||||
|
|
||||||
The race name is read from the engine's `Report.race`, the game
|
The race name is read from the engine's `Report.race`, the game
|
||||||
name from the lobby's `GameSummary.gameName`. While either piece
|
name from the lobby's `GameSummary.gameName`. While either piece
|
||||||
@@ -22,6 +24,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
} from "$lib/game-state.svelte";
|
} 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";
|
||||||
|
import TurnNavigator from "./turn-navigator.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
@@ -44,27 +47,14 @@ absent until Phase 24 wires push-event state.
|
|||||||
const name = gameState?.gameName ?? "";
|
const name = gameState?.gameName ?? "";
|
||||||
return name === "" ? i18n.t("game.shell.unknown") : name;
|
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" data-testid="game-shell-headline">
|
||||||
<span class="headline" data-testid="game-shell-headline">
|
<span class="identity" data-testid="game-shell-identity">
|
||||||
{headline}
|
{raceName} @ {gameName}
|
||||||
</span>
|
</span>
|
||||||
|
<TurnNavigator />
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<button
|
<button
|
||||||
@@ -106,7 +96,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.headline {
|
.identity {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user