From 13e224bdb1d747dbaa2681c0b7726ac8e89a04d0 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:44:36 -0500 Subject: [PATCH 01/14] feat: add Paldeck guild and map roadmap tranche --- .github/workflows/ci.yml | 10 +- .github/workflows/ghcr.yml | 10 +- Dockerfile | 2 +- README.md | 6 +- VERSION | 2 +- backend/internal/sav/player_progress_test.go | 36 +- backend/internal/sav/types.go | 9 + backend/internal/sav/world.go | 33 ++ backend/internal/server/guild_detail.go | 32 ++ backend/internal/server/openapi.json | 11 + .../server/paldeck_guild_detail_test.go | 98 +++++ backend/internal/server/paldeck_progress.go | 36 ++ backend/internal/server/server.go | 3 + backend/internal/store/guild_detail.go | 247 +++++++++++ backend/internal/store/guild_detail_test.go | 63 +++ .../store/migrations/010_player_paldeck.sql | 19 + backend/internal/store/paldeck_progress.go | 385 ++++++++++++++++++ .../internal/store/paldeck_progress_test.go | 112 +++++ backend/internal/store/store.go | 11 + .../store/store_migration_audit_test.go | 20 +- backend/internal/store/store_test.go | 16 +- .../architecture/storage-and-migrations.md | 12 +- .../content/docs/getting-started/updating.md | 3 +- docs-site/src/content/docs/panel/guilds.md | 23 ++ .../src/content/docs/panel/pal-explorer.md | 12 + .../content/docs/panel/paldeck-progress.md | 31 ++ docs-site/src/content/docs/panel/players.md | 4 +- docs/API.md | 3 + docs/PANEL-ROADMAP.md | 14 +- docs/releases/v0.9.0.md | 35 ++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/client.ts | 8 + frontend/src/api/mock.ts | 162 +++++++- frontend/src/api/types.ts | 125 ++++++ frontend/src/app/App.tsx | 5 + frontend/src/app/mapClustering.ts | 67 +++ frontend/src/components/Shell.tsx | 4 + frontend/src/components/icons.tsx | 19 + frontend/src/routes/activity/Activity.tsx | 3 +- frontend/src/routes/guilds/Guilds.css | 33 ++ frontend/src/routes/guilds/Guilds.tsx | 114 ++++++ frontend/src/routes/map/Map.css | 26 ++ frontend/src/routes/map/Map.tsx | 157 +++++-- frontend/src/routes/paldeck/Paldeck.css | 29 ++ frontend/src/routes/paldeck/Paldeck.tsx | 146 +++++++ frontend/src/routes/paldeck/paldeckView.ts | 28 ++ frontend/src/routes/pals/Pals.tsx | 22 +- frontend/src/routes/pals/palExplorer.ts | 60 +++ frontend/src/routes/players/Players.tsx | 17 +- frontend/tests/ci-workflow.test.mjs | 23 ++ .../tests/fixtures/map-transform-1.0.json | 65 +++ frontend/tests/guild-paldeck-routes.test.mjs | 32 ++ frontend/tests/mapClustering.test.mjs | 78 ++++ frontend/tests/mapTransform.test.mjs | 43 ++ frontend/tests/pal-explorer.test.mjs | 27 +- frontend/tests/paldeck-view.test.mjs | 23 ++ 57 files changed, 2523 insertions(+), 97 deletions(-) create mode 100644 backend/internal/server/guild_detail.go create mode 100644 backend/internal/server/paldeck_guild_detail_test.go create mode 100644 backend/internal/server/paldeck_progress.go create mode 100644 backend/internal/store/guild_detail.go create mode 100644 backend/internal/store/guild_detail_test.go create mode 100644 backend/internal/store/migrations/010_player_paldeck.sql create mode 100644 backend/internal/store/paldeck_progress.go create mode 100644 backend/internal/store/paldeck_progress_test.go create mode 100644 docs-site/src/content/docs/panel/guilds.md create mode 100644 docs-site/src/content/docs/panel/paldeck-progress.md create mode 100644 docs/releases/v0.9.0.md create mode 100644 frontend/src/app/mapClustering.ts create mode 100644 frontend/src/routes/guilds/Guilds.css create mode 100644 frontend/src/routes/guilds/Guilds.tsx create mode 100644 frontend/src/routes/paldeck/Paldeck.css create mode 100644 frontend/src/routes/paldeck/Paldeck.tsx create mode 100644 frontend/src/routes/paldeck/paldeckView.ts create mode 100644 frontend/tests/fixtures/map-transform-1.0.json create mode 100644 frontend/tests/guild-paldeck-routes.test.mjs create mode 100644 frontend/tests/mapClustering.test.mjs create mode 100644 frontend/tests/paldeck-view.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a1a8f4..c32a92a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ jobs: name: Backend (go test) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum @@ -26,13 +26,13 @@ jobs: name: Frontend (build) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 with: node-version: 24 cache: npm cache-dependency-path: frontend/package-lock.json - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod cache-dependency-path: backend/go.sum diff --git a/.github/workflows/ghcr.yml b/.github/workflows/ghcr.yml index edbd89f..62e0dbe 100644 --- a/.github/workflows/ghcr.yml +++ b/.github/workflows/ghcr.yml @@ -20,8 +20,8 @@ jobs: attestations: write id-token: write steps: - - uses: actions/checkout@v4 - - uses: docker/setup-buildx-action@v3 + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v4 - name: Read panel version id: panel-version @@ -35,7 +35,7 @@ jobs: - name: Log in to GHCR if: startsWith(github.ref, 'refs/tags/v') - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -43,7 +43,7 @@ jobs: - name: Generate image metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | @@ -57,7 +57,7 @@ jobs: - name: Build id: build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile diff --git a/Dockerfile b/Dockerfile index eccdba1..aa37032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY backend/ ./ # swap the placeholder dist for the real build, then embed RUN rm -rf internal/webdist/dist COPY --from=frontend /src/frontend/dist/ internal/webdist/dist/ -ARG VERSION=0.8.0 +ARG VERSION=0.9.0 RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/palhelm ./cmd/palhelm # ---- runtime ---- diff --git a/README.md b/README.md index bb8f5ef..4daaa6a 100644 --- a/README.md +++ b/README.md @@ -103,9 +103,9 @@ scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons | `PALHELM_OODLE_LIB` | unset | path to `liboo2corelinux64.so.9` if you provide your own | | `PALHELM_INTEGRATION_RATE_LIMIT` | `60` | requests/minute per Integration API key | -Version 0.5.0 adds schema migration 009 for aggregate Game Data activity history. Back up the -complete `/data` volume before upgrading; rollback to a 0.4.x image requires restoring that -pre-upgrade backup. See [the v0.5.0 release notes](docs/releases/v0.5.0.md). +Version 0.9.0 adds schema migration 010 for save-observed per-player Paldeck progression. Back up +the complete `/data` volume before upgrading; rollback to a 0.8.x image requires restoring that +pre-upgrade backup. See [the v0.9.0 release notes](docs/releases/v0.9.0.md). ## Known limits diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/backend/internal/sav/player_progress_test.go b/backend/internal/sav/player_progress_test.go index 9de28e7..ce6f27c 100644 --- a/backend/internal/sav/player_progress_test.go +++ b/backend/internal/sav/player_progress_test.go @@ -1,6 +1,9 @@ package sav -import "testing" +import ( + "fmt" + "testing" +) func TestDecodePlayerProgressUsesAuthoritativeRecordData(t *testing.T) { data := propertyMap{ @@ -29,6 +32,12 @@ func TestDecodePlayerProgressUsesAuthoritativeRecordData(t *testing.T) { if got.PaldeckUnlocked == nil || *got.PaldeckUnlocked != 2 { t.Fatalf("PaldeckUnlocked = %v, want 2", got.PaldeckUnlocked) } + if got.PalCaptureCounts["SheepBall"] != 4 || got.PalCaptureCounts["Anubis"] != 1 || got.PalCaptureCounts["NeverCaught"] != 0 { + t.Fatalf("PalCaptureCounts = %#v", got.PalCaptureCounts) + } + if !got.PaldeckUnlockFlags["SheepBall"] || !got.PaldeckUnlockFlags["Anubis"] || got.PaldeckUnlockFlags["Unknown"] { + t.Fatalf("PaldeckUnlockFlags = %#v", got.PaldeckUnlockFlags) + } } func TestDecodePlayerProgressPreservesUnavailableVersusZero(t *testing.T) { @@ -48,4 +57,29 @@ func TestDecodePlayerProgressPreservesUnavailableVersusZero(t *testing.T) { if zero.CaptureTotal == nil || *zero.CaptureTotal != 0 || zero.UniquePalsCaptured == nil || *zero.UniquePalsCaptured != 0 || zero.PaldeckUnlocked == nil || *zero.PaldeckUnlocked != 0 { t.Fatalf("real zeros must remain present: %+v", zero) } + if zero.PalCaptureCounts == nil || len(zero.PalCaptureCounts) != 0 || zero.PaldeckUnlockFlags == nil || len(zero.PaldeckUnlockFlags) != 0 { + t.Fatalf("authoritative empty maps must remain distinguishable from unavailable: %+v", zero) + } +} + +func TestDecodePlayerProgressBoundsSpeciesMapsWithoutChangingAggregateCounters(t *testing.T) { + captures := make([]mapEntry, 2050) + unlocks := make([]mapEntry, 2050) + for i := range captures { + id := fmt.Sprintf("Species_%04d", i) + captures[i] = mapEntry{Key: id, Value: int32(1)} + unlocks[i] = mapEntry{Key: id, Value: true} + } + data := propertyMap{"RecordData": {Value: structData{Value: propertyMap{ + "PalCaptureCount": {Value: captures}, + "PaldeckUnlockFlag": {Value: unlocks}, + }}}} + var got Player + decodePlayerProgress(data, &got) + if len(got.PalCaptureCounts) != 2048 || !got.PalCaptureCountsTruncated || len(got.PaldeckUnlockFlags) != 2048 || !got.PaldeckUnlockFlagsTruncated { + t.Fatalf("bounded maps = captures %d/%v unlocks %d/%v", len(got.PalCaptureCounts), got.PalCaptureCountsTruncated, len(got.PaldeckUnlockFlags), got.PaldeckUnlockFlagsTruncated) + } + if got.UniquePalsCaptured == nil || *got.UniquePalsCaptured != 2050 || got.PaldeckUnlocked == nil || *got.PaldeckUnlocked != 2050 { + t.Fatalf("aggregate counters must describe the complete decoded maps: unique=%v unlocked=%v", got.UniquePalsCaptured, got.PaldeckUnlocked) + } } diff --git a/backend/internal/sav/types.go b/backend/internal/sav/types.go index f34a20e..359999d 100644 --- a/backend/internal/sav/types.go +++ b/backend/internal/sav/types.go @@ -59,6 +59,15 @@ type Player struct { CaptureTotal *int64 `json:"captureTotal,omitempty"` UniquePalsCaptured *int `json:"uniquePalsCaptured,omitempty"` PaldeckUnlocked *int `json:"paldeckUnlocked,omitempty"` + // PalCaptureCounts and PaldeckUnlockFlags retain the authoritative + // CharacterID-keyed RecordData maps for Paldeck progression. A nil map means + // unavailable; an empty map is an authoritative zero-entry map. The + // Truncated flags are defensive parser bounds and must be surfaced by any + // consumer rather than silently treating a partial map as complete. + PalCaptureCounts map[string]int64 `json:"palCaptureCounts,omitempty"` + PaldeckUnlockFlags map[string]bool `json:"paldeckUnlockFlags,omitempty"` + PalCaptureCountsTruncated bool `json:"palCaptureCountsTruncated,omitempty"` + PaldeckUnlockFlagsTruncated bool `json:"paldeckUnlockFlagsTruncated,omitempty"` } // Pal describes a non-player character from CharacterSaveParameterMap. diff --git a/backend/internal/sav/world.go b/backend/internal/sav/world.go index 8de58a9..d553fd6 100644 --- a/backend/internal/sav/world.go +++ b/backend/internal/sav/world.go @@ -302,6 +302,14 @@ func mergePlayer(w *World, p Player) { if p.PaldeckUnlocked != nil { w.Players[i].PaldeckUnlocked = p.PaldeckUnlocked } + if p.PalCaptureCounts != nil { + w.Players[i].PalCaptureCounts = p.PalCaptureCounts + w.Players[i].PalCaptureCountsTruncated = p.PalCaptureCountsTruncated + } + if p.PaldeckUnlockFlags != nil { + w.Players[i].PaldeckUnlockFlags = p.PaldeckUnlockFlags + w.Players[i].PaldeckUnlockFlagsTruncated = p.PaldeckUnlockFlagsTruncated + } return } } @@ -313,6 +321,7 @@ func mergePlayer(w *World, p Player) { // from the current world roster. Missing maps stay nil so API consumers can say // "unavailable" instead of presenting a misleading zero. func decodePlayerProgress(data propertyMap, p *Player) { + const maxPaldeckEntries = 2048 record, ok := propertyProperties(data, "RecordData") if !ok { return @@ -322,19 +331,43 @@ func decodePlayerProgress(data propertyMap, p *Player) { } if entries, ok := propertyMapEntries(record, "PalCaptureCount"); ok { count := 0 + p.PalCaptureCounts = make(map[string]int64, min(len(entries), maxPaldeckEntries)) for _, entry := range entries { if v, ok := numericValue(entry.Value); ok && v > 0 { count++ } + key, validKey := entry.Key.(string) + value, validValue := numericValue(entry.Value) + key = strings.TrimSpace(key) + if !validKey || key == "" || !validValue || value < 0 { + continue + } + if _, exists := p.PalCaptureCounts[key]; !exists && len(p.PalCaptureCounts) == maxPaldeckEntries { + p.PalCaptureCountsTruncated = true + continue + } + p.PalCaptureCounts[key] = value } p.UniquePalsCaptured = intPtr(count) } if entries, ok := propertyMapEntries(record, "PaldeckUnlockFlag"); ok { count := 0 + p.PaldeckUnlockFlags = make(map[string]bool, min(len(entries), maxPaldeckEntries)) for _, entry := range entries { if v, ok := entry.Value.(bool); ok && v { count++ } + key, validKey := entry.Key.(string) + value, validValue := entry.Value.(bool) + key = strings.TrimSpace(key) + if !validKey || key == "" || !validValue { + continue + } + if _, exists := p.PaldeckUnlockFlags[key]; !exists && len(p.PaldeckUnlockFlags) == maxPaldeckEntries { + p.PaldeckUnlockFlagsTruncated = true + continue + } + p.PaldeckUnlockFlags[key] = value } p.PaldeckUnlocked = intPtr(count) } diff --git a/backend/internal/server/guild_detail.go b/backend/internal/server/guild_detail.go new file mode 100644 index 0000000..8bb21ac --- /dev/null +++ b/backend/internal/server/guild_detail.go @@ -0,0 +1,32 @@ +package server + +import ( + "database/sql" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) guildDetail(w http.ResponseWriter, r *http.Request) { + rawID := chi.URLParam(r, "id") + if !integrationUIDPattern.MatchString(rawID) { + writeError(w, http.StatusNotFound, "not_found", "Guild not found.") + return + } + result, err := s.store.GuildDetail(r.Context(), rawID, time.Now()) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "not_found", "Guild not found.") + return + } + if err != nil { + internal(w, err) + return + } + online := s.poll.Online() + for index := range result.Members { + result.Members[index].Online = online[result.Members[index].UID] + } + writeJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index b14886c..688b3c8 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -20,11 +20,13 @@ "/api/v1/players": {"get": {"responses": {"200": {"description": "Players"}}}}, "/api/v1/pals": {"get": {"responses": {"200": {"description": "Viewer-safe, filtered and keyset-paginated server-wide Pal roster"}, "400": {"description": "Invalid bounded filter or cursor"}}}}, "/api/v1/players/{uid}": {"get": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player detail with save-derived Pals and bounded panel-observed activity: current session, rolling 1/7/30-day duration and count, tracking coverage, and at most 20 recent sessions"}}}}, + "/api/v1/players/{uid}/paldeck": {"get": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-fA-F-]{1,36}$"}}], "responses": {"200": {"description": "Per-player authoritative RecordData capture/unlock progression over the complete pinned catalog; null values are unknown, never inferred from owned Pals", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PlayerPaldeck"}}}}, "404": {"description": "Unknown or invalid player uid"}}}}, "/api/v1/players/{uid}/kick": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player kicked"}}}}, "/api/v1/players/{uid}/ban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player banned"}}}}, "/api/v1/players/{uid}/unban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player unbanned"}}}}, "/api/v1/whitelist": {"get": {"responses": {"200": {"description": "Whitelist"}}}, "put": {"responses": {"200": {"description": "Whitelist replaced"}}}}, "/api/v1/guilds": {"get": {"responses": {"200": {"description": "Guilds"}}}}, + "/api/v1/guilds/{id}": {"get": {"parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-fA-F-]{1,36}$"}}], "responses": {"200": {"description": "Viewer-safe current-save guild detail with members, bases, bounded associated Pals, and current-membership-attributed 30-day panel activity", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildDetail"}}}}, "404": {"description": "Unknown or invalid guild id"}}}}, "/api/v1/world": {"get": {"responses": {"200": {"description": "Save parse status"}}}}, "/api/v1/world/snapshot": {"get": {"responses": {"200": {"description": "Sanitized, memory-only live actor snapshot plus bounded session-only poller diagnostics for the authenticated panel"}}}}, "/api/v1/world/activity": {"get": {"responses": {"200": {"description": "Aggregate-only Game Data API activity history; newest sample per bounded time bucket, hard-capped at 500 rows; persists no actor identities, names, health, or locations"}, "400": {"description": "Invalid window"}}}}, @@ -32,6 +34,7 @@ "/api/v1/map/dataset": {"get": {"responses": {"200": {"description": "Map tile pyramid provenance (fetched_at/game_version/source), defaulted to a pre-1.0 marker when unset"}}}}, "/api/v1/paldeck/icon/{characterId}": {"get": {"parameters": [{"name":"characterId","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Pal preview icon image (webp or png), matched case-insensitively"}, "404": {"description": "No icon installed for this CharacterID; the frontend should fall back to an initials avatar"}}}}, "/api/v1/paldeck/icon-dataset": {"get": {"responses": {"200": {"description": "Installed pal-icon set provenance (source/fetchedAt/count) plus the full known CharacterID roster"}}}}, + "/api/v1/paldeck": {"get": {"responses": {"200": {"description": "Server union of authoritative per-player RecordData capture/unlock maps over the complete pinned catalog, with explicit decode/truncation coverage and unknown CharacterID drift", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerPaldeck"}}}}}}}, "/api/v1/console/exec": {"post": {"responses": {"200": {"description": "Command executed"}}}}, "/api/v1/console/log": {"get": {"responses": {"200": {"description": "Console history"}}}}, "/api/v1/console/saved": {"get": {"responses": {"200": {"description": "Saved commands"}}}, "post": {"responses": {"201": {"description": "Command saved"}}}}, @@ -205,6 +208,14 @@ }, "schemas": { "Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "object", "required": ["code", "message"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}, "manualCommand": {"type": "string"}}}}}, + "PaldeckCatalog": {"type":"object","required":["version","knownSpecies","observedUnknownSpecies"],"properties":{"version":{"type":"string","const":"palworld_1.0_pinned"},"knownSpecies":{"type":"integer","minimum":0},"observedUnknownSpecies":{"type":"integer","minimum":0}}}, + "PaldeckCoverage": {"type":"object","required":["source","playersTotal","playersWithCaptureCounts","playersWithUnlockFlags","captureCountsTruncated","unlockFlagsTruncated","oldestObservedAt","latestObservedAt"],"properties":{"source":{"type":"string","const":"player_save_record_data"},"playersTotal":{"type":"integer","minimum":0},"playersWithCaptureCounts":{"type":"integer","minimum":0},"playersWithUnlockFlags":{"type":"integer","minimum":0},"captureCountsTruncated":{"type":"boolean"},"unlockFlagsTruncated":{"type":"boolean"},"oldestObservedAt":{"type":["string","null"],"format":"date-time"},"latestObservedAt":{"type":["string","null"],"format":"date-time"}}}, + "PaldeckSpecies": {"type":"object","required":["characterId","displayName","known","captureCount","capturedByPlayers","unlockedByPlayers"],"properties":{"characterId":{"type":"string"},"displayName":{"type":"string"},"known":{"type":"boolean"},"captureCount":{"type":["integer","null"],"format":"int64","minimum":0},"capturedByPlayers":{"type":["integer","null"],"minimum":0},"unlockedByPlayers":{"type":["integer","null"],"minimum":0}}}, + "ServerPaldeck": {"type":"object","required":["coverage","catalog","captureTotal","uniqueSpeciesCaptured","speciesUnlocked","species"],"properties":{"coverage":{"$ref":"#/components/schemas/PaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniqueSpeciesCaptured":{"type":["integer","null"],"minimum":0},"speciesUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PaldeckSpecies"}}}}, + "PlayerPaldeckCoverage": {"type":"object","required":["source","captureCountsAvailable","unlockFlagsAvailable","captureCountsTruncated","unlockFlagsTruncated","captureObservedAt","unlockObservedAt"],"properties":{"source":{"type":"string","const":"player_save_record_data"},"captureCountsAvailable":{"type":"boolean"},"unlockFlagsAvailable":{"type":"boolean"},"captureCountsTruncated":{"type":"boolean"},"unlockFlagsTruncated":{"type":"boolean"},"captureObservedAt":{"type":["string","null"],"format":"date-time"},"unlockObservedAt":{"type":["string","null"],"format":"date-time"}}}, + "PlayerPaldeckSpecies": {"type":"object","required":["characterId","displayName","known","captureCount","unlocked"],"properties":{"characterId":{"type":"string"},"displayName":{"type":"string"},"known":{"type":"boolean"},"captureCount":{"type":["integer","null"],"format":"int64","minimum":0},"unlocked":{"type":["boolean","null"]}}}, + "PlayerPaldeck": {"type":"object","required":["player","coverage","catalog","captureTotal","uniquePalsCaptured","paldeckUnlocked","species"],"properties":{"player":{"type":"object","required":["uid","name"],"properties":{"uid":{"type":"string"},"name":{"type":"string"}}},"coverage":{"$ref":"#/components/schemas/PlayerPaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PlayerPaldeckSpecies"}}}}, + "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","location","level","palCount"],"properties":{"id":{"type":"string"},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, "Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}}, "BackupEntry": {"type": "object", "required": ["path", "sizeBytes", "modifiedAt"], "properties": {"path": {"type": "string"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "modifiedAt": {"type": "string", "format": "date-time"}}}, "BackupChange": {"type": "object", "required": ["path", "kind"], "properties": {"path": {"type": "string"}, "kind": {"type": "string", "enum": ["add", "modify", "delete"]}, "fromSize": {"type": "integer", "format": "int64", "minimum": 0}, "toSize": {"type": "integer", "format": "int64", "minimum": 0}}}, diff --git a/backend/internal/server/paldeck_guild_detail_test.go b/backend/internal/server/paldeck_guild_detail_test.go new file mode 100644 index 0000000..d63849b --- /dev/null +++ b/backend/internal/server/paldeck_guild_detail_test.go @@ -0,0 +1,98 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" + "github.com/8tp/palhelm/internal/store" +) + +func TestPaldeckAndGuildDetailAreViewerSafeTruthfulAndAuthenticated(t *testing.T) { + _, h, st := newKeyManagementTestServer(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + uid := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + guildID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + baseID := "cccccccccccccccccccccccccccccccc" + total, unique, unlocked := int64(9), 2, 3 + world := &sav.World{ + Players: []sav.Player{{UID: uid, Nickname: "Safe Member", GuildID: guildID, CaptureTotal: &total, UniquePalsCaptured: &unique, PaldeckUnlocked: &unlocked, + PalCaptureCounts: map[string]int64{"BOSS_Anubis": 2, "Unknown_Test_Pal": 1}, PaldeckUnlockFlags: map[string]bool{"BOSS_Anubis": true}}}, + Guilds: []sav.Guild{{ID: guildID, Name: "Safe Guild", AdminUID: uid, Members: []sav.GuildMember{{UID: uid, Name: "Safe Member"}}}}, + Bases: []sav.BaseCamp{{ID: baseID, GuildID: guildID, Position: &sav.Vector{X: 120, Y: 340}}}, + Pals: []sav.Pal{{InstanceID: "dddddddddddddddddddddddddddddddd", CharacterID: "BOSS_Anubis", OwnerUID: uid, BaseID: baseID, SlotIndex: -1}}, + } + if err := st.ReplaceWorld(ctx, world, now, time.Millisecond); err != nil { + t.Fatal(err) + } + if err := st.UpsertLivePlayer(ctx, store.Player{UID: uid, SteamID: "PRIVATE-STEAM", AccountName: "PRIVATE-ACCOUNT", Name: "Safe Member", Ping: 99}, now); err != nil { + t.Fatal(err) + } + if err := st.StartSession(ctx, uid, now.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + + viewer := loginAs(t, h, "viewerpass") + serverRR := sessionRequest(h, http.MethodGet, "/api/v1/paldeck", "", viewer) + if serverRR.Code != http.StatusOK { + t.Fatalf("server paldeck status=%d body=%s", serverRR.Code, serverRR.Body.String()) + } + var serverDoc store.ServerPaldeck + if err := json.Unmarshal(serverRR.Body.Bytes(), &serverDoc); err != nil { + t.Fatal(err) + } + if serverDoc.Coverage.Source != "player_save_record_data" || serverDoc.Catalog.KnownSpecies == 0 || serverDoc.Catalog.ObservedUnknownSpecies != 1 || len(serverDoc.Species) != serverDoc.Catalog.KnownSpecies+1 { + t.Fatalf("server paldeck = %#v", serverDoc) + } + + playerRR := sessionRequest(h, http.MethodGet, "/api/v1/players/"+uid+"/paldeck", "", viewer) + if playerRR.Code != http.StatusOK { + t.Fatalf("player paldeck status=%d body=%s", playerRR.Code, playerRR.Body.String()) + } + var playerDoc store.PlayerPaldeck + if err := json.Unmarshal(playerRR.Body.Bytes(), &playerDoc); err != nil { + t.Fatal(err) + } + if playerDoc.Player.UID != uid || !playerDoc.Coverage.CaptureCountsAvailable || len(playerDoc.Species) != playerDoc.Catalog.KnownSpecies+1 { + t.Fatalf("player paldeck = %#v", playerDoc) + } + + guildRR := sessionRequest(h, http.MethodGet, "/api/v1/guilds/"+guildID, "", viewer) + if guildRR.Code != http.StatusOK { + t.Fatalf("guild status=%d body=%s", guildRR.Code, guildRR.Body.String()) + } + var guildDoc store.GuildDetail + if err := json.Unmarshal(guildRR.Body.Bytes(), &guildDoc); err != nil { + t.Fatal(err) + } + if guildDoc.ID != guildID || guildDoc.MemberCount != 1 || len(guildDoc.Bases) != 1 || guildDoc.PalCount != 1 || guildDoc.Activity.Coverage != "panel_observed_sessions" || guildDoc.Activity.DurationSec < 3600 || guildDoc.Activity.DurationSec > 3602 { + t.Fatalf("guild detail = %#v", guildDoc) + } + + for path, body := range map[string]string{"server paldeck": serverRR.Body.String(), "player paldeck": playerRR.Body.String(), "guild detail": guildRR.Body.String()} { + lower := strings.ToLower(body) + for _, forbidden := range []string{"private-steam", "private-account", "steamid", "accountname", "raw_json", "rawjson", "ping", "platformid", "runtimeactor"} { + if strings.Contains(lower, forbidden) { + t.Errorf("%s leaked %q: %s", path, forbidden, body) + } + } + } + + for _, path := range []string{"/api/v1/players/not-a-guid/paldeck", "/api/v1/guilds/not-a-guid", "/api/v1/guilds/dddddddddddddddddddddddddddddddd"} { + rr := sessionRequest(h, http.MethodGet, path, "", viewer) + if rr.Code != http.StatusNotFound { + t.Errorf("%s status=%d body=%s", path, rr.Code, rr.Body.String()) + } + } + for _, path := range []string{"/api/v1/paldeck", "/api/v1/players/" + uid + "/paldeck", "/api/v1/guilds/" + guildID} { + rr := sessionRequest(h, http.MethodGet, path, "", nil) + if rr.Code != http.StatusUnauthorized { + t.Errorf("unauthenticated %s status=%d", path, rr.Code) + } + } +} diff --git a/backend/internal/server/paldeck_progress.go b/backend/internal/server/paldeck_progress.go new file mode 100644 index 0000000..15fa33d --- /dev/null +++ b/backend/internal/server/paldeck_progress.go @@ -0,0 +1,36 @@ +package server + +import ( + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" +) + +func (s *Server) serverPaldeck(w http.ResponseWriter, r *http.Request) { + result, err := s.store.ServerPaldeck(r.Context()) + if err != nil { + internal(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) playerPaldeck(w http.ResponseWriter, r *http.Request) { + rawUID := chi.URLParam(r, "uid") + if !integrationUIDPattern.MatchString(rawUID) { + writeError(w, http.StatusNotFound, "not_found", "Player not found.") + return + } + result, err := s.store.PlayerPaldeck(r.Context(), rawUID) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, http.StatusNotFound, "not_found", "Player not found.") + return + } + if err != nil { + internal(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index aa2a04f..21778d2 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -178,9 +178,12 @@ func (s *Server) routes() http.Handler { api.Get("/players", s.players) api.Get("/pals", s.pals) api.Get("/players/{uid}", s.player) + api.Get("/players/{uid}/paldeck", s.playerPaldeck) api.Get("/players/{uid}/avatar", s.playerAvatar) api.Get("/whitelist", s.whitelist) api.Get("/guilds", s.guilds) + api.Get("/guilds/{id}", s.guildDetail) + api.Get("/paldeck", s.serverPaldeck) api.Get("/world", s.world) api.Get("/world/snapshot", s.worldSnapshot) api.Get("/world/activity", s.worldActivityHistory) diff --git a/backend/internal/store/guild_detail.go b/backend/internal/store/guild_detail.go new file mode 100644 index 0000000..f34955b --- /dev/null +++ b/backend/internal/store/guild_detail.go @@ -0,0 +1,247 @@ +package store + +import ( + "context" + "database/sql" + "time" + + "github.com/8tp/palhelm/internal/paldeck" +) + +const maxGuildDetailPals = 500 + +type GuildDetailLocation struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type GuildDetailMember struct { + UID string `json:"uid"` + Name string `json:"name"` + Level int `json:"level"` + Online bool `json:"online"` + LastSeenAt *time.Time `json:"lastSeenAt"` + PlaytimeSec int64 `json:"playtimeSec"` + CaptureTotal *int64 `json:"captureTotal"` + UniquePalsCaptured *int `json:"uniquePalsCaptured"` + PaldeckUnlocked *int `json:"paldeckUnlocked"` + ObservedDurationSec int64 `json:"observedDurationSec"` + ObservedSessionCount int `json:"observedSessionCount"` + CurrentSession bool `json:"currentSession"` +} + +type GuildDetailBase struct { + ID string `json:"id"` + Location *GuildDetailLocation `json:"location"` + Level int `json:"level"` + PalCount int `json:"palCount"` +} + +type GuildDetailPal struct { + InstanceID string `json:"instanceId"` + CharacterID string `json:"characterId"` + DisplayName string `json:"displayName"` + Level int `json:"level"` + IsAlpha bool `json:"isAlpha"` + IsLucky bool `json:"isLucky"` + IsBoss bool `json:"isBoss"` + Placement string `json:"placement"` + BaseID *string `json:"baseId"` + OwnerUID string `json:"ownerUid"` + OwnerName string `json:"ownerName"` + OwnerSource string `json:"ownerSource"` + OwnerResolved bool `json:"ownerResolved"` + Association string `json:"association"` +} + +type GuildDetailActivity struct { + Coverage string `json:"coverage"` + Attribution string `json:"attribution"` + Window string `json:"window"` + Since time.Time `json:"since"` + Through time.Time `json:"through"` + TrackingSince *time.Time `json:"trackingSince"` + AnalysisTruncated bool `json:"analysisTruncated"` + DurationSec int64 `json:"durationSec"` + SessionCount int `json:"sessionCount"` + ActivePlayers int `json:"activePlayers"` +} + +type GuildDetail struct { + ID string `json:"id"` + Name string `json:"name"` + AdminUID string `json:"adminUid"` + MemberCount int `json:"memberCount"` + Members []GuildDetailMember `json:"members"` + Bases []GuildDetailBase `json:"bases"` + PalCount int `json:"palCount"` + PalsTruncated bool `json:"palsTruncated"` + Pals []GuildDetailPal `json:"pals"` + Activity GuildDetailActivity `json:"activity"` +} + +// GuildDetail returns a bounded current-save projection. Pals are associated only by an exact +// guild base join or a current guild member's stored owner UID; activity is attributed only to +// the current member roster and is explicitly limited to panel-observed sessions. +func (s *Store) GuildDetail(ctx context.Context, guildID string, now time.Time) (GuildDetail, error) { + now = now.UTC().Truncate(time.Second) + guildID = NormalizeUID(guildID) + result := GuildDetail{ + ID: guildID, Members: []GuildDetailMember{}, Bases: []GuildDetailBase{}, Pals: []GuildDetailPal{}, + Activity: GuildDetailActivity{Coverage: "panel_observed_sessions", Attribution: "current_guild_membership", Window: "30d", Since: now.Add(-30 * 24 * time.Hour), Through: now}, + } + if err := s.db.QueryRowContext(ctx, `SELECT name,admin_uid FROM guilds WHERE id=?`, guildID).Scan(&result.Name, &result.AdminUID); err != nil { + return GuildDetail{}, err + } + + rows, err := s.db.QueryContext(ctx, `SELECT gm.player_uid,COALESCE(NULLIF(p.name,''),gm.name,''),COALESCE(p.level,0),p.last_seen,COALESCE(p.playtime_sec,0),p.capture_total,p.unique_pals_captured,p.paldeck_unlocked +FROM guild_members gm LEFT JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_id=? ORDER BY COALESCE(NULLIF(p.name,''),gm.name,''),gm.player_uid`, guildID) + if err != nil { + return GuildDetail{}, err + } + memberIndex := map[string]int{} + for rows.Next() { + var member GuildDetailMember + var lastSeen, capture, unique, unlocked sql.NullInt64 + if err = rows.Scan(&member.UID, &member.Name, &member.Level, &lastSeen, &member.PlaytimeSec, &capture, &unique, &unlocked); err != nil { + rows.Close() + return GuildDetail{}, err + } + if lastSeen.Valid { + v := time.Unix(lastSeen.Int64, 0).UTC() + member.LastSeenAt = &v + } + if capture.Valid { + member.CaptureTotal = &capture.Int64 + } + if unique.Valid { + v := int(unique.Int64) + member.UniquePalsCaptured = &v + } + if unlocked.Valid { + v := int(unlocked.Int64) + member.PaldeckUnlocked = &v + } + memberIndex[member.UID] = len(result.Members) + result.Members = append(result.Members, member) + } + if err = rows.Close(); err != nil { + return GuildDetail{}, err + } + result.MemberCount = len(result.Members) + + baseRows, err := s.db.QueryContext(ctx, `SELECT b.id,b.x,b.y,b.level,COUNT(p.instance_id) FROM bases b LEFT JOIN pals p ON p.base_id=b.id WHERE b.guild_id=? GROUP BY b.id,b.x,b.y,b.level ORDER BY b.id`, guildID) + if err != nil { + return GuildDetail{}, err + } + for baseRows.Next() { + var base GuildDetailBase + var x, y sql.NullFloat64 + if err = baseRows.Scan(&base.ID, &x, &y, &base.Level, &base.PalCount); err != nil { + baseRows.Close() + return GuildDetail{}, err + } + if x.Valid && y.Valid { + base.Location = &GuildDetailLocation{X: x.Float64, Y: y.Float64} + } + result.Bases = append(result.Bases, base) + } + if err = baseRows.Close(); err != nil { + return GuildDetail{}, err + } + + if err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM pals p LEFT JOIN guild_members gm ON gm.player_uid=p.owner_uid AND gm.guild_id=? LEFT JOIN bases b ON b.id=p.base_id AND b.guild_id=? WHERE b.id IS NOT NULL OR gm.player_uid IS NOT NULL`, guildID, guildID).Scan(&result.PalCount); err != nil { + return GuildDetail{}, err + } + result.PalsTruncated = result.PalCount > maxGuildDetailPals + palRows, err := s.db.QueryContext(ctx, `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.box_page,p.base_id,p.owner_uid,COALESCE(owner.name,''),p.owner_source,owner.uid IS NOT NULL,b.id IS NOT NULL +FROM pals p +LEFT JOIN players owner ON owner.uid=p.owner_uid +LEFT JOIN guild_members gm ON gm.player_uid=p.owner_uid AND gm.guild_id=? +LEFT JOIN bases b ON b.id=p.base_id AND b.guild_id=? +WHERE b.id IS NOT NULL OR gm.player_uid IS NOT NULL +ORDER BY p.display_name,p.instance_id LIMIT ?`, guildID, guildID, maxGuildDetailPals) + if err != nil { + return GuildDetail{}, err + } + for palRows.Next() { + var pal GuildDetailPal + var inParty, hasBase bool + var boxPage sql.NullInt64 + var baseID string + if err = palRows.Scan(&pal.InstanceID, &pal.CharacterID, &pal.DisplayName, &pal.Level, &pal.IsAlpha, &pal.IsLucky, &inParty, &boxPage, &baseID, &pal.OwnerUID, &pal.OwnerName, &pal.OwnerSource, &pal.OwnerResolved, &hasBase); err != nil { + palRows.Close() + return GuildDetail{}, err + } + pal.IsBoss = paldeck.IsBossID(pal.CharacterID) + switch { + case inParty: + pal.Placement = "party" + case boxPage.Valid: + pal.Placement = "box" + case baseID != "": + pal.Placement = "base" + default: + pal.Placement = "unknown" + } + if baseID != "" { + pal.BaseID = &baseID + } + if hasBase { + pal.Association = "guild_base" + } else { + pal.Association = "current_member_owner" + } + result.Pals = append(result.Pals, pal) + } + if err = palRows.Close(); err != nil { + return GuildDetail{}, err + } + if len(memberIndex) == 0 { + return result, nil + } + var tracking sql.NullInt64 + if err = s.db.QueryRowContext(ctx, `SELECT MIN(join_at) FROM sessions WHERE player_uid IN (SELECT player_uid FROM guild_members WHERE guild_id=?)`, guildID).Scan(&tracking); err != nil { + return GuildDetail{}, err + } + if tracking.Valid { + v := time.Unix(tracking.Int64, 0).UTC() + result.Activity.TrackingSince = &v + } + sessionRows, err := s.db.QueryContext(ctx, `SELECT player_uid,join_at,leave_at FROM sessions WHERE player_uid IN (SELECT player_uid FROM guild_members WHERE guild_id=?) AND join_at? ORDER BY join_at,id LIMIT ?`, guildID, now.Unix(), now.Unix(), result.Activity.Since.Unix(), maxActivityIntervals+1) + if err != nil { + return GuildDetail{}, err + } + active := map[string]struct{}{} + for sessionRows.Next() { + if result.Activity.SessionCount == maxActivityIntervals { + result.Activity.AnalysisTruncated = true + break + } + var uid string + var joined int64 + var left sql.NullInt64 + if err = sessionRows.Scan(&uid, &joined, &left); err != nil { + sessionRows.Close() + return GuildDetail{}, err + } + end := now.Unix() + if left.Valid { + end = min(end, left.Int64) + } + duration := max(int64(0), end-max(joined, result.Activity.Since.Unix())) + result.Activity.DurationSec += duration + result.Activity.SessionCount++ + active[uid] = struct{}{} + if index, ok := memberIndex[uid]; ok { + result.Members[index].ObservedDurationSec += duration + result.Members[index].ObservedSessionCount++ + result.Members[index].CurrentSession = result.Members[index].CurrentSession || !left.Valid + } + } + if err = sessionRows.Close(); err != nil { + return GuildDetail{}, err + } + result.Activity.ActivePlayers = len(active) + return result, nil +} diff --git a/backend/internal/store/guild_detail_test.go b/backend/internal/store/guild_detail_test.go new file mode 100644 index 0000000..b9b9c7c --- /dev/null +++ b/backend/internal/store/guild_detail_test.go @@ -0,0 +1,63 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" +) + +func TestGuildDetailUsesExactCurrentSaveLinksAndClampedObservedActivity(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "guild.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + member, outsider := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + guildID, baseID := "cccccccccccccccccccccccccccccccc", "dddddddddddddddddddddddddddddddd" + world := &sav.World{ + Players: []sav.Player{{UID: member, Nickname: "Member", GuildID: guildID}, {UID: outsider, Nickname: "Outsider"}}, + Guilds: []sav.Guild{{ID: guildID, Name: "Guild", AdminUID: member, Members: []sav.GuildMember{{UID: member, Name: "Member"}}}}, + Bases: []sav.BaseCamp{{ID: baseID, GuildID: guildID, Position: &sav.Vector{X: 100, Y: 200}}}, + Pals: []sav.Pal{ + {InstanceID: "11111111111111111111111111111111", CharacterID: "SheepBall", OwnerUID: member, SlotIndex: -1}, + {InstanceID: "22222222222222222222222222222222", CharacterID: "BOSS_Anubis", OwnerUID: outsider, BaseID: baseID, SlotIndex: -1}, + {InstanceID: "33333333333333333333333333333333", CharacterID: "ChickenPal", OwnerUID: outsider, SlotIndex: -1}, + }, + } + if err = s.ReplaceWorld(ctx, world, now, 0); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`INSERT INTO sessions(player_uid,join_at,leave_at) VALUES(?,?,?),(?,?,?)`, member, now.Add(-40*24*time.Hour).Unix(), now.Add(-20*24*time.Hour).Unix(), member, now.Add(-time.Hour).Unix(), nil); err != nil { + t.Fatal(err) + } + + detail, err := s.GuildDetail(ctx, guildID, now) + if err != nil { + t.Fatal(err) + } + if detail.Name != "Guild" || detail.MemberCount != 1 || len(detail.Bases) != 1 || detail.Bases[0].Location == nil || detail.Bases[0].Location.X != 100 || detail.Bases[0].PalCount != 1 { + t.Fatalf("guild detail = %#v", detail) + } + if detail.PalCount != 2 || len(detail.Pals) != 2 || detail.PalsTruncated { + t.Fatalf("associated pals = count %d rows %#v truncated=%v", detail.PalCount, detail.Pals, detail.PalsTruncated) + } + associations := map[string]string{} + for _, pal := range detail.Pals { + associations[pal.CharacterID] = pal.Association + } + if associations["SheepBall"] != "current_member_owner" || associations["BOSS_Anubis"] != "guild_base" || associations["ChickenPal"] != "" { + t.Fatalf("associations = %#v", associations) + } + wantDuration := int64((10*24*time.Hour + time.Hour) / time.Second) + if detail.Activity.Coverage != "panel_observed_sessions" || detail.Activity.Attribution != "current_guild_membership" || detail.Activity.DurationSec != wantDuration || detail.Activity.SessionCount != 2 || detail.Activity.ActivePlayers != 1 { + t.Fatalf("activity = %#v, want duration %d", detail.Activity, wantDuration) + } + if detail.Members[0].ObservedDurationSec != wantDuration || detail.Members[0].ObservedSessionCount != 2 || !detail.Members[0].CurrentSession { + t.Fatalf("member activity = %#v", detail.Members[0]) + } +} diff --git a/backend/internal/store/migrations/010_player_paldeck.sql b/backend/internal/store/migrations/010_player_paldeck.sql new file mode 100644 index 0000000..f38cf7f --- /dev/null +++ b/backend/internal/store/migrations/010_player_paldeck.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS player_paldeck ( + player_uid TEXT NOT NULL, + character_id TEXT NOT NULL, + capture_count INTEGER, + unlocked INTEGER, + PRIMARY KEY(player_uid, character_id) +); + +CREATE TABLE IF NOT EXISTS player_paldeck_state ( + player_uid TEXT PRIMARY KEY, + capture_counts_available INTEGER NOT NULL DEFAULT 0, + unlock_flags_available INTEGER NOT NULL DEFAULT 0, + capture_counts_truncated INTEGER NOT NULL DEFAULT 0, + unlock_flags_truncated INTEGER NOT NULL DEFAULT 0, + capture_observed_at INTEGER, + unlock_observed_at INTEGER +); + +CREATE INDEX IF NOT EXISTS player_paldeck_character ON player_paldeck(character_id); diff --git a/backend/internal/store/paldeck_progress.go b/backend/internal/store/paldeck_progress.go new file mode 100644 index 0000000..ce30ba0 --- /dev/null +++ b/backend/internal/store/paldeck_progress.go @@ -0,0 +1,385 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "math" + "sort" + "strings" + "time" + + "github.com/8tp/palhelm/internal/paldeck" + "github.com/8tp/palhelm/internal/sav" +) + +const paldeckCoverageSource = "player_save_record_data" + +type PaldeckSpecies struct { + CharacterID string `json:"characterId"` + DisplayName string `json:"displayName"` + Known bool `json:"known"` + CaptureCount *int64 `json:"captureCount"` + CapturedByPlayers *int `json:"capturedByPlayers"` + UnlockedByPlayers *int `json:"unlockedByPlayers"` +} + +type PlayerPaldeckSpecies struct { + CharacterID string `json:"characterId"` + DisplayName string `json:"displayName"` + Known bool `json:"known"` + CaptureCount *int64 `json:"captureCount"` + Unlocked *bool `json:"unlocked"` +} + +type PaldeckCatalog struct { + Version string `json:"version"` + KnownSpecies int `json:"knownSpecies"` + ObservedUnknownSpecies int `json:"observedUnknownSpecies"` +} + +type PaldeckCoverage struct { + Source string `json:"source"` + PlayersTotal int `json:"playersTotal"` + PlayersWithCaptureCounts int `json:"playersWithCaptureCounts"` + PlayersWithUnlockFlags int `json:"playersWithUnlockFlags"` + CaptureCountsTruncated bool `json:"captureCountsTruncated"` + UnlockFlagsTruncated bool `json:"unlockFlagsTruncated"` + OldestObservedAt *time.Time `json:"oldestObservedAt"` + LatestObservedAt *time.Time `json:"latestObservedAt"` +} + +type ServerPaldeck struct { + Coverage PaldeckCoverage `json:"coverage"` + Catalog PaldeckCatalog `json:"catalog"` + CaptureTotal *int64 `json:"captureTotal"` + UniqueSpeciesCaptured *int `json:"uniqueSpeciesCaptured"` + SpeciesUnlocked *int `json:"speciesUnlocked"` + Species []PaldeckSpecies `json:"species"` +} + +type PlayerPaldeckCoverage struct { + Source string `json:"source"` + CaptureCountsAvailable bool `json:"captureCountsAvailable"` + UnlockFlagsAvailable bool `json:"unlockFlagsAvailable"` + CaptureCountsTruncated bool `json:"captureCountsTruncated"` + UnlockFlagsTruncated bool `json:"unlockFlagsTruncated"` + CaptureObservedAt *time.Time `json:"captureObservedAt"` + UnlockObservedAt *time.Time `json:"unlockObservedAt"` +} + +type PlayerPaldeckIdentity struct { + UID string `json:"uid"` + Name string `json:"name"` +} + +type PlayerPaldeck struct { + Player PlayerPaldeckIdentity `json:"player"` + Coverage PlayerPaldeckCoverage `json:"coverage"` + Catalog PaldeckCatalog `json:"catalog"` + CaptureTotal *int64 `json:"captureTotal"` + UniquePalsCaptured *int `json:"uniquePalsCaptured"` + PaldeckUnlocked *int `json:"paldeckUnlocked"` + Species []PlayerPaldeckSpecies `json:"species"` +} + +// replacePlayerPaldeck persists only authoritative RecordData maps. A nil map leaves the last +// successful observation untouched; an empty non-nil map authoritatively clears that side. +func replacePlayerPaldeck(ctx context.Context, tx *sql.Tx, p sav.Player, at time.Time) error { + if p.PalCaptureCounts == nil && p.PaldeckUnlockFlags == nil { + return nil + } + uid := NormalizeUID(p.UID) + if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO player_paldeck_state(player_uid) VALUES(?)`, uid); err != nil { + return err + } + if p.PalCaptureCounts != nil { + if _, err := tx.ExecContext(ctx, `UPDATE player_paldeck SET capture_count=NULL WHERE player_uid=?`, uid); err != nil { + return err + } + counts := normalizeCaptureCounts(p.PalCaptureCounts) + for characterID, count := range counts { + if _, err := tx.ExecContext(ctx, `INSERT INTO player_paldeck(player_uid,character_id,capture_count) VALUES(?,?,?) ON CONFLICT(player_uid,character_id) DO UPDATE SET capture_count=excluded.capture_count`, uid, characterID, count); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE player_paldeck_state SET capture_counts_available=1,capture_counts_truncated=?,capture_observed_at=? WHERE player_uid=?`, p.PalCaptureCountsTruncated, at.Unix(), uid); err != nil { + return err + } + } + if p.PaldeckUnlockFlags != nil { + if _, err := tx.ExecContext(ctx, `UPDATE player_paldeck SET unlocked=NULL WHERE player_uid=?`, uid); err != nil { + return err + } + flags := normalizeUnlockFlags(p.PaldeckUnlockFlags) + for characterID, unlocked := range flags { + if _, err := tx.ExecContext(ctx, `INSERT INTO player_paldeck(player_uid,character_id,unlocked) VALUES(?,?,?) ON CONFLICT(player_uid,character_id) DO UPDATE SET unlocked=excluded.unlocked`, uid, characterID, unlocked); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `UPDATE player_paldeck_state SET unlock_flags_available=1,unlock_flags_truncated=?,unlock_observed_at=? WHERE player_uid=?`, p.PaldeckUnlockFlagsTruncated, at.Unix(), uid); err != nil { + return err + } + } + _, err := tx.ExecContext(ctx, `DELETE FROM player_paldeck WHERE player_uid=? AND capture_count IS NULL AND unlocked IS NULL`, uid) + return err +} + +func normalizePaldeckCharacterID(value string) string { + return strings.ToLower(strings.TrimSpace(paldeck.BaseCharacterID(value))) +} + +func normalizeCaptureCounts(input map[string]int64) map[string]int64 { + out := make(map[string]int64, len(input)) + for raw, value := range input { + id := normalizePaldeckCharacterID(raw) + if id == "" || value < 0 { + continue + } + if value > math.MaxInt64-out[id] { + out[id] = math.MaxInt64 + } else { + out[id] += value + } + } + return out +} + +func normalizeUnlockFlags(input map[string]bool) map[string]bool { + out := make(map[string]bool, len(input)) + for raw, value := range input { + id := normalizePaldeckCharacterID(raw) + if id != "" { + out[id] = out[id] || value + } + } + return out +} + +func (s *Store) ServerPaldeck(ctx context.Context) (ServerPaldeck, error) { + result := ServerPaldeck{Coverage: PaldeckCoverage{Source: paldeckCoverageSource}, Catalog: PaldeckCatalog{Version: "palworld_1.0_pinned"}, Species: []PaldeckSpecies{}} + var oldest, latest sql.NullInt64 + err := s.db.QueryRowContext(ctx, `SELECT + (SELECT COUNT(*) FROM players), + COALESCE(SUM(capture_counts_available),0),COALESCE(SUM(unlock_flags_available),0), + COALESCE(MAX(capture_counts_truncated),0),COALESCE(MAX(unlock_flags_truncated),0), + MIN(CASE WHEN capture_observed_at IS NULL THEN unlock_observed_at WHEN unlock_observed_at IS NULL THEN capture_observed_at ELSE MIN(capture_observed_at,unlock_observed_at) END), + MAX(CASE WHEN capture_observed_at IS NULL THEN unlock_observed_at WHEN unlock_observed_at IS NULL THEN capture_observed_at ELSE MAX(capture_observed_at,unlock_observed_at) END) +FROM player_paldeck_state`).Scan(&result.Coverage.PlayersTotal, &result.Coverage.PlayersWithCaptureCounts, &result.Coverage.PlayersWithUnlockFlags, &result.Coverage.CaptureCountsTruncated, &result.Coverage.UnlockFlagsTruncated, &oldest, &latest) + if err != nil { + return ServerPaldeck{}, err + } + if oldest.Valid { + v := time.Unix(oldest.Int64, 0).UTC() + result.Coverage.OldestObservedAt = &v + } + if latest.Valid { + v := time.Unix(latest.Int64, 0).UTC() + result.Coverage.LatestObservedAt = &v + } + var aggregate sql.NullInt64 + var aggregatePlayers int + if err = s.db.QueryRowContext(ctx, `SELECT SUM(capture_total),COUNT(capture_total) FROM players`).Scan(&aggregate, &aggregatePlayers); err != nil { + return ServerPaldeck{}, err + } + if aggregatePlayers > 0 && aggregate.Valid { + result.CaptureTotal = &aggregate.Int64 + } + rows, err := s.db.QueryContext(ctx, `SELECT character_id,SUM(capture_count),COUNT(CASE WHEN capture_count>0 THEN 1 END),COUNT(capture_count),COUNT(CASE WHEN unlocked=1 THEN 1 END),COUNT(unlocked) FROM player_paldeck GROUP BY character_id ORDER BY character_id LIMIT 2048`) + if err != nil { + return ServerPaldeck{}, err + } + defer rows.Close() + observed := map[string]PaldeckSpecies{} + captured, unlocked := 0, 0 + for rows.Next() { + var species PaldeckSpecies + var captureCount sql.NullInt64 + var capturedBy, captureRows, unlockedBy, unlockRows int + if err = rows.Scan(&species.CharacterID, &captureCount, &capturedBy, &captureRows, &unlockedBy, &unlockRows); err != nil { + return ServerPaldeck{}, err + } + species.DisplayName = paldeck.Name(species.CharacterID) + if captureRows > 0 || result.Coverage.PlayersWithCaptureCounts > 0 && !result.Coverage.CaptureCountsTruncated { + value := captureCount.Int64 + species.CaptureCount, species.CapturedByPlayers = &value, &capturedBy + } + if unlockRows > 0 || result.Coverage.PlayersWithUnlockFlags > 0 && !result.Coverage.UnlockFlagsTruncated { + species.UnlockedByPlayers = &unlockedBy + } + if captureCount.Valid && captureCount.Int64 > 0 { + captured++ + } + if unlockedBy > 0 { + unlocked++ + } + observed[species.CharacterID] = species + } + if err = rows.Err(); err != nil { + return ServerPaldeck{}, err + } + if result.Coverage.PlayersWithCaptureCounts > 0 { + result.UniqueSpeciesCaptured = &captured + } + if result.Coverage.PlayersWithUnlockFlags > 0 { + result.SpeciesUnlocked = &unlocked + } + known := paldeckCatalog() + result.Catalog.KnownSpecies = len(known) + for _, entry := range known { + species, ok := observed[entry.CharacterID] + if ok { + delete(observed, entry.CharacterID) + } else { + species = PaldeckSpecies{CharacterID: entry.CharacterID, DisplayName: entry.DisplayName} + if result.Coverage.PlayersWithCaptureCounts > 0 && !result.Coverage.CaptureCountsTruncated { + zero, players := int64(0), 0 + species.CaptureCount, species.CapturedByPlayers = &zero, &players + } + if result.Coverage.PlayersWithUnlockFlags > 0 && !result.Coverage.UnlockFlagsTruncated { + zero := 0 + species.UnlockedByPlayers = &zero + } + } + if species.CaptureCount == nil && result.Coverage.PlayersWithCaptureCounts > 0 && !result.Coverage.CaptureCountsTruncated { + zero, players := int64(0), 0 + species.CaptureCount, species.CapturedByPlayers = &zero, &players + } + if species.UnlockedByPlayers == nil && result.Coverage.PlayersWithUnlockFlags > 0 && !result.Coverage.UnlockFlagsTruncated { + zero := 0 + species.UnlockedByPlayers = &zero + } + species.Known = true + result.Species = append(result.Species, species) + } + result.Catalog.ObservedUnknownSpecies = len(observed) + for _, species := range observed { + if species.CaptureCount == nil && result.Coverage.PlayersWithCaptureCounts > 0 && !result.Coverage.CaptureCountsTruncated { + zero, players := int64(0), 0 + species.CaptureCount, species.CapturedByPlayers = &zero, &players + } + if species.UnlockedByPlayers == nil && result.Coverage.PlayersWithUnlockFlags > 0 && !result.Coverage.UnlockFlagsTruncated { + zero := 0 + species.UnlockedByPlayers = &zero + } + result.Species = append(result.Species, species) + } + sort.Slice(result.Species, func(i, j int) bool { return result.Species[i].DisplayName < result.Species[j].DisplayName }) + return result, nil +} + +func (s *Store) PlayerPaldeck(ctx context.Context, uid string) (PlayerPaldeck, error) { + p, err := s.PlayerByUID(ctx, uid) + if err != nil { + return PlayerPaldeck{}, err + } + result := PlayerPaldeck{ + Player: PlayerPaldeckIdentity{UID: p.UID, Name: p.Name}, + Coverage: PlayerPaldeckCoverage{Source: paldeckCoverageSource}, + Catalog: PaldeckCatalog{Version: "palworld_1.0_pinned"}, + CaptureTotal: p.CaptureTotal, UniquePalsCaptured: p.UniquePalsCaptured, PaldeckUnlocked: p.PaldeckUnlocked, + Species: []PlayerPaldeckSpecies{}, + } + var captureAt, unlockAt sql.NullInt64 + err = s.db.QueryRowContext(ctx, `SELECT capture_counts_available,unlock_flags_available,capture_counts_truncated,unlock_flags_truncated,capture_observed_at,unlock_observed_at FROM player_paldeck_state WHERE player_uid=?`, p.UID).Scan( + &result.Coverage.CaptureCountsAvailable, &result.Coverage.UnlockFlagsAvailable, &result.Coverage.CaptureCountsTruncated, &result.Coverage.UnlockFlagsTruncated, &captureAt, &unlockAt) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return PlayerPaldeck{}, err + } + if captureAt.Valid { + v := time.Unix(captureAt.Int64, 0).UTC() + result.Coverage.CaptureObservedAt = &v + } + if unlockAt.Valid { + v := time.Unix(unlockAt.Int64, 0).UTC() + result.Coverage.UnlockObservedAt = &v + } + rows, err := s.db.QueryContext(ctx, `SELECT character_id,capture_count,unlocked FROM player_paldeck WHERE player_uid=? ORDER BY character_id LIMIT 2048`, p.UID) + if err != nil { + return PlayerPaldeck{}, err + } + defer rows.Close() + observed := map[string]PlayerPaldeckSpecies{} + for rows.Next() { + var species PlayerPaldeckSpecies + var capture sql.NullInt64 + var unlocked sql.NullBool + if err = rows.Scan(&species.CharacterID, &capture, &unlocked); err != nil { + return PlayerPaldeck{}, err + } + species.DisplayName = paldeck.Name(species.CharacterID) + if capture.Valid { + species.CaptureCount = &capture.Int64 + } + if unlocked.Valid { + species.Unlocked = &unlocked.Bool + } + observed[species.CharacterID] = species + } + if err = rows.Err(); err != nil { + return PlayerPaldeck{}, err + } + known := paldeckCatalog() + result.Catalog.KnownSpecies = len(known) + for _, entry := range known { + species, ok := observed[entry.CharacterID] + if ok { + delete(observed, entry.CharacterID) + } else { + species = PlayerPaldeckSpecies{CharacterID: entry.CharacterID, DisplayName: entry.DisplayName} + if result.Coverage.CaptureCountsAvailable && !result.Coverage.CaptureCountsTruncated { + zero := int64(0) + species.CaptureCount = &zero + } + if result.Coverage.UnlockFlagsAvailable && !result.Coverage.UnlockFlagsTruncated { + value := false + species.Unlocked = &value + } + } + if species.CaptureCount == nil && result.Coverage.CaptureCountsAvailable && !result.Coverage.CaptureCountsTruncated { + zero := int64(0) + species.CaptureCount = &zero + } + if species.Unlocked == nil && result.Coverage.UnlockFlagsAvailable && !result.Coverage.UnlockFlagsTruncated { + value := false + species.Unlocked = &value + } + species.Known = true + result.Species = append(result.Species, species) + } + result.Catalog.ObservedUnknownSpecies = len(observed) + for _, species := range observed { + if species.CaptureCount == nil && result.Coverage.CaptureCountsAvailable && !result.Coverage.CaptureCountsTruncated { + zero := int64(0) + species.CaptureCount = &zero + } + if species.Unlocked == nil && result.Coverage.UnlockFlagsAvailable && !result.Coverage.UnlockFlagsTruncated { + value := false + species.Unlocked = &value + } + result.Species = append(result.Species, species) + } + sort.Slice(result.Species, func(i, j int) bool { return result.Species[i].DisplayName < result.Species[j].DisplayName }) + return result, nil +} + +type paldeckCatalogEntry struct { + CharacterID string + DisplayName string +} + +func paldeckCatalog() []paldeckCatalogEntry { + byID := map[string]string{} + for _, entry := range paldeck.All() { + id := normalizePaldeckCharacterID(entry.ID) + if id != "" { + byID[id] = entry.Name + } + } + out := make([]paldeckCatalogEntry, 0, len(byID)) + for id, name := range byID { + out = append(out, paldeckCatalogEntry{CharacterID: id, DisplayName: name}) + } + sort.Slice(out, func(i, j int) bool { return out[i].CharacterID < out[j].CharacterID }) + return out +} diff --git a/backend/internal/store/paldeck_progress_test.go b/backend/internal/store/paldeck_progress_test.go new file mode 100644 index 0000000..c0c71e2 --- /dev/null +++ b/backend/internal/store/paldeck_progress_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" +) + +func TestPaldeckProgressPersistsAuthoritativeMapsAndNormalizesBossSpecies(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "paldeck.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + at := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + totalA, uniqueA, unlockedA := int64(10), 3, 2 + totalB, uniqueB, unlockedB := int64(7), 1, 1 + world := &sav.World{Players: []sav.Player{ + {UID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Nickname: "A", CaptureTotal: &totalA, UniquePalsCaptured: &uniqueA, PaldeckUnlocked: &unlockedA, + PalCaptureCounts: map[string]int64{"SheepBall": 2, "BOSS_Anubis": 1, "Unknown_One": 4}, PaldeckUnlockFlags: map[string]bool{"SheepBall": true, "BOSS_Anubis": true}}, + {UID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Nickname: "B", CaptureTotal: &totalB, UniquePalsCaptured: &uniqueB, PaldeckUnlocked: &unlockedB, + PalCaptureCounts: map[string]int64{"sheepball": 3}, PaldeckUnlockFlags: map[string]bool{"SheepBall": true}}, + }} + if err = s.ReplaceWorld(ctx, world, at, time.Millisecond); err != nil { + t.Fatal(err) + } + + server, err := s.ServerPaldeck(ctx) + if err != nil { + t.Fatal(err) + } + if server.Coverage.Source != paldeckCoverageSource || server.Coverage.PlayersTotal != 2 || server.Coverage.PlayersWithCaptureCounts != 2 || server.Coverage.PlayersWithUnlockFlags != 2 { + t.Fatalf("coverage = %#v", server.Coverage) + } + if server.CaptureTotal == nil || *server.CaptureTotal != 17 || server.Catalog.KnownSpecies == 0 || server.Catalog.ObservedUnknownSpecies != 1 || len(server.Species) != server.Catalog.KnownSpecies+1 { + t.Fatalf("server paldeck = %#v", server) + } + byID := map[string]PaldeckSpecies{} + for _, species := range server.Species { + byID[species.CharacterID] = species + } + if got := byID["sheepball"]; got.CaptureCount == nil || *got.CaptureCount != 5 || got.CapturedByPlayers == nil || *got.CapturedByPlayers != 2 || got.UnlockedByPlayers == nil || *got.UnlockedByPlayers != 2 || !got.Known { + t.Fatalf("Lamball aggregate = %#v", got) + } + if got := byID["anubis"]; got.CaptureCount == nil || *got.CaptureCount != 1 { + t.Fatalf("boss-normalized Anubis = %#v", got) + } + if _, exists := byID["boss_anubis"]; exists { + t.Fatal("boss variant leaked as a duplicate Paldeck species") + } + if got := byID["unknown_one"]; got.Known || got.CaptureCount == nil || *got.CaptureCount != 4 { + t.Fatalf("unknown observed species = %#v", got) + } + + player, err := s.PlayerPaldeck(ctx, world.Players[0].UID) + if err != nil { + t.Fatal(err) + } + if !player.Coverage.CaptureCountsAvailable || !player.Coverage.UnlockFlagsAvailable || player.Coverage.CaptureObservedAt == nil || !player.Coverage.CaptureObservedAt.Equal(at) { + t.Fatalf("player coverage = %#v", player.Coverage) + } + playerByID := map[string]PlayerPaldeckSpecies{} + for _, species := range player.Species { + playerByID[species.CharacterID] = species + } + if absent := playerByID["alpaca"]; absent.CaptureCount == nil || *absent.CaptureCount != 0 || absent.Unlocked == nil || *absent.Unlocked { + t.Fatalf("known absent species must be authoritative zero/false: %#v", absent) + } +} + +func TestPaldeckProgressPreservesUnavailableAndClearsAuthoritativeEmptyMap(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "paldeck.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + uid := "cccccccccccccccccccccccccccccccc" + first := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + if err = s.ReplaceWorld(ctx, &sav.World{Players: []sav.Player{{UID: uid, PalCaptureCounts: map[string]int64{"SheepBall": 2}, PaldeckUnlockFlags: map[string]bool{"SheepBall": true}}}}, first, 0); err != nil { + t.Fatal(err) + } + // A missing map is not an authoritative clear, matching the aggregate progression contract. + if err = s.ReplaceWorld(ctx, &sav.World{Players: []sav.Player{{UID: uid}}}, first.Add(time.Hour), 0); err != nil { + t.Fatal(err) + } + got, err := s.PlayerPaldeck(ctx, uid) + if err != nil || got.Coverage.CaptureObservedAt == nil || !got.Coverage.CaptureObservedAt.Equal(first) { + t.Fatalf("unavailable map erased prior observation: %#v, %v", got, err) + } + // An empty non-nil map is authoritative and clears capture counts while retaining unlocks. + second := first.Add(2 * time.Hour) + if err = s.ReplaceWorld(ctx, &sav.World{Players: []sav.Player{{UID: uid, PalCaptureCounts: map[string]int64{}}}}, second, 0); err != nil { + t.Fatal(err) + } + got, err = s.PlayerPaldeck(ctx, uid) + if err != nil { + t.Fatal(err) + } + if got.Coverage.CaptureObservedAt == nil || !got.Coverage.CaptureObservedAt.Equal(second) || got.Coverage.UnlockObservedAt == nil || !got.Coverage.UnlockObservedAt.Equal(first) { + t.Fatalf("independent map observations = %#v", got.Coverage) + } + for _, species := range got.Species { + if species.CharacterID == "sheepball" && (species.CaptureCount == nil || *species.CaptureCount != 0 || species.Unlocked == nil || !*species.Unlocked) { + t.Fatalf("independent capture clear/unlock preservation = %#v", species) + } + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 6132989..46fd00e 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -917,6 +917,14 @@ func (s *Store) ReplaceWorld(ctx context.Context, w *sav.World, at time.Time, d if incoming.PaldeckUnlocked != nil { merged.PaldeckUnlocked = incoming.PaldeckUnlocked } + if incoming.PalCaptureCounts != nil { + merged.PalCaptureCounts = incoming.PalCaptureCounts + merged.PalCaptureCountsTruncated = incoming.PalCaptureCountsTruncated + } + if incoming.PaldeckUnlockFlags != nil { + merged.PaldeckUnlockFlags = incoming.PaldeckUnlockFlags + merged.PaldeckUnlockFlagsTruncated = incoming.PaldeckUnlockFlagsTruncated + } merged.UID = uid players[uid] = merged } @@ -931,6 +939,9 @@ func (s *Store) ReplaceWorld(ctx context.Context, w *sav.World, at time.Time, d if err != nil { return err } + if err = replacePlayerPaldeck(ctx, tx, p, at); err != nil { + return err + } } containerOwners := indexPersonalContainers(players) for _, p := range w.Pals { diff --git a/backend/internal/store/store_migration_audit_test.go b/backend/internal/store/store_migration_audit_test.go index 5b19c01..84a1ef8 100644 --- a/backend/internal/store/store_migration_audit_test.go +++ b/backend/internal/store/store_migration_audit_test.go @@ -132,8 +132,8 @@ func TestAuditUpgradeV030RealVolumeReadableThroughBothSurfaces(t *testing.T) { } defer st.Close() ctx := context.Background() - if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, err) + if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, err) } if v, err := st.GetKV(ctx, "operator-note"); err != nil || v != "preserve-me" { t.Fatalf("operator kv = %q, %v", v, err) @@ -293,8 +293,8 @@ func TestAuditInterruptedMigrationReplayPreservesData(t *testing.T) { t.Fatalf("reopen after simulated interrupted migration: %v", err) } defer reopened.Close() - if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version after replay = %q, %v; want repaired to 9", v, err) + if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 10", v, err) } keys, err := reopened.ListAPIKeys(ctx) if err != nil || len(keys) != 1 || keys[0].ID != "aaaa1111" || keys[0].Label != "survives-replay" { @@ -310,7 +310,7 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { t.Run("future version names both numbers", func(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") legacy := buildV030Database(t, path) - if _, err := legacy.Exec(`UPDATE kv SET value='10' WHERE key='schema_version'`); err != nil { + if _, err := legacy.Exec(`UPDATE kv SET value='11' WHERE key='schema_version'`); err != nil { t.Fatal(err) } if err := legacy.Close(); err != nil { @@ -318,9 +318,9 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { } _, err := Open(path) if err == nil { - t.Fatal("Open on schema_version 10 succeeded") + t.Fatal("Open on schema_version 11 succeeded") } - for _, needle := range []string{"10", "9", "newer than this binary supports"} { + for _, needle := range []string{"11", "10", "newer than this binary supports"} { if !strings.Contains(err.Error(), needle) { t.Errorf("fail-closed error %q does not mention %q", err, needle) } @@ -403,9 +403,9 @@ func TestAuditConcurrentOpenSameFile(t *testing.T) { if err != nil { t.Fatalf("round %d: reopen after concurrent Open: %v", round, err) } - if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "9" { + if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "10" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 9", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 10", round, v, err) } if _, err := st.ListAPIKeys(context.Background()); err != nil { st.Close() @@ -527,7 +527,7 @@ func TestAuditDowngradeV04DatabaseUnderV03OpenSemantics(t *testing.T) { t.Fatalf("v0.3 binary cannot open a v0.4 database (001 re-execution failed): %v", err) } var v string - if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "9" { + if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "10" { t.Fatalf("schema_version after v0.3-style open = %q, %v; INSERT OR IGNORE must not clobber it", v, err) } var name string diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 4996ba5..ef6eddc 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -612,8 +612,8 @@ func TestMigration004AddsNullablePalPlacementColumns(t *testing.T) { t.Fatal(err) } defer st.Close() - if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, getErr) + if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, getErr) } pals, err := st.PalsTyped(context.Background(), "owner") if err != nil || len(pals) != 1 { @@ -672,8 +672,8 @@ func TestFreshDatabaseReachesLatestSchemaAndAPIKeysUsable(t *testing.T) { defer s.Close() ctx := context.Background() v, err := s.GetKV(ctx, "schema_version") - if err != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, err) + if err != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, err) } hash := [32]byte{1, 2, 3} created, err := s.CreateAPIKey(ctx, "abcd1234", hash, "fresh-db-key", time.Now()) @@ -739,8 +739,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { ctx := context.Background() v, err := upgraded.GetKV(ctx, "schema_version") - if err != nil || v != "9" { - t.Fatalf("schema_version after upgrade = %q, %v; want 9", v, err) + if err != nil || v != "10" { + t.Fatalf("schema_version after upgrade = %q, %v; want 10", v, err) } if _, err = upgraded.ListAPIKeys(ctx); err != nil { t.Fatalf("api_keys table not usable after upgrade: %v", err) @@ -769,8 +769,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { t.Fatalf("second Open (already at latest version) returned an error: %v", err) } defer reopened.Close() - if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version after no-op reopen = %q, %v; want 9", v, err) + if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 10", v, err) } } diff --git a/docs-site/src/content/docs/architecture/storage-and-migrations.md b/docs-site/src/content/docs/architecture/storage-and-migrations.md index 0a22a66..3fc219e 100644 --- a/docs-site/src/content/docs/architecture/storage-and-migrations.md +++ b/docs-site/src/content/docs/architecture/storage-and-migrations.md @@ -1,12 +1,12 @@ --- title: Storage and migrations -description: The single SQLite file, what its tables hold, metrics retention windows, and the nine schema migrations. +description: The single SQLite file, what its tables hold, metrics retention windows, and the ten schema migrations. sidebar: order: 4 --- This page covers where Palhelm keeps its data: one SQLite file, its main table areas, -how long metrics are kept, and how the schema is versioned through nine ordered +how long metrics are kept, and how the schema is versioned through ten ordered migrations. ## One SQLite file @@ -30,6 +30,9 @@ The schema groups into a few areas: by the save-sync poller. `world_state` holds one row of parse metadata: the world day, when the last parse ran, how long it took, the counts it produced, and the skipped and drift counters from the parser. +- Paldeck observations. `player_paldeck` stores normalized species capture counts and + unlock flags decoded from authoritative player `RecordData`; `player_paldeck_state` + distinguishes unavailable, complete, and defensively truncated observations. - Operational logs. `events`, `console_log`, and `saved_commands` back the events feed, the console history, and saved console commands. - Aggregate Game Data diagnostics. `game_data_activity` stores FPS, actor counts, worker @@ -72,7 +75,7 @@ migrated: it sees a schema version above the newest migration it knows and refus start. To roll back past a migration, restore the pre-update copy of the data volume. See [Updating Palhelm](/getting-started/updating/) for the full rollback procedure. -## The nine migrations +## The ten migrations | File | Purpose | |---|---| @@ -85,7 +88,8 @@ See [Updating Palhelm](/getting-started/updating/) for the full rollback procedu | `007_pal_instance_details.sql` | Adds per-pal detail columns: HP, gender, the four talent values, passive skill ids, and equipped skill ids. | | `008_pal_base_workers.sql` | Adds a `base_id` column to `pals` so pals assigned to a base can be linked to it. | | `009_game_data_activity.sql` | Adds aggregate-only Game Data activity/health samples for bounded operator diagnostics. | +| `010_player_paldeck.sql` | Adds authoritative per-player species capture/unlock observations and their availability, timestamp, and truncation state. | -Migrations 004 through 009 grew the pal/player records and aggregate diagnostics as the panel's player-view +Migrations 004 through 010 grew the pal/player records and aggregate diagnostics as the panel's player-view and pal-box screens matured. Each one is additive, so upgrading is a matter of pulling a newer image and restarting. diff --git a/docs-site/src/content/docs/getting-started/updating.md b/docs-site/src/content/docs/getting-started/updating.md index 9b62bf5..5a9f0ed 100644 --- a/docs-site/src/content/docs/getting-started/updating.md +++ b/docs-site/src/content/docs/getting-started/updating.md @@ -52,6 +52,7 @@ You do not run migrations by hand. They apply automatically when the new image b | `007_pal_instance_details` | Adds per-Pal details such as HP, gender, and talents. | | `008_pal_base_workers` | Adds a base id to Pals so base workers can be attributed. | | `009_game_data_activity` | Adds aggregate-only Game Data FPS, worker activity, and link-coverage samples with 30-day retention. It stores no actor identities, names, health, guilds, or locations. | +| `010_player_paldeck` | Adds bounded, save-observed per-player species capture counts and Paldeck unlock flags plus explicit observation and truncation coverage. | The runner fails closed. If the database was written by a newer Palhelm than the running binary knows about, it refuses to open rather than risk corrupting data. That refusal is what makes rollback predictable. @@ -65,7 +66,7 @@ docker compose -f ./compose/docker-compose.yml up -d palhelm Whether the old binary starts depends on the schema. A database written at a schema the old binary already knows opens cleanly. If a newer migration ran and the old binary does not recognize the schema version, the runner fails closed and the panel will not start on the old tag. In that case, restore the `/data` copy you made before the update and start the old tag against it: -Version 0.5.0 applies migration 009. Rolling back from it to a 0.4.x image therefore requires restoring the pre-upgrade `/data` backup; changing only the image tag is not sufficient. +Version 0.9.0 applies migration 010. Rolling back from it to a 0.8.x image therefore requires restoring the pre-upgrade `/data` backup; changing only the image tag is not sufficient. ```sh docker compose -f ./compose/docker-compose.yml stop palhelm diff --git a/docs-site/src/content/docs/panel/guilds.md b/docs-site/src/content/docs/panel/guilds.md new file mode 100644 index 0000000..394bfb3 --- /dev/null +++ b/docs-site/src/content/docs/panel/guilds.md @@ -0,0 +1,23 @@ +--- +title: Guild details +description: Current members, bases, associated Pals, and bounded panel-observed guild activity. +sidebar: + order: 5 +--- + +The **Guilds** route lists every guild from the latest parsed save. Open one for a dedicated view of +its current member roster, bases, associated Pals, and rolling 30-day activity. + +- Member names link to player detail and their save-observed Paldeck progression. +- Base coordinates link to the exact location on the authenticated live map. +- Pals are included only when they join an exact guild base or have a resolved current-member owner. + Owner evidence stays qualified, and matching species link to the filtered Pal explorer. +- Activity is derived from sessions observed by this Palhelm installation and attributed to the + guild's current member roster. It does not reconstruct historical guild transfers. + +The detail endpoint returns at most 500 associated Pals and says when that bounded result was +truncated. Missing base locations and progression counters remain unavailable instead of being +rendered as zero. + +The list reads `GET /api/v1/guilds`; detail reads viewer-safe +`GET /api/v1/guilds/{id}`. The path uses the normalized save guild ID. diff --git a/docs-site/src/content/docs/panel/pal-explorer.md b/docs-site/src/content/docs/panel/pal-explorer.md index 6abde9c..d3877ea 100644 --- a/docs-site/src/content/docs/panel/pal-explorer.md +++ b/docs-site/src/content/docs/panel/pal-explorer.md @@ -24,6 +24,18 @@ Results use keyset pagination. The browser loads 48 at a time and stops at 480 v narrow the filters to inspect a larger roster. Filtering happens in SQLite before pagination, so the page does not download the complete save roster. +## Filtered links + +The current filters are stored in the URL, so a record, history entry, guild page, or another +Palhelm integration can link directly to the matching roster. Supported query fields are `q`, +`ownerSource`, `placement`, `specimen`, `minLevel`, and `maxLevel`. The page validates every value +against the same bounded API contract and starts pagination from the beginning; opaque pagination +cursors are never put into shared links. + +For example, `/pals?q=Mammorest&specimen=boss` opens the known Boss Mammorest results. The link is a +view of the latest parsed save when it is opened, not a frozen historical snapshot and not proof of +lifetime ownership. + This screen reads the authenticated, viewer-safe `GET /api/v1/pals` endpoint. It never receives raw save JSON, Steam ids, account names, or platform identifiers. It is separate from the public Integration API used by bots. diff --git a/docs-site/src/content/docs/panel/paldeck-progress.md b/docs-site/src/content/docs/panel/paldeck-progress.md new file mode 100644 index 0000000..b8cb1d7 --- /dev/null +++ b/docs-site/src/content/docs/panel/paldeck-progress.md @@ -0,0 +1,31 @@ +--- +title: Paldeck progression +description: Save-observed server and per-player capture progression against the pinned Palworld 1.0 catalog. +sidebar: + order: 4 +--- + +The **Save-observed Paldeck** compares authoritative player `RecordData` maps with Palhelm's pinned +Palworld 1.0 species catalog. Choose **Server union** or an individual player. Search the catalog or +filter to captured, unseen, or unavailable observations, then open matching owned instances in the +[Pal explorer](/panel/pal-explorer/). + +Palhelm keeps three kinds of number separate: + +- **Pinned species progress** counts only known catalog entries with authoritative per-species + observations. A percentage appears only when capture or unlock coverage is complete and + untruncated. +- **Aggregate captures** and the **save unique counter** come directly from player saves. A unique + counter may include an ID outside the pinned catalog, so it is not used as the catalog percentage. +- **Unavailable** is not zero. A player without a decoded map remains unavailable. A partial server + union cannot prove that a zero-count species is unseen by the whole server, so the unseen filter + is disabled until every known player has complete, untruncated capture coverage. + +The catalog response includes known species plus bounded unknown IDs actually observed in saves. +Boss-prefixed keys are normalized to their base species before aggregation. This is capture and +unlock state observed at the latest successful parse, not owned-Pal inference and not a lifetime +event timeline. + +This screen reads viewer-safe `GET /api/v1/paldeck` and +`GET /api/v1/players/{uid}/paldeck`. Player selection is stored in the `player` URL query so a guild +member can link directly to that view. diff --git a/docs-site/src/content/docs/panel/players.md b/docs-site/src/content/docs/panel/players.md index 13238c4..cbf6c11 100644 --- a/docs-site/src/content/docs/panel/players.md +++ b/docs-site/src/content/docs/panel/players.md @@ -57,7 +57,9 @@ Pal data comes from the last save parse, not live memory. It updates when save s ## Guilds tab -Lists each guild with its member count, base count, and the roster of known members. Guild data is parsed from the save file. +Lists each guild with its member count, base count, and the roster of known members. Guild names +link to the [dedicated guild detail](/panel/guilds/) with bases, associated Pals, and bounded +panel-observed activity. Guild data is parsed from the save file. ## Player notes tab diff --git a/docs/API.md b/docs/API.md index 0d00558..98a8435 100644 --- a/docs/API.md +++ b/docs/API.md @@ -29,7 +29,9 @@ Operation-specific recovery details, such as Config's `manualCommand`, stay insi | GET | `/activity?window=24h\|7d\|30d` | viewer-safe server-wide analytics derived only from panel-observed sessions. Returns 24/28/30 bounded concurrency buckets, peak concurrency/time, first-observed versus returning counts, top 25 active-player and current-guild rankings, unattributed totals, `trackingSince`, coverage/attribution enums, and explicit defensive truncation. No raw session rows, platform identity, or lifetime-history claims. | | GET | `/players` | union of live + save-derived: `[{uid, steamId, name, accountName, online, level, guildId, guildName, ping, location: {x,y}\|null, firstSeenAt, lastSeenAt, playtimeSec, banned, whitelisted, captureTotal?, uniquePalsCaptured?, paldeckUnlocked?}]`; optional progression is decoded from the player save | | GET | `/players/{uid}` | detail incl. save-derived Pal placement and individual HP, gender, talents, passive IDs, and equipped-skill IDs. `activity` is a viewer-safe, panel-observed projection with `coverage: "panel_observed_sessions"`, nullable `trackingSince`/`currentSession`, rolling `last24Hours`/`last7Days`/`last30Days` duration and session counts, and at most 20 `recentSessions`; `recentSessionsTruncated` is explicit. The legacy `sessions` field mirrors that bounded recent sample. Activity is not lifetime game history. The three Pal placement numbers are nullable. | +| GET | `/players/{uid}/paldeck` | Viewer-safe per-player capture progression decoded from authoritative `SaveData.RecordData.PalCaptureCount` and `PaldeckUnlockFlag` maps. Returns the complete version-pinned catalog plus separately marked observed unknown CharacterIDs, aggregate counters, per-species `captureCount`/`unlocked`, independent observation timestamps, and availability/truncation flags. Missing values are `null` unless the corresponding map was decoded completely, in which case an absent catalog entry is the authoritative zero/false. Boss keys normalize to their base species. Never infers lifetime catches from currently owned Pals. | | GET | `/pals` | Viewer-safe server-wide Pal explorer. Keyset-paginated save-derived instances with display/species identity, level, gender, HP/talents/skills, owner provenance, placement, and Alpha/Lucky/Boss flags. Supports bounded `q`, `ownerSource`, `placement`, `specimen`, `minLevel`, `maxLevel`, `limit`, and opaque `cursor` filters. Never returns raw save JSON or platform account fields. | +| GET | `/paldeck` | Viewer-safe server union of the decoded per-player capture/unlock maps. Returns `coverage` (`player_save_record_data`, player decode counts, oldest/latest observation, truncation), `catalog` (`palworld_1.0_pinned`, known and observed-unknown counts), nullable aggregate totals, and the complete pinned species list. Per-species totals are partial when not every player map is available; coverage makes that explicit. Unknown save CharacterIDs remain visible with `known=false` instead of being discarded or mislabeled. | | POST | `/players/{uid}/kick` | `{message?}` | | POST | `/players/{uid}/ban` | `{message?}` | | POST | `/players/{uid}/unban` | | @@ -37,6 +39,7 @@ Operation-specific recovery details, such as Config's `manualCommand`, stay insi ## Guilds | GET | `/guilds` | `[{id, name, adminUid, memberCount, members: [{uid,name}], bases: [{id, location:{x,y}, level}]}]` | +| GET | `/guilds/{id}` | Viewer-safe current-save detail: safe member profile/progression fields, bases with nullable persistent map coordinates and exact base-Pal counts, and at most 500 Pals associated by an exact guild-base join or a current member owner join. `palCount` is exact and `palsTruncated` reports response truncation; each row carries `association` and owner provenance. `activity` is a clamped 30-day aggregate of panel-observed sessions attributed to the **current** guild roster, with coverage/tracking/truncation—never historical guild membership or lifetime activity. No platform ids, player coordinates, raw save/live actors, or container GUIDs. Invalid/nonexistent ids return 404. | ## World / save data | GET | `/world` | `{day, lastParseAt, parseDurationMs, stats: {players, pals, guilds, skippedProps}, formatDrift: bool}` | diff --git a/docs/PANEL-ROADMAP.md b/docs/PANEL-ROADMAP.md index d370fd2..724aea5 100644 --- a/docs/PANEL-ROADMAP.md +++ b/docs/PANEL-ROADMAP.md @@ -48,11 +48,13 @@ happened without reading container logs. guild-attributed activity with explicit tracking coverage and truncation. - [x] Add a server-wide Pal explorer with search, owner provenance, party/box/base placement, and Alpha/Lucky/Boss and level filters. -- [ ] Link current and historical records directly into filtered Pal explorer views. +- [x] Make Pal explorer filters URL-addressable and bounded so current records, + history, guild detail, and authenticated integrations can deep-link into a + fresh filtered roster without sharing opaque pagination state. - [x] Add numeric, version-pinned work-suitability badges with distinct SVGs to the shared party and Palbox detail view, without presenting species metadata as individual save data. -- [ ] Add Paldeck/capture progression and dedicated guild detail pages linking +- [x] Add Paldeck/capture progression and dedicated guild detail pages linking members, bases, Pals, activity, and map locations. Exit: the panel exposes the useful save-derived information already available to @@ -72,13 +74,15 @@ the Discord bot instead of limiting it to player detail dialogs. - [x] Add explicit selected-online-player/base focus, fit-online-players/bases, player/base search, privacy-safe shareable Palworld display coordinates, and practical touch/mobile controls. Do not guess a "current player" from panel auth. -- [ ] Add marker clustering for dense same-layer player/base views without hiding +- [x] Add marker clustering for dense same-layer player/base views without hiding exact coordinates or changing the corrected transform. - [x] Add contained wheel/trackpad zoom, explicit zoom and fit controls, distinct SVG player/base/worker/Palbox markers, and keep dense worker markers off by default. -- [ ] Add automated landmark fixtures across Palpagos and the World Tree so axis, - offset, layer-boundary, and inverse-coordinate regressions fail in CI. +- [x] Add automated transform fixtures across Palpagos and the World Tree so axis, + offset, layer-boundary, and inverse-coordinate regressions fail in CI. Palpagos + includes the surveyed starting-area position; World Tree is intentionally limited + to verified dataset bounds anchors until a licensed, surveyed landmark is available. ### Palworld 1.0 live game-data track diff --git a/docs/releases/v0.9.0.md b/docs/releases/v0.9.0.md new file mode 100644 index 0000000..bc81917 --- /dev/null +++ b/docs/releases/v0.9.0.md @@ -0,0 +1,35 @@ +# Palhelm v0.9.0 + +Palhelm 0.9.0 turns the save's authoritative per-player Pal capture records into a +viewer-safe Paldeck experience, adds connected guild exploration, and makes dense +live maps easier to navigate without weakening coordinate accuracy or privacy. + +## Paldeck and guild exploration + +- Save-observed capture counts and Paldeck unlock flags are retained per player. +- Server and player progression explicitly report observation coverage, unavailable + data, parser bounds, and catalog drift instead of inferring catches from owned Pals. +- Boss identifiers are folded into their base species for progression while captured + specimens keep their Boss presentation in the Pal explorer. +- Dedicated guild views connect members, bases, Pals, activity, and map focus links. +- Pal explorer filters live in canonical, shareable URLs so records, guilds, and other + panel pages can open an exact roster view without sharing pagination cursors. + +## Map and build reliability + +- Same-layer player and base markers cluster independently at dense screen positions; + exact markers remain reachable and selected markers are never swallowed by a cluster. +- Version-pinned transform, inverse-coordinate, bounds, and layer-boundary fixtures + protect Palpagos and World Tree alignment in CI. +- GitHub-hosted workflows use the supported Node 24 action generations while preserving + dependency caches, release tag gates, provenance, SBOM generation, and signing. + +## Upgrade and rollback + +This release adds SQLite migration 010 for per-player Paldeck observations. Back up the +complete Palhelm data volume before upgrading. Rolling the image back to 0.8.x requires +restoring that pre-upgrade data-volume backup; Palhelm does not run schema migrations +backward. + +This release changes only Palhelm and its authenticated API. It does not require or +perform a Palworld game-server restart. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 53944b4..bf99cb8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@base-ui/react": "^1.6.0", "@tanstack/react-query": "^5.101.2", diff --git a/frontend/package.json b/frontend/package.json index 2a3b138..1f27c6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c8dbb27..f49c034 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,6 +14,7 @@ import type { ConfigValue, ConsoleLogEntry, Guild, + GuildDetail, IntegrationKey, IntegrationKeyCreated, MapDataset, @@ -23,6 +24,7 @@ import type { PalExplorerPage, PalExplorerParams, PaldeckIconDataset, + PlayerPaldeck, PalhelmEvent, Player, PlayerDetail, @@ -31,6 +33,7 @@ import type { ServerActivity, ServerActivityWindow, ServerHealth, + ServerPaldeck, ServerInfo, SessionInfo, WhitelistEntry, @@ -153,6 +156,8 @@ export const api = { }, guilds: { list: (): Promise => (USE_MOCK ? mock.listGuilds() : request("GET", "/guilds")), + detail: (id: string): Promise => + USE_MOCK ? mock.guildDetail(id) : request("GET", `/guilds/${encodeURIComponent(id)}`), }, // Session-authenticated admin key management (docs/specs/integration-api.md §9) — not the // bearer-token Integration API surface itself, which this frontend never calls directly. @@ -165,6 +170,9 @@ export const api = { USE_MOCK ? mock.revokeIntegrationKey(id) : request("DELETE", `/integration-keys/${id}`), }, paldeck: { + get: (): Promise => (USE_MOCK ? mock.getServerPaldeck() : request("GET", "/paldeck")), + player: (uid: string): Promise => + USE_MOCK ? mock.getPlayerPaldeck(uid) : request("GET", `/players/${encodeURIComponent(uid)}/paldeck`), iconDataset: (): Promise => USE_MOCK ? mock.getPaldeckIconDataset() : request("GET", "/paldeck/icon-dataset"), // Not a JSON call — this builds the for a pal icon (404 = not installed, handled diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index ed1d698..92f6c3f 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -20,6 +20,7 @@ import type { ConfigValue, ConsoleLogEntry, Guild, + GuildDetail, IntegrationKey, IntegrationKeyCreated, LiveWorldSnapshot, @@ -31,6 +32,7 @@ import type { PalExplorerPal, PalExplorerParams, PaldeckIconDataset, + PlayerPaldeck, PalhelmEvent, Player, PlayerDetail, @@ -40,6 +42,7 @@ import type { ServerActivity, ServerActivityWindow, ServerHealth, + ServerPaldeck, ServerInfo, SessionInfo, WhitelistEntry, @@ -107,6 +110,9 @@ const players: Player[] = [ firstSeenAt: "2026-07-04T09:12:00Z", lastSeenAt: new Date().toISOString(), playtimeSec: 21 * 3600 + 36 * 60, + captureTotal: 146, + uniquePalsCaptured: 8, + paldeckUnlocked: 9, banned: false, whitelisted: true, }, @@ -124,6 +130,9 @@ const players: Player[] = [ firstSeenAt: "2026-07-04T10:02:00Z", lastSeenAt: new Date().toISOString(), playtimeSec: 18 * 3600 + 5 * 60, + captureTotal: 113, + uniquePalsCaptured: 7, + paldeckUnlocked: 8, banned: false, whitelisted: true, }, @@ -141,6 +150,9 @@ const players: Player[] = [ firstSeenAt: "2026-07-05T08:00:00Z", lastSeenAt: "2026-07-09T22:18:00Z", playtimeSec: 15 * 3600 + 51 * 60, + captureTotal: 82, + uniquePalsCaptured: 5, + paldeckUnlocked: 6, banned: false, whitelisted: true, }, @@ -319,7 +331,7 @@ export async function getServer(): Promise { worldGuid: "A1B2C3D4E5F6478090ABCDEF12345678", state: "running", uptimeSec: Math.floor((Date.now() - BOOT_AT) / 1000), - panelVersion: "0.8.0", + panelVersion: "0.9.0", }; } @@ -630,6 +642,71 @@ export async function listGuilds(): Promise { return guilds; } +export async function guildDetail(id: string): Promise { + requireSession(); + await latency(); + const guild = guilds.find((item) => item.id === id); + if (!guild) throw new ApiRequestError(404, "not_found", "Guild not found."); + const memberPlayers = players.filter((player) => player.guildId === guild.id); + const now = new Date(); + const since = new Date(now.getTime() - 30 * 86_400_000); + const guildPals = memberPlayers.flatMap((owner) => withPlacement(palsByPlayer[owner.name] ?? []).map((pal) => ({ + instanceId: pal.instanceId, + characterId: pal.characterId, + displayName: pal.displayName, + level: pal.level, + isAlpha: pal.isAlpha, + isLucky: pal.isLucky, + isBoss: pal.characterId.toLowerCase().startsWith("boss_"), + placement: pal.placement ?? "unknown", + baseId: null, + ownerUid: owner.uid, + ownerName: owner.name, + ownerSource: "personal_container" as const, + ownerResolved: true, + association: "current_member_owner" as const, + }))); + return { + id: guild.id, + name: guild.name, + adminUid: guild.adminUid, + memberCount: guild.memberCount, + members: guild.members.map((member) => { + const player = players.find((item) => item.uid === member.uid); + return { + uid: member.uid, + name: member.name, + level: player?.level ?? 0, + online: player?.online ?? false, + lastSeenAt: player?.lastSeenAt ?? null, + playtimeSec: player?.playtimeSec ?? 0, + captureTotal: player?.captureTotal ?? null, + uniquePalsCaptured: player?.uniquePalsCaptured ?? null, + paldeckUnlocked: player?.paldeckUnlocked ?? null, + observedDurationSec: player ? Math.min(player.playtimeSec, 30 * 3600) : 0, + observedSessionCount: player ? Math.max(1, Math.ceil(player.playtimeSec / 7200)) : 0, + currentSession: player?.online ?? false, + }; + }), + bases: guild.bases.map((base) => ({ ...base, palCount: 0 })), + palCount: guildPals.length, + palsTruncated: false, + pals: guildPals, + activity: { + coverage: "panel_observed_sessions", + attribution: "current_guild_membership", + window: "30d", + since: since.toISOString(), + through: now.toISOString(), + trackingSince: memberPlayers.map((player) => player.firstSeenAt).sort()[0] ?? null, + analysisTruncated: false, + durationSec: memberPlayers.reduce((total, player) => total + Math.min(player.playtimeSec, 30 * 3600), 0), + sessionCount: memberPlayers.reduce((total, player) => total + Math.max(1, Math.ceil(player.playtimeSec / 7200)), 0), + activePlayers: memberPlayers.length, + }, + }; +} + // ---------- integration keys ---------- // Admin key management (docs/specs/integration-api.md §9). This mock never validates a bearer // token against `/api/integration/v1` — that surface isn't part of this frontend's scope — it @@ -736,6 +813,89 @@ export function __seedActiveIntegrationKeysForTests(count: number): IntegrationK // ---------- paldeck icons ---------- +const mockPaldeckSpecies = [ + ["Anubis", "Anubis"], + ["Bristla", "Bristla"], + ["Depresso", "Depresso"], + ["Eikthyrdeer", "Eikthyrdeer"], + ["Fuack", "Fuack"], + ["Grizzbolt", "Grizzbolt"], + ["Lamball", "Lamball"], + ["Mammorest", "Mammorest"], + ["Mossanda", "Mossanda"], + ["Petallia", "Petallia"], + ["Relaxaurus", "Relaxaurus"], + ["Shadowbeak", "Shadowbeak"], +] as const; + +function mockCaptureCount(playerIndex: number, characterId: string): number { + const owned = new Set((palsByPlayer[players[playerIndex]?.name ?? ""] ?? []).map((pal) => pal.characterId.toLowerCase())); + return owned.has(characterId.toLowerCase()) ? playerIndex + 1 : 0; +} + +export async function getServerPaldeck(): Promise { + requireSession(); + await latency(); + const observedPlayers = players.slice(0, 3); + const species = mockPaldeckSpecies.map(([characterId, displayName]) => { + const counts = observedPlayers.map((_, playerIndex) => mockCaptureCount(playerIndex, characterId)); + return { + characterId, + displayName, + known: true, + captureCount: counts.reduce((total, count) => total + count, 0), + capturedByPlayers: counts.filter((count) => count > 0).length, + unlockedByPlayers: counts.filter((count) => count > 0).length, + }; + }); + return { + coverage: { + source: "player_save_record_data", + playersTotal: players.length, + playersWithCaptureCounts: observedPlayers.length, + playersWithUnlockFlags: observedPlayers.length, + captureCountsTruncated: false, + unlockFlagsTruncated: false, + oldestObservedAt: "2026-07-10T08:00:00Z", + latestObservedAt: new Date().toISOString(), + }, + catalog: { version: "palworld_1.0_pinned", knownSpecies: mockPaldeckSpecies.length, observedUnknownSpecies: 0 }, + captureTotal: observedPlayers.reduce((total, player) => total + (player.captureTotal ?? 0), 0), + uniqueSpeciesCaptured: species.filter((item) => (item.captureCount ?? 0) > 0).length, + speciesUnlocked: species.filter((item) => (item.unlockedByPlayers ?? 0) > 0).length, + species, + }; +} + +export async function getPlayerPaldeck(uid: string): Promise { + requireSession(); + await latency(); + const playerIndex = players.findIndex((item) => item.uid === uid); + if (playerIndex < 0) throw new ApiRequestError(404, "not_found", "Player not found."); + const player = players[playerIndex]; + const available = playerIndex < 3; + return { + player: { uid: player.uid, name: player.name }, + coverage: { + source: "player_save_record_data", + captureCountsAvailable: available, + unlockFlagsAvailable: available, + captureCountsTruncated: false, + unlockFlagsTruncated: false, + captureObservedAt: available ? new Date().toISOString() : null, + unlockObservedAt: available ? new Date().toISOString() : null, + }, + catalog: { version: "palworld_1.0_pinned", knownSpecies: mockPaldeckSpecies.length, observedUnknownSpecies: 0 }, + captureTotal: player.captureTotal ?? null, + uniquePalsCaptured: player.uniquePalsCaptured ?? null, + paldeckUnlocked: player.paldeckUnlocked ?? null, + species: mockPaldeckSpecies.map(([characterId, displayName]) => { + const count = available ? mockCaptureCount(playerIndex, characterId) : null; + return { characterId, displayName, known: true, captureCount: count, unlocked: count === null ? null : count > 0 }; + }), + }; +} + // A couple of entries so the "known id" branch in is exercised in mock mode — the // component itself still skips the actual fetch under USE_MOCK (see components/PalIcon.tsx), // since no icon files exist without a real fetch-pal-icons.sh run against a backend. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fd161cf..36e91c1 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -90,6 +90,10 @@ export interface Player { firstSeenAt: string; lastSeenAt: string; playtimeSec: number; + /** Aggregate player-save observations. Omitted by older parsers; absence is not zero. */ + captureTotal?: number; + uniquePalsCaptured?: number; + paldeckUnlocked?: number; banned: boolean; whitelisted: boolean; } @@ -261,6 +265,127 @@ export interface Guild { bases: GuildBase[]; } +export interface GuildDetailMember { + uid: string; + name: string; + level: number; + online: boolean; + lastSeenAt: string | null; + playtimeSec: number; + captureTotal: number | null; + uniquePalsCaptured: number | null; + paldeckUnlocked: number | null; + observedDurationSec: number; + observedSessionCount: number; + currentSession: boolean; +} + +export interface GuildDetailBase { + id: string; + location: PlayerLocation | null; + level: number; + palCount: number; +} + +export interface GuildDetailPal { + instanceId: string; + characterId: string; + displayName: string; + level: number; + isAlpha: boolean; + isLucky: boolean; + isBoss: boolean; + placement: PalPlacement; + baseId: string | null; + ownerUid: string; + ownerName: string; + ownerSource: PalOwnerSource; + ownerResolved: boolean; + association: "guild_base" | "current_member_owner"; +} + +export interface GuildDetail { + id: string; + name: string; + adminUid: string; + memberCount: number; + members: GuildDetailMember[]; + bases: GuildDetailBase[]; + palCount: number; + palsTruncated: boolean; + pals: GuildDetailPal[]; + activity: { + coverage: "panel_observed_sessions"; + attribution: "current_guild_membership"; + window: "30d"; + since: string; + through: string; + trackingSince: string | null; + analysisTruncated: boolean; + durationSec: number; + sessionCount: number; + activePlayers: number; + }; +} + +// ---------- Paldeck progression ---------- +export interface PaldeckCatalogCoverage { + version: "palworld_1.0_pinned"; + knownSpecies: number; + observedUnknownSpecies: number; +} + +export interface ServerPaldeckSpecies { + characterId: string; + displayName: string; + known: boolean; + captureCount: number | null; + capturedByPlayers: number | null; + unlockedByPlayers: number | null; +} + +export interface ServerPaldeck { + coverage: { + source: "player_save_record_data"; + playersTotal: number; + playersWithCaptureCounts: number; + playersWithUnlockFlags: number; + captureCountsTruncated: boolean; + unlockFlagsTruncated: boolean; + oldestObservedAt: string | null; + latestObservedAt: string | null; + }; + catalog: PaldeckCatalogCoverage; + captureTotal: number | null; + uniqueSpeciesCaptured: number | null; + speciesUnlocked: number | null; + species: ServerPaldeckSpecies[]; +} + +export interface PlayerPaldeck { + player: { uid: string; name: string }; + coverage: { + source: "player_save_record_data"; + captureCountsAvailable: boolean; + unlockFlagsAvailable: boolean; + captureCountsTruncated: boolean; + unlockFlagsTruncated: boolean; + captureObservedAt: string | null; + unlockObservedAt: string | null; + }; + catalog: PaldeckCatalogCoverage; + captureTotal: number | null; + uniquePalsCaptured: number | null; + paldeckUnlocked: number | null; + species: Array<{ + characterId: string; + displayName: string; + known: boolean; + captureCount: number | null; + unlocked: boolean | null; + }>; +} + // ---------- Map ---------- // Mirrors backend/internal/server/tiles.go's mapDatasetInfo/mapDatasetLayer JSON shape. export interface MapDatasetTransform { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index d9c0ab3..bd3b959 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -9,6 +9,8 @@ const Dashboard = lazy(() => import("../routes/dashboard/Dashboard")); const PlayersRoute = lazy(() => import("../routes/players/Players")); const ActivityRoute = lazy(() => import("../routes/activity/Activity")); const PalsRoute = lazy(() => import("../routes/pals/Pals")); +const GuildsRoute = lazy(() => import("../routes/guilds/Guilds")); +const PaldeckRoute = lazy(() => import("../routes/paldeck/Paldeck")); const ConsoleRoute = lazy(() => import("../routes/console/Console")); const MapRoute = lazy(() => import("../routes/map/Map")); const BackupsRoute = lazy(() => import("../routes/backups/Backups")); @@ -60,6 +62,9 @@ export default function App() { )} /> )} /> )} /> + )} /> + )} /> + )} /> )} /> )} /> )} /> diff --git a/frontend/src/app/mapClustering.ts b/frontend/src/app/mapClustering.ts new file mode 100644 index 0000000..6b1747d --- /dev/null +++ b/frontend/src/app/mapClustering.ts @@ -0,0 +1,67 @@ +export type ClusterMarkerKind = "player" | "base"; + +/** A marker already projected into screen space. `value` retains the original entity/coordinates. */ +export interface ClusterMarkerPoint { + key: string; + kind: ClusterMarkerKind; + layerId: string; + x: number; + y: number; + value: T; +} + +export type ClusterMarkerGroup = + | { type: "single"; key: string; x: number; y: number; member: ClusterMarkerPoint } + | { type: "cluster"; key: string; x: number; y: number; members: ClusterMarkerPoint[] }; + +/** + * Groups dense markers in screen space. Clusters never cross tile-layer or marker-kind + * boundaries, and the selected marker is always emitted as a standalone marker so focus and + * exact-coordinate access cannot be hidden by aggregation. + * + * Points are admitted in stable key order only when they remain within the radius of every + * existing member. This caps cluster diameter and prevents an A-near-B-near-C chain from + * percolating across a whole dense region when A and C are far apart. + */ +export function clusterMapMarkers( + points: readonly ClusterMarkerPoint[], + radiusPx = 48, + selectedKey: string | null = null, +): ClusterMarkerGroup[] { + if (!Number.isFinite(radiusPx) || radiusPx <= 0) { + return points.map((member) => ({ type: "single", key: member.key, x: member.x, y: member.y, member })); + } + + const ordered = [...points].sort((left, right) => left.key.localeCompare(right.key)); + const radiusSquared = radiusPx * radiusPx; + const components: Array<{ locked: boolean; members: ClusterMarkerPoint[] }> = []; + for (const point of ordered) { + const component = point.key === selectedKey + ? undefined + : components.find((candidate) => + !candidate.locked && + candidate.members[0].kind === point.kind && + candidate.members[0].layerId === point.layerId && + candidate.members.every((member) => { + const dx = member.x - point.x; + const dy = member.y - point.y; + return dx * dx + dy * dy <= radiusSquared; + })); + if (component) component.members.push(point); + else components.push({ locked: point.key === selectedKey, members: [point] }); + } + + return components.map(({ members }): ClusterMarkerGroup => { + if (members.length === 1) { + const member = members[0]; + return { type: "single", key: member.key, x: member.x, y: member.y, member }; + } + return { + type: "cluster", + key: `cluster:${members[0].layerId}:${members[0].kind}:${members.map((member) => member.key).join("|")}`, + x: members.reduce((sum, member) => sum + member.x, 0) / members.length, + y: members.reduce((sum, member) => sum + member.y, 0) / members.length, + members, + }; + }); +} diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 95d859c..731f2c5 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -18,6 +18,8 @@ import { IconSettings, IconEvents, IconInfo, + IconGuild, + IconPaldeck, IconPals, type IconProps, } from "./icons"; @@ -47,6 +49,8 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/players", label: "Players", icon: IconPlayers }, { to: "/activity", label: "Activity", icon: IconActivity }, { to: "/pals", label: "Pal explorer", icon: IconPals }, + { to: "/paldeck", label: "Paldeck", icon: IconPaldeck }, + { to: "/guilds", label: "Guilds", icon: IconGuild }, { to: "/map", label: "Live map", icon: IconMap }, { to: "/events", label: "Events", icon: IconEvents }, { to: "/console", label: "Console", icon: IconConsole }, diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 62e2a32..ae2fc4d 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -112,6 +112,25 @@ export function IconPals(props: IconProps) { ); } +export function IconGuild(props: IconProps) { + return ( + + + + + ); +} + +export function IconPaldeck(props: IconProps) { + return ( + + + + + + ); +} + export function IconMapWorker(props: IconProps) { return ; } diff --git a/frontend/src/routes/activity/Activity.tsx b/frontend/src/routes/activity/Activity.tsx index 2fcfabf..9f415d5 100644 --- a/frontend/src/routes/activity/Activity.tsx +++ b/frontend/src/routes/activity/Activity.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Link } from "react-router"; import { api } from "../../api/client"; import type { ServerActivityWindow } from "../../api/types"; import { formatDuration } from "../../app/format"; @@ -98,7 +99,7 @@ export default function ActivityRoute() { {activity.guilds.length === 0 ? : ( - {activity.guilds.map((guild) => )} + {activity.guilds.map((guild) => )}
GuildObservedPlayers
{guild.guildName || "Unnamed guild"}{guild.sessionCount} observed sessions{formatDuration(guild.durationSec)}{guild.activePlayers}
{guild.guildName || "Unnamed guild"}{guild.sessionCount} observed sessions{formatDuration(guild.durationSec)}{guild.activePlayers}

Sessions are attributed to each player's current save-derived guild. Historical guild membership is not stored.

diff --git a/frontend/src/routes/guilds/Guilds.css b/frontend/src/routes/guilds/Guilds.css new file mode 100644 index 0000000..d791b43 --- /dev/null +++ b/frontend/src/routes/guilds/Guilds.css @@ -0,0 +1,33 @@ +.guilds-page { gap: var(--space-4); } +.guilds-head { align-items: center; justify-content: space-between; } +.guilds-head > div { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; } +.guilds-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.guild-card-body { display: flex; flex-direction: column; gap: 6px; color: var(--ink-2); font-size: var(--text-sm); } +.guild-card-body span:last-child { color: var(--ink-3); } +.guilds-skeleton { width: 100%; height: 150px; } +.guilds-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); } +.guild-stat { display: flex; flex-direction: column; gap: 4px; } +.guild-stat span { color: var(--ink-3); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: var(--track-caps); } +.guild-stat strong { font: 600 var(--text-2xl)/1 var(--font-mono); font-variant-numeric: tabular-nums; } +.guild-stat small { color: var(--ink-3); } +.guilds-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); align-items: start; } +.guild-table-wrap { overflow-x: auto; } +.guild-table-wrap td strong, .guild-table-wrap td small { display: block; } +.guild-table-wrap td small { margin-top: 3px; color: var(--ink-3); font-size: 10px; } +.guild-muted { color: var(--ink-3); } +.guild-pal-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); } +.guild-pal { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 9px; border: 1px solid var(--line); border-radius: var(--radius-ctl); background: var(--surface-2); } +.guild-pal > div { min-width: 0; } +.guild-pal strong, .guild-pal small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.guild-pal small { color: var(--ink-3); font-size: 10px; } +@media (max-width: 850px) { + .guilds-grid, .guilds-detail-grid { grid-template-columns: 1fr; } + .guilds-stats, .guild-pal-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + .guilds-head, .guilds-head > div { align-items: flex-start; } + .guilds-head > div { flex-direction: column; gap: 2px; } + .guilds-stats, .guild-pal-grid { grid-template-columns: 1fr; } + .guild-pal { grid-template-columns: auto minmax(0, 1fr); } + .guild-pal > a { grid-column: 2; justify-self: start; } +} diff --git a/frontend/src/routes/guilds/Guilds.tsx b/frontend/src/routes/guilds/Guilds.tsx new file mode 100644 index 0000000..fd792e6 --- /dev/null +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -0,0 +1,114 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router"; +import { api } from "../../api/client"; +import { ApiRequestError, type GuildDetail } from "../../api/types"; +import { formatDuration, formatRelativeToNow } from "../../app/format"; +import { worldToGame } from "../../app/mapTransform"; +import { Banner } from "../../components/Banner"; +import { Card, CardBody, CardHead } from "../../components/Card"; +import { EmptyState } from "../../components/EmptyState"; +import { PalIcon } from "../../components/PalIcon"; +import { Pill } from "../../components/Pill"; +import { palExplorerHref, palOwnerSummary, palSpecimenLabels } from "../pals/palExplorer"; +import "./Guilds.css"; + +export default function GuildsRoute() { + const { guildId } = useParams(); + const listQuery = useQuery({ queryKey: ["guilds"], queryFn: () => api.guilds.list(), enabled: !guildId }); + const detailQuery = useQuery({ queryKey: ["guilds", "detail", guildId], queryFn: () => api.guilds.detail(guildId ?? ""), enabled: Boolean(guildId) }); + const pending = guildId ? detailQuery.isPending : listQuery.isPending; + const failed = guildId ? detailQuery.isError : listQuery.isError; + const notFound = detailQuery.error instanceof ApiRequestError && detailQuery.error.status === 404; + + return ( +
+
+
+

{guildId ? detailQuery.data?.name || "Guild detail" : "Guilds"}

+ current save roster · bases · known members +
+ {guildId && All guilds} +
+ + {notFound ? ( + + ) : failed ? ( + Couldn't load guild data from the latest parsed save. + ) : pending ? ( + + ) : detailQuery.data ? ( + + ) : (listQuery.data ?? []).length === 0 ? ( + + ) : ( +
+ {(listQuery.data ?? []).map((item) => ( + + {item.name || "Unnamed guild"}} hint={`${item.memberCount} members`} /> + + {item.bases.length} save-derived {item.bases.length === 1 ? "base" : "bases"} + {item.members.length > 0 ? item.members.map((member) => member.name || "Unknown player").join(", ") : "No known member names"} + + + ))} +
+ )} +
+ ); +} + +function GuildDetailView({ guild }: { guild: GuildDetail }) { + return ( + <> + This is the current parsed save roster. The 30-day activity below is panel-observed and attributed to current membership; it does not reconstruct historical transfers.{guild.activity.analysisTruncated ? " Analysis hit its safety cap." : ""} +
+ Known members{guild.memberCount}{guild.members.filter((member) => member.online).length} online now + Bases{guild.bases.length}{guild.bases.reduce((total, base) => total + base.palCount, 0)} linked base workers + Associated Pals{guild.palCount}exact base or current-member association + Observed activity · 30d{formatDuration(guild.activity.durationSec)}{guild.activity.sessionCount} sessions · {guild.activity.activePlayers} players +
+
+ + + {guild.members.length === 0 ? : ( + + + {guild.members.map((member) => )} +
PlayerLevelObserved · 30dProgress
{member.name || "Unknown player"}{member.online ? Online : `seen ${formatRelativeToNow(member.lastSeenAt)}`}{member.level}{formatDuration(member.observedDurationSec)}{member.observedSessionCount} sessions{member.paldeckUnlocked === null ? Unavailable : {member.paldeckUnlocked} save unlock counter}
+
+ )} +
+ + + {guild.bases.length === 0 ? : ( + + + {guild.bases.map((base, index) => { + const game = base.location ? worldToGame(base.location.x, base.location.y) : null; + return ; + })} +
BaseLevelPalsLocation
Base {index + 1}{base.level}{base.palCount}{game ? {game.x}, {game.y} : Unavailable}
+
+ )} +
+
+ + + Open Pal explorer + + {guild.pals.length === 0 ? : ( + + {guild.pals.map((pal) => { + const specimen = palSpecimenLabels(pal); + return
+ +
{pal.displayName}Lv {pal.level} · {specimen.length ? specimen.map((label) => label === "Boss" ? "◆ Boss" : label).join(" · ") : "Standard"}{pal.association === "guild_base" ? "Exact guild base" : palOwnerSummary(pal)}
+ Roster +
; + })} +
+ )} +
+ + ); +} diff --git a/frontend/src/routes/map/Map.css b/frontend/src/routes/map/Map.css index 8ec2113..4728ca9 100644 --- a/frontend/src/routes/map/Map.css +++ b/frontend/src/routes/map/Map.css @@ -103,6 +103,12 @@ /* markers (screen-space layer, unscaled chips) */ .marker { position: absolute; transform: translate(-50%, -50%); z-index: 1; pointer-events: none; } +.marker-action { + pointer-events: auto; cursor: pointer; appearance: none; padding: 0; border: 0; + font: inherit; text-align: left; background: transparent; +} +.marker-action:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: var(--radius-ctl); } +.marker-action:hover .chip { background: var(--surface-2); } .marker-player { display: flex; align-items: center; gap: 6px; } .marker-symbol { width: 25px; height: 25px; flex: none; display: grid; place-items: center; @@ -117,6 +123,26 @@ .marker.is-selected .marker-symbol { box-shadow: 0 0 0 3px var(--accent-soft), 0 0 0 5px var(--accent); } +.marker-cluster { z-index: 2; } +.marker-cluster .marker-symbol { position: relative; width: 31px; height: 31px; } +.marker-count { + position: absolute; right: -7px; top: -8px; min-width: 18px; height: 18px; padding: 0 4px; + display: grid; place-items: center; border-radius: 999px; border: 2px solid var(--surface); + color: white; background: var(--accent); font-size: 10px; font-weight: 700; line-height: 1; +} +.marker-base.marker-cluster .marker-count { background: var(--ink-2); } +.marker-cluster-menu { + position: absolute; z-index: 4; transform: translate(-50%, 24px); width: min(220px, calc(100vw - 32px)); + max-height: 180px; overflow-y: auto; padding: 4px; background: var(--surface); + border: 1px solid var(--line-strong); border-radius: var(--radius-card); box-shadow: var(--shadow-pop); +} +.marker-cluster-menu button { + width: 100%; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; + padding: 7px 8px; border: 0; border-radius: var(--radius-ctl); color: var(--ink); + background: transparent; cursor: pointer; text-align: left; +} +.marker-cluster-menu button:hover, .marker-cluster-menu button:focus-visible { background: var(--surface-2); } +.marker-cluster-menu small { color: var(--ink-3); font-family: var(--font-mono); white-space: nowrap; } .marker-player .chip { font-size: 11px; font-weight: 500; color: var(--ink); background: var(--surface); border: 1px solid var(--line-strong); diff --git a/frontend/src/routes/map/Map.tsx b/frontend/src/routes/map/Map.tsx index 0d25b0b..c4451ca 100644 --- a/frontend/src/routes/map/Map.tsx +++ b/frontend/src/routes/map/Map.tsx @@ -16,6 +16,7 @@ import { } from "../../app/mapTransform"; import { formatRelativeToNow, formatWorldGuid } from "../../app/format"; import { tileZoomForScale } from "../../app/mapTiles"; +import { clusterMapMarkers, type ClusterMarkerGroup } from "../../app/mapClustering"; import { addContainedMapWheelListener, buildSharedMapURL, @@ -141,6 +142,7 @@ export default function MapRoute() { const [mapSearch, setMapSearch] = useState(""); const [searchExpanded, setSearchExpanded] = useState(false); const [selectedTargetKey, setSelectedTargetKey] = useState(null); + const [expandedClusterKey, setExpandedClusterKey] = useState(null); const [activeLayerId, setActiveLayerId] = useState(() => initialShared.current?.layerId ?? null); const wellRef = useRef(null); const dragRef = useRef<{ startX: number; startY: number; tx: number; ty: number; moved: boolean } | null>(null); @@ -368,11 +370,12 @@ export default function MapRoute() { setPinnedGame(worldToGame(location.x, location.y)); }, [activeLayer, availableLayers, scaleBoundsFor, view?.scale]); - function focusTarget(target: MapSearchTarget) { + const focusTarget = useCallback((target: MapSearchTarget) => { focusWorldLocation(target.location, target.kind, target.key); setMapSearch(target.label); setSearchExpanded(false); - } + setExpandedClusterKey(null); + }, [focusWorldLocation]); function fitLocations(kind: "player" | "base") { const locations = kind === "player" @@ -410,6 +413,42 @@ export default function MapRoute() { [view], ); + const markerGroups = useMemo(() => { + const points = searchTargets + .filter((target) => onLayer(activeLayer, target.location.x, target.location.y)) + .map((target) => { + const mapPoint = layerWorldToMap(activeLayer, target.location.x, target.location.y); + const screenPoint = toScreen(mapPoint.x, mapPoint.y); + return { + key: target.key, + kind: target.kind, + layerId: activeLayer.id, + x: screenPoint.x, + y: screenPoint.y, + value: target, + }; + }); + return clusterMapMarkers(points, 48, selectedTargetKey); + }, [activeLayer, searchTargets, selectedTargetKey, toScreen]); + const baseMarkerGroups = markerGroups.filter((group) => markerKind(group) === "base"); + const playerMarkerGroups = markerGroups.filter((group) => markerKind(group) === "player"); + + const focusCluster = useCallback((group: Extract, { type: "cluster" }>) => { + const el = wellRef.current; + if (!el) return; + const points = group.members.map(({ value: target }) => + layerWorldToMap(activeLayer, target.location.x, target.location.y)); + const next = fitMapPoints(points, { width: el.clientWidth, height: el.clientHeight }, scaleBoundsFor(activeLayer)); + // Zoom to the exact member extent first. If the members are still inseparable at the + // current/max scale (including identical coordinates), expose the exact-marker chooser. + if (next && (!view || next.scale > view.scale * 1.15)) { + setExpandedClusterKey(null); + setView(next); + return; + } + setExpandedClusterKey((current) => current === group.key ? null : group.key); + }, [activeLayer, scaleBoundsFor, view]); + const hasMap = tileState === "tiles" || tileState === "mockgrid"; useEffect(() => { @@ -599,32 +638,26 @@ export default function MapRoute() { {/* screen-space markers (chips stay crisp and unscaled) */} - {layers.Bases && - bases - .filter((b) => onLayer(activeLayer, b.location.x, b.location.y)) - .map((b) => { - const m = layerWorldToMap(activeLayer, b.location.x, b.location.y); - const s = toScreen(m.x, m.y); - return ( -
- - {b.guildName} -
- ); - })} - {layers.Players && - playerMarkers - .filter((p) => onLayer(activeLayer, p.location.x, p.location.y)) - .map((p) => { - const m = layerWorldToMap(activeLayer, p.location.x, p.location.y); - const s = toScreen(m.x, m.y); - return ( -
- - {p.name} -
- ); - })} + {layers.Bases && baseMarkerGroups.map((group) => ( + + ))} + {layers.Players && playerMarkerGroups.map((group) => ( + + ))} {layers.Workers && workers .filter((worker) => onLayer(activeLayer, worker.location.x, worker.location.y)) @@ -710,6 +743,74 @@ export default function MapRoute() { ); } +function markerKind(group: ClusterMarkerGroup): "player" | "base" { + return group.type === "single" ? group.member.kind : group.members[0].kind; +} + +function MapMarkerGroup({ + group, + selectedTargetKey, + expanded, + onTarget, + onCluster, +}: { + group: ClusterMarkerGroup; + selectedTargetKey: string | null; + expanded: boolean; + onTarget: (target: MapSearchTarget) => void; + onCluster: (group: Extract, { type: "cluster" }>) => void; +}) { + const kind = markerKind(group); + const Icon = kind === "player" ? IconMapPlayer : IconMapBase; + if (group.type === "single") { + const target = group.member.value; + return ( + + ); + } + + const noun = kind === "player" ? "online players" : "bases"; + const names = group.members.map(({ value }) => value.label); + return ( + <> + + {expanded && ( +
+ {group.members.map(({ value: target }) => { + const coordinate = worldToGame(target.location.x, target.location.y); + return ( + + ); + })} +
+ )} + + ); +} + /** Renders the full tile pyramid level `z` of `layer` in map space (each level covers the * 256-square). */ function TileGrid({ layer, z, onTileError }: { layer: ResolvedLayer; z: number; onTileError: () => void }) { diff --git a/frontend/src/routes/paldeck/Paldeck.css b/frontend/src/routes/paldeck/Paldeck.css new file mode 100644 index 0000000..019d9c8 --- /dev/null +++ b/frontend/src/routes/paldeck/Paldeck.css @@ -0,0 +1,29 @@ +.paldeck-page { gap: var(--space-4); } +.paldeck-head { justify-content: space-between; align-items: flex-end; } +.paldeck-head > div:first-child { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; } +.paldeck-player-select { display: flex; flex-direction: column; gap: 4px; min-width: 220px; color: var(--ink-3); font-size: var(--text-xs); } +.paldeck-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); } +.paldeck-stat { display: flex; flex-direction: column; gap: 5px; } +.paldeck-stat > span { color: var(--ink-3); font-size: var(--text-xs); text-transform: uppercase; letter-spacing: var(--track-caps); } +.paldeck-stat strong { font: 600 var(--text-xl)/1.2 var(--font-mono); font-variant-numeric: tabular-nums; } +.paldeck-stat small { color: var(--ink-3); } +.paldeck-stat progress { width: 100%; height: 7px; accent-color: var(--accent); } +.paldeck-tools { display: grid; grid-template-columns: minmax(220px, 1fr) 180px; gap: var(--space-2); } +.paldeck-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); padding-top: 0; } +.paldeck-species { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-ctl); background: var(--surface-2); } +.paldeck-species > div:nth-child(2) { min-width: 0; } +.paldeck-species strong, .paldeck-species small { display: block; } +.paldeck-species strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.paldeck-species small { margin-top: 2px; color: var(--ink-3); font-size: 10px; line-height: 1.35; } +.paldeck-species-meta { grid-column: 1 / -1; display: flex; gap: 8px; align-items: center; justify-content: flex-end; color: var(--ink-3); font-size: 10px; } +.paldeck-species-meta a { margin-left: auto; } +.paldeck-footnote { color: var(--ink-3); font-size: var(--text-xs); line-height: 1.5; } +.paldeck-skeleton { width: 100%; height: 190px; } +@media (max-width: 1000px) { .paldeck-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 800px) { .paldeck-stats { grid-template-columns: 1fr; } } +@media (max-width: 650px) { + .paldeck-head { align-items: stretch; flex-direction: column; } + .paldeck-head > div:first-child { align-items: flex-start; flex-direction: column; gap: 2px; } + .paldeck-player-select { min-width: 0; } + .paldeck-tools, .paldeck-grid { grid-template-columns: 1fr; } +} diff --git a/frontend/src/routes/paldeck/Paldeck.tsx b/frontend/src/routes/paldeck/Paldeck.tsx new file mode 100644 index 0000000..4be9d2b --- /dev/null +++ b/frontend/src/routes/paldeck/Paldeck.tsx @@ -0,0 +1,146 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router"; +import { api } from "../../api/client"; +import type { PlayerPaldeck, ServerPaldeck } from "../../api/types"; +import { formatRelativeToNow } from "../../app/format"; +import { Banner } from "../../components/Banner"; +import { Card, CardBody, CardHead } from "../../components/Card"; +import { EmptyState } from "../../components/EmptyState"; +import { SearchField } from "../../components/Field"; +import { PalIcon } from "../../components/PalIcon"; +import { palExplorerHref } from "../pals/palExplorer"; +import { filterPaldeckSpecies, paldeckPercent, type PaldeckSpeciesFilter } from "./paldeckView"; +import "./Paldeck.css"; + +type PaldeckSpecies = ServerPaldeck["species"][number] | PlayerPaldeck["species"][number]; + +export default function PaldeckRoute() { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedUid = searchParams.get("player") ?? ""; + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("all"); + const playersQuery = useQuery({ queryKey: ["players"], queryFn: () => api.players.list() }); + const serverQuery = useQuery({ queryKey: ["paldeck", "server"], queryFn: () => api.paldeck.get(), enabled: !selectedUid }); + const playerQuery = useQuery({ queryKey: ["paldeck", "player", selectedUid], queryFn: () => api.paldeck.player(selectedUid), enabled: Boolean(selectedUid) }); + const data = selectedUid ? playerQuery.data : serverQuery.data; + const query = selectedUid ? playerQuery : serverQuery; + const captureConclusive = data ? "player" in data + ? data.coverage.captureCountsAvailable && !data.coverage.captureCountsTruncated + : data.coverage.playersTotal > 0 && data.coverage.playersWithCaptureCounts === data.coverage.playersTotal && !data.coverage.captureCountsTruncated + : false; + const effectiveFilter = !captureConclusive && filter === "unseen" ? "all" : filter; + const species = useMemo(() => filterPaldeckSpecies(data?.species ?? [], search, effectiveFilter), [data?.species, search, effectiveFilter]); + + function selectPlayer(uid: string) { + const next = new URLSearchParams(searchParams); + if (uid) next.set("player", uid); + else next.delete("player"); + setSearchParams(next, { replace: true }); + } + + return ( +
+
+
+

Save-observed Paldeck

+ pinned 1.0 catalog · authoritative RecordData only +
+ +
+ + {query.isError ? ( + Couldn't load Paldeck observations from the parsed player saves. + ) : query.isPending || !data ? ( + + ) : ( + + )} +
+ ); +} + +function PaldeckContent({ data, search, setSearch, filter, setFilter, species }: { + data: ServerPaldeck | PlayerPaldeck; + search: string; + setSearch: (value: string) => void; + filter: PaldeckSpeciesFilter; + setFilter: (value: PaldeckSpeciesFilter) => void; + species: PaldeckSpecies[]; +}) { + const isPlayer = "player" in data; + const captureAvailable = isPlayer ? data.coverage.captureCountsAvailable : data.coverage.playersWithCaptureCounts > 0; + const captureTruncated = data.coverage.captureCountsTruncated; + const captureConclusive = isPlayer + ? data.coverage.captureCountsAvailable && !captureTruncated + : data.coverage.playersTotal > 0 && data.coverage.playersWithCaptureCounts === data.coverage.playersTotal && !captureTruncated; + const unlockConclusive = isPlayer + ? data.coverage.unlockFlagsAvailable && !data.coverage.unlockFlagsTruncated + : data.coverage.playersTotal > 0 && data.coverage.playersWithUnlockFlags === data.coverage.playersTotal && !data.coverage.unlockFlagsTruncated; + const observedAt = isPlayer ? data.coverage.captureObservedAt : data.coverage.latestObservedAt; + const rawUnique = isPlayer ? data.uniquePalsCaptured : data.uniqueSpeciesCaptured; + const pinnedCaptured = captureConclusive ? data.species.filter((item) => item.known && item.captureCount !== null && item.captureCount > 0).length : null; + const pinnedUnlocked = unlockConclusive ? data.species.filter((item) => item.known && (isPlayer ? (item as PlayerPaldeck["species"][number]).unlocked === true : ((item as ServerPaldeck["species"][number]).unlockedByPlayers ?? 0) > 0)).length : null; + + return ( + <> + + {isPlayer + ? captureAvailable ? `Capture map observed ${formatRelativeToNow(observedAt)}.` : "This player's save has no decoded per-species capture map yet." + : `${data.coverage.playersWithCaptureCounts} of ${data.coverage.playersTotal} players have decoded capture maps; this is the observed server union.`} + {captureTruncated ? " The decoded capture map was truncated, so unseen entries are not conclusive." : " Missing observations are never counted as zero."} + + +
+ + + Aggregate captures{data.captureTotal ?? "Unavailable"}player-save aggregate counter + Save unique counter{rawUnique ?? "Unavailable"}may include IDs outside pinned catalog +
+ + + + +
+ setSearch(event.target.value)} placeholder="Search Pal name…" aria-label="Search Paldeck species" /> + +
+
+ {species.length === 0 ? : ( + + {species.map((item) => { + const count = item.captureCount; + const unsafeZero = !captureConclusive && count === 0; + const serverItem = !isPlayer ? item as ServerPaldeck["species"][number] : null; + const playerItem = isPlayer ? item as PlayerPaldeck["species"][number] : null; + return ( +
+ +
{item.displayName}{!item.known ? `Unknown observed id · ${item.characterId}` : count === null ? "Capture observation unavailable" : unsafeZero ? "Zero in available partial observations" : count === 0 ? "Complete observation: unseen" : `${count} save-observed capture${count === 1 ? "" : "s"}`}
+
+ {serverItem && serverItem.capturedByPlayers !== null && {serverItem.capturedByPlayers} players} + {playerItem?.unlocked !== null && playerItem?.unlocked !== undefined && {playerItem.unlocked ? "Unlocked" : "Locked"}} + {count !== null && count > 0 && View roster} +
+
+ ); + })} +
+ )} +
+

Catalog {data.catalog.version} · {data.catalog.observedUnknownSpecies} observed IDs outside the pinned catalog. Counts are last-parsed save observations, not a lifetime timeline.

+ + ); +} + +function ProgressStat({ label, value, denominator, percent }: { label: string; value: number | null; denominator: number; percent: number | null }) { + return {label}{value === null ? "Unavailable" : `${value} / ${denominator}`}{percent === null ? "coverage unavailable" : `${percent}% of pinned catalog`}{percent !== null && }; +} diff --git a/frontend/src/routes/paldeck/paldeckView.ts b/frontend/src/routes/paldeck/paldeckView.ts new file mode 100644 index 0000000..6bef958 --- /dev/null +++ b/frontend/src/routes/paldeck/paldeckView.ts @@ -0,0 +1,28 @@ +export type PaldeckSpeciesFilter = "all" | "captured" | "unseen" | "unavailable"; + +export interface PaldeckSpeciesView { + characterId: string; + displayName: string; + known: boolean; + captureCount: number | null; +} + +export function paldeckPercent(value: number | null, knownSpecies: number): number | null { + if (value === null || knownSpecies <= 0) return null; + return Math.max(0, Math.min(100, Math.round((value / knownSpecies) * 1000) / 10)); +} + +export function filterPaldeckSpecies( + species: readonly T[], + search: string, + filter: PaldeckSpeciesFilter, +): T[] { + const needle = search.trim().toLocaleLowerCase(); + return species.filter((item) => { + if (needle && !`${item.displayName} ${item.characterId}`.toLocaleLowerCase().includes(needle)) return false; + if (filter === "captured") return item.captureCount !== null && item.captureCount > 0; + if (filter === "unseen") return item.captureCount === 0; + if (filter === "unavailable") return item.captureCount === null; + return true; + }); +} diff --git a/frontend/src/routes/pals/Pals.tsx b/frontend/src/routes/pals/Pals.tsx index cd0e211..32acd5f 100644 --- a/frontend/src/routes/pals/Pals.tsx +++ b/frontend/src/routes/pals/Pals.tsx @@ -1,5 +1,6 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useInfiniteQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router"; import { api } from "../../api/client"; import type { PalExplorerPal } from "../../api/types"; import { Banner } from "../../components/Banner"; @@ -13,24 +14,19 @@ import { SearchField } from "../../components/Field"; import { PAL_EXPLORER_CLIENT_CAP, PAL_EXPLORER_PAGE_SIZE, + EMPTY_PAL_EXPLORER_FILTERS, + palExplorerFiltersFromSearch, palExplorerParams, + palExplorerSearch, palOwnerSummary, palSpecimenLabels, type PalExplorerFilterState, } from "./palExplorer"; import "./Pals.css"; -const initialFilters: PalExplorerFilterState = { - q: "", - ownerSource: "", - placement: "", - specimen: "", - minLevel: "", - maxLevel: "", -}; - export default function PalsRoute() { - const [filters, setFilters] = useState(initialFilters); + const [searchParams, setSearchParams] = useSearchParams(); + const filters = useMemo(() => palExplorerFiltersFromSearch(searchParams), [searchParams]); const [debouncedSearch, setDebouncedSearch] = useState(""); const [expanded, setExpanded] = useState(null); @@ -56,7 +52,7 @@ export default function PalsRoute() { const capped = pals.length >= PAL_EXPLORER_CLIENT_CAP && palsQuery.data?.pages.at(-1)?.nextCursor !== null; function update(key: K, value: PalExplorerFilterState[K]) { - setFilters((current) => ({ ...current, [key]: value })); + setSearchParams(palExplorerSearch({ ...filters, [key]: value }, searchParams), { replace: true }); setExpanded(null); } @@ -109,7 +105,7 @@ export default function PalsRoute() { Maximum level update("maxLevel", event.target.value)} placeholder="Any" /> - diff --git a/frontend/src/routes/pals/palExplorer.ts b/frontend/src/routes/pals/palExplorer.ts index dc0c369..0fc0735 100644 --- a/frontend/src/routes/pals/palExplorer.ts +++ b/frontend/src/routes/pals/palExplorer.ts @@ -12,6 +12,66 @@ export interface PalExplorerFilterState { maxLevel: string; } +export const EMPTY_PAL_EXPLORER_FILTERS: PalExplorerFilterState = { + q: "", + ownerSource: "", + placement: "", + specimen: "", + minLevel: "", + maxLevel: "", +}; + +const PAL_EXPLORER_SEARCH_KEYS = ["q", "ownerSource", "placement", "specimen", "minLevel", "maxLevel"] as const; + +/** Restore only the explorer's allowlisted filters from a shareable URL. */ +export function palExplorerFiltersFromSearch(search: URLSearchParams | string): PalExplorerFilterState { + const query = typeof search === "string" ? new URLSearchParams(search) : search; + const params = palExplorerParams({ + q: query.get("q") ?? "", + ownerSource: query.get("ownerSource") ?? "", + placement: query.get("placement") ?? "", + specimen: query.get("specimen") ?? "", + minLevel: query.get("minLevel") ?? "", + maxLevel: query.get("maxLevel") ?? "", + }); + return { + q: params.q ?? "", + ownerSource: params.ownerSource ?? "", + placement: params.placement ?? "", + specimen: params.specimen ?? "", + minLevel: params.minLevel === undefined ? "" : String(params.minLevel), + maxLevel: params.maxLevel === undefined ? "" : String(params.maxLevel), + }; +} + +/** + * Write a canonical explorer query while retaining unrelated flags such as `mock`. + * Cursors are intentionally excluded so a record link always starts at fresh results. + */ +export function palExplorerSearch( + state: PalExplorerFilterState, + current: URLSearchParams | string = "", +): URLSearchParams { + const query = new URLSearchParams(typeof current === "string" ? current : current.toString()); + for (const key of PAL_EXPLORER_SEARCH_KEYS) query.delete(key); + query.delete("cursor"); + const params = palExplorerParams(state); + if (params.q) query.set("q", params.q); + if (params.ownerSource) query.set("ownerSource", params.ownerSource); + if (params.placement) query.set("placement", params.placement); + if (params.specimen) query.set("specimen", params.specimen); + if (params.minLevel !== undefined) query.set("minLevel", String(params.minLevel)); + if (params.maxLevel !== undefined) query.set("maxLevel", String(params.maxLevel)); + return query; +} + +/** Build a stable panel deep link for records, history, guilds, or external integrations. */ +export function palExplorerHref(filters: Partial): string { + const query = palExplorerSearch({ ...EMPTY_PAL_EXPLORER_FILTERS, ...filters }); + const suffix = query.toString(); + return suffix ? `/pals?${suffix}` : "/pals"; +} + /** Convert form strings to the API's narrow query contract without sending empty values. */ export function palExplorerParams(state: PalExplorerFilterState): PalExplorerParams { const params: PalExplorerParams = {}; diff --git a/frontend/src/routes/players/Players.tsx b/frontend/src/routes/players/Players.tsx index de5b409..4c36cf6 100644 --- a/frontend/src/routes/players/Players.tsx +++ b/frontend/src/routes/players/Players.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { Link, useNavigate, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; import type { Player, PlayerActivity, PlayerActivityWindow, WhitelistEntry } from "../../api/types"; @@ -52,7 +52,14 @@ function StatusPill({ p }: { p: Player }) { export default function PlayersRoute() { const [tab, setTab] = useState("players"); - const [selectedUid, setSelectedUid] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedUid = searchParams.get("player"); + const setSelectedUid = (uid: string | null) => { + const next = new URLSearchParams(searchParams); + if (uid) next.set("player", uid); + else next.delete("player"); + setSearchParams(next, { replace: true }); + }; const playersQuery = useQuery({ queryKey: ["players"], queryFn: () => api.players.list(), refetchInterval: 15000 }); const guildsQuery = useQuery({ queryKey: ["guilds"], queryFn: () => api.guilds.list() }); @@ -224,7 +231,7 @@ function PlayersTab({ {p.level} - {p.guildName ?? "—"} + event.stopPropagation()}>{p.guildId && p.guildName ? {p.guildName} : "—"} {p.ping !== null ? `${p.ping} ms` : "—"} {lastSeenLabel(p)} e.stopPropagation()}> @@ -341,7 +348,7 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k
Guild - {d.guildName ?? "—"} + {d.guildId && d.guildName ? {d.guildName} : "—"}
Position @@ -638,7 +645,7 @@ function GuildsTab() {
{initials(g.name)}
-
{g.name}
+
{g.name}
{g.id}
diff --git a/frontend/tests/ci-workflow.test.mjs b/frontend/tests/ci-workflow.test.mjs index 9ec7a7b..74f13d8 100644 --- a/frontend/tests/ci-workflow.test.mjs +++ b/frontend/tests/ci-workflow.test.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import test from "node:test"; const workflowUrl = new URL("../../.github/workflows/ci.yml", import.meta.url); +const imageWorkflowUrl = new URL("../../.github/workflows/ghcr.yml", import.meta.url); test("frontend CI prefetches Go fixture dependencies before its bounded test timeout", async () => { const workflow = await readFile(workflowUrl, "utf8"); @@ -12,3 +13,25 @@ test("frontend CI prefetches Go fixture dependencies before its bounded test tim assert.doesNotMatch(frontendJob, /cache:\s*false/); assert.ok(frontendJob.indexOf("run: go mod download") < frontendJob.indexOf("run: npm test")); }); + +test("workflows use the current Node 24 action majors", async () => { + const [ci, image] = await Promise.all([ + readFile(workflowUrl, "utf8"), + readFile(imageWorkflowUrl, "utf8"), + ]); + assert.match(ci, /actions\/checkout@v6/); + assert.match(ci, /actions\/setup-node@v6/); + assert.match(ci, /actions\/setup-go@v6/); + assert.doesNotMatch(ci, /actions\/(?:checkout@v4|setup-node@v4|setup-go@v5)/); + + for (const action of [ + "actions/checkout@v6", + "docker/setup-buildx-action@v4", + "docker/login-action@v4", + "docker/metadata-action@v6", + "docker/build-push-action@v7", + "actions/attest@v4", + ]) { + assert.match(image, new RegExp(action.replace("/", "\\/"))); + } +}); diff --git a/frontend/tests/fixtures/map-transform-1.0.json b/frontend/tests/fixtures/map-transform-1.0.json new file mode 100644 index 0000000..5fb2327 --- /dev/null +++ b/frontend/tests/fixtures/map-transform-1.0.json @@ -0,0 +1,65 @@ +{ + "provenance": "Checked-in THGL 1.0 metadata from scripts/fetch-map-tiles.sh; only the Palpagos starting-area anchor is a surveyed gameplay location.", + "layers": [ + { + "id": "default", + "label": "Palpagos", + "tileSize": 512, + "transform": { + "a": 0.000353395913859746, + "b": 256, + "c": -0.000353395913859746, + "d": 123.47653230259525 + }, + "bounds": [[-1099399, -724399], [349399, 724399]], + "anchors": [ + { + "id": "verified-starting-area-live-position", + "kind": "surveyed", + "world": { "x": -353196.34375, "y": 270687.59375 }, + "game": { "x": 246, "y": -500 }, + "nativePixel": { "x": 351.6598895637769, "y": 248.2946769740475 } + }, + { + "id": "dataset-bounds-center", + "kind": "metadata", + "world": { "x": 0, "y": -375000 }, + "nativePixel": { "x": 123.47653230259525, "y": 123.47653230259525 } + } + ] + }, + { + "id": "tree", + "label": "World Tree", + "tileSize": 512, + "transform": { + "a": 0.0014979651664584533, + "b": 1225.6306053008072, + "c": -0.0014979651664584533, + "d": 1032.3204475170935 + }, + "bounds": [[347352.5, -818196], [689147.5, -476401]], + "anchors": [ + { + "id": "dataset-bounds-min", + "kind": "metadata", + "world": { "x": -818196, "y": 347352.5 }, + "nativePixel": { "x": 1745.9525507830672, "y": 2257.949554852734 } + }, + { + "id": "dataset-bounds-center", + "kind": "metadata", + "world": { "x": -647298.5, "y": 518250 }, + "game": { "x": 785, "y": -1140 }, + "nativePixel": { "x": 2001.9510528179007, "y": 2001.9510528179007 } + }, + { + "id": "dataset-bounds-max", + "kind": "metadata", + "world": { "x": -476401, "y": 689147.5 }, + "nativePixel": { "x": 2257.9495548527343, "y": 1745.9525507830672 } + } + ] + } + ] +} diff --git a/frontend/tests/guild-paldeck-routes.test.mjs b/frontend/tests/guild-paldeck-routes.test.mjs new file mode 100644 index 0000000..9696ce4 --- /dev/null +++ b/frontend/tests/guild-paldeck-routes.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; + +test("authenticated routes expose dedicated lazy Paldeck and guild pages", async () => { + const app = await readFile(new URL("../src/app/App.tsx", import.meta.url), "utf8"); + const shell = await readFile(new URL("../src/components/Shell.tsx", import.meta.url), "utf8"); + assert.match(app, /path="paldeck"/); + assert.match(app, /path="guilds\/:guildId"/); + assert.match(shell, /to: "\/paldeck"/); + assert.match(shell, /to: "\/guilds"/); +}); + +test("guild detail links members, bases, Pals, activity, and progression", async () => { + const source = await readFile(new URL("../src/routes/guilds/Guilds.tsx", import.meta.url), "utf8"); + assert.match(source, /api\.guilds\.detail/); + assert.match(source, /panel-observed/); + assert.match(source, /current membership/); + assert.match(source, /\/players\?player=/); + assert.match(source, /\/paldeck\?player=/); + assert.match(source, /\/map\?x=/); + assert.match(source, /palExplorerHref/); +}); + +test("Paldeck screen distinguishes partial save observations from pinned progression", async () => { + const source = await readFile(new URL("../src/routes/paldeck/Paldeck.tsx", import.meta.url), "utf8"); + assert.match(source, /playersWithCaptureCounts === data\.coverage\.playersTotal/); + assert.match(source, /Pinned species captured/); + assert.match(source, /Save unique counter/); + assert.match(source, /Missing observations are never counted as zero/); + assert.match(source, /Unseen \(complete coverage only\)/); +}); diff --git a/frontend/tests/mapClustering.test.mjs b/frontend/tests/mapClustering.test.mjs new file mode 100644 index 0000000..59de8a0 --- /dev/null +++ b/frontend/tests/mapClustering.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { clusterMapMarkers } from "../src/app/mapClustering.ts"; + +function point(key, kind, layerId, x, y, worldX = x, worldY = y) { + return { key, kind, layerId, x, y, value: { location: { x: worldX, y: worldY } } }; +} + +test("dense markers form deterministic bounded-diameter screen-space groups", () => { + const points = [ + point("player:c", "player", "default", 80, 0), + point("player:a", "player", "default", 0, 0), + point("player:b", "player", "default", 40, 0), + ]; + const groups = clusterMapMarkers(points, 48); + const [group] = groups; + assert.equal(group.type, "cluster"); + assert.deepEqual(group.members.map((member) => member.key), ["player:a", "player:b"]); + assert.deepEqual({ x: group.x, y: group.y }, { x: 20, y: 0 }); + assert.equal(groups[1].type, "single"); + assert.equal(groups[1].key, "player:c"); +}); + +test("a proximity chain cannot percolate beyond the cluster radius", () => { + const groups = clusterMapMarkers([ + point("player:a", "player", "default", 0, 0), + point("player:b", "player", "default", 40, 0), + point("player:c", "player", "default", 80, 0), + point("player:d", "player", "default", 120, 0), + ], 48); + assert.deepEqual(groups.map((group) => + group.type === "single" ? [group.member.key] : group.members.map((member) => member.key)), [ + ["player:a", "player:b"], + ["player:c", "player:d"], + ]); +}); + +test("clusters never cross player/base or tile-layer boundaries", () => { + const groups = clusterMapMarkers([ + point("player:default", "player", "default", 10, 10), + point("base:default", "base", "default", 10, 10), + point("player:tree", "player", "tree", 10, 10), + ], 48); + assert.equal(groups.length, 3); + assert.ok(groups.every((group) => group.type === "single")); +}); + +test("the selected marker stays exact and standalone while neighbors cluster", () => { + const selected = point("player:selected", "player", "default", 100, 100, -353196.34375, 270687.59375); + const groups = clusterMapMarkers([ + selected, + point("player:b", "player", "default", 102, 101, -350000, 271000), + point("player:c", "player", "default", 104, 102, -349000, 272000), + ], 48, selected.key); + const exact = groups.find((group) => group.key === selected.key); + const cluster = groups.find((group) => group.type === "cluster"); + assert.equal(exact.type, "single"); + assert.deepEqual(exact.member.value.location, { x: -353196.34375, y: 270687.59375 }); + assert.equal(cluster.members.length, 2); + assert.ok(cluster.members.every((member) => member.value.location !== selected.value.location)); +}); + +test("invalid clustering radius leaves every original marker accessible", () => { + const points = [ + point("player:a", "player", "default", 0, 0), + point("player:b", "player", "default", 0, 0), + ]; + assert.deepEqual(clusterMapMarkers(points, 0).map((group) => group.key), ["player:a", "player:b"]); +}); + +test("map clusters expose a direct exact-coordinate chooser when zoom cannot separate members", async () => { + const route = await readFile(new URL("../src/routes/map/Map.tsx", import.meta.url), "utf8"); + assert.match(route, /marker-cluster-menu/); + assert.match(route, /worldToGame\(target\.location\.x, target\.location\.y\)/); + assert.match(route, /onClick=\{\(\) => onTarget\(target\)\}/); + assert.match(route, /next\.scale > view\.scale \* 1\.15/); +}); diff --git a/frontend/tests/mapTransform.test.mjs b/frontend/tests/mapTransform.test.mjs index c88636d..b4410fc 100644 --- a/frontend/tests/mapTransform.test.mjs +++ b/frontend/tests/mapTransform.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { gameToWorld, @@ -16,6 +17,11 @@ const PALPAGOS = { }; const BOUNDS = [[-1099399, -724399], [349399, 724399]]; +const fixtures = JSON.parse(await readFile( + new URL("./fixtures/map-transform-1.0.json", import.meta.url), + "utf8", +)); + test("live starting-area data coordinates convert to Palworld display coordinates", () => { const game = worldToGame(-353196.34375, 270687.59375); assert.deepEqual(game, { x: 246, y: -500 }); @@ -36,3 +42,40 @@ test("THGL transform consumes Palworld Y horizontally and X vertically", () => { assert.ok(Math.abs(roundTrip.y - world.y) < 0.01); assert.equal(worldInBounds(world.x, world.y, BOUNDS), true); }); + +test("checked-in 1.0 layer anchors lock transform axes, offsets, and inverses", () => { + assert.deepEqual(fixtures.layers.map((layer) => layer.id), ["default", "tree"]); + for (const layer of fixtures.layers) { + for (const anchor of layer.anchors) { + const map = worldToLayerMap(anchor.world.x, anchor.world.y, layer.transform, layer.tileSize); + const nativePixel = { x: map.x * layer.tileSize / 256, y: map.y * layer.tileSize / 256 }; + assert.ok(Math.abs(nativePixel.x - anchor.nativePixel.x) < 1e-9, `${layer.id}/${anchor.id} x offset`); + assert.ok(Math.abs(nativePixel.y - anchor.nativePixel.y) < 1e-9, `${layer.id}/${anchor.id} y offset`); + const inverse = layerMapToWorld(map.x, map.y, layer.transform, layer.tileSize); + assert.ok(Math.abs(inverse.x - anchor.world.x) < 1e-6, `${layer.id}/${anchor.id} inverse x`); + assert.ok(Math.abs(inverse.y - anchor.world.y) < 1e-6, `${layer.id}/${anchor.id} inverse y`); + assert.equal(worldInBounds(anchor.world.x, anchor.world.y, layer.bounds), true, `${layer.id}/${anchor.id} bounds`); + if (anchor.game) assert.deepEqual(worldToGame(anchor.world.x, anchor.world.y), anchor.game); + } + } +}); + +test("Palpagos and World Tree bounds use data-Y/data-X order and do not bleed layers", () => { + const [palpagos, tree] = fixtures.layers; + const palpagosPoint = palpagos.anchors[0].world; + const treePoint = tree.anchors.find((anchor) => anchor.id === "dataset-bounds-center").world; + assert.equal(worldInBounds(palpagosPoint.x, palpagosPoint.y, palpagos.bounds), true); + assert.equal(worldInBounds(palpagosPoint.x, palpagosPoint.y, tree.bounds), false); + assert.equal(worldInBounds(treePoint.x, treePoint.y, tree.bounds), true); + assert.equal(worldInBounds(treePoint.x, treePoint.y, palpagos.bounds), false); + + for (const layer of fixtures.layers) { + const [[minY, minX], [maxY, maxX]] = layer.bounds; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + assert.equal(worldInBounds(minX, minY, layer.bounds), true, `${layer.id} inclusive minimum`); + assert.equal(worldInBounds(maxX, maxY, layer.bounds), true, `${layer.id} inclusive maximum`); + assert.equal(worldInBounds(minX - 1, centerY, layer.bounds), false, `${layer.id} world-X minimum`); + assert.equal(worldInBounds(centerX, minY - 1, layer.bounds), false, `${layer.id} world-Y minimum`); + } +}); diff --git a/frontend/tests/pal-explorer.test.mjs b/frontend/tests/pal-explorer.test.mjs index ca2d501..485a8d2 100644 --- a/frontend/tests/pal-explorer.test.mjs +++ b/frontend/tests/pal-explorer.test.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readFile } from "node:fs/promises"; -import { palExplorerParams, palOwnerSummary, palSpecimenLabels } from "../src/routes/pals/palExplorer.ts"; +import { + palExplorerFiltersFromSearch, + palExplorerHref, + palExplorerParams, + palExplorerSearch, + palOwnerSummary, + palSpecimenLabels, +} from "../src/routes/pals/palExplorer.ts"; test("Pal explorer narrows form strings to bounded API parameters", () => { assert.deepEqual(palExplorerParams({ @@ -20,6 +27,24 @@ test("owner evidence is explicit and unresolved owners are never guessed", () => assert.equal(palOwnerSummary({ ownerName: "", ownerResolved: false, ownerSource: "unresolved" }), "Owner unavailable"); }); +test("Pal explorer filters round-trip through a bounded shareable URL", () => { + const source = new URLSearchParams("mock=&q=%20Mammorest%20&specimen=boss&placement=base&minLevel=35&cursor=secret"); + const filters = palExplorerFiltersFromSearch(source); + assert.deepEqual(filters, { + q: "Mammorest", ownerSource: "", placement: "base", specimen: "boss", minLevel: "35", maxLevel: "", + }); + const query = palExplorerSearch(filters, source); + assert.equal(query.get("mock"), ""); + assert.equal(query.has("cursor"), false, "pagination cursors are never shared"); + assert.equal(palExplorerHref({ q: "Mammorest", specimen: "boss" }), "/pals?q=Mammorest&specimen=boss"); +}); + +test("invalid deep-link values degrade to empty filters instead of reaching the API", () => { + assert.deepEqual(palExplorerFiltersFromSearch("?ownerSource=guessed&placement=inventory&specimen=shiny&minLevel=-1&maxLevel=1000"), { + q: "", ownerSource: "", placement: "", specimen: "", minLevel: "", maxLevel: "", + }); +}); + test("boss variants receive a Boss emblem instead of a duplicate Alpha label", () => { assert.deepEqual(palSpecimenLabels({ isBoss: true, isAlpha: true, isLucky: false }), ["Boss"]); assert.deepEqual(palSpecimenLabels({ isBoss: false, isAlpha: true, isLucky: true }), ["Alpha", "Lucky"]); diff --git a/frontend/tests/paldeck-view.test.mjs b/frontend/tests/paldeck-view.test.mjs new file mode 100644 index 0000000..46c3224 --- /dev/null +++ b/frontend/tests/paldeck-view.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { filterPaldeckSpecies, paldeckPercent } from "../src/routes/paldeck/paldeckView.ts"; + +const species = [ + { characterId: "Anubis", displayName: "Anubis", known: true, captureCount: 3 }, + { characterId: "Mammorest", displayName: "Mammorest", known: true, captureCount: 0 }, + { characterId: "NewPal", displayName: "New Pal", known: false, captureCount: null }, +]; + +test("Paldeck filters distinguish observed zero from unavailable", () => { + assert.deepEqual(filterPaldeckSpecies(species, "", "captured").map((item) => item.characterId), ["Anubis"]); + assert.deepEqual(filterPaldeckSpecies(species, "", "unseen").map((item) => item.characterId), ["Mammorest"]); + assert.deepEqual(filterPaldeckSpecies(species, "", "unavailable").map((item) => item.characterId), ["NewPal"]); + assert.deepEqual(filterPaldeckSpecies(species, "new pal", "all").map((item) => item.characterId), ["NewPal"]); +}); + +test("Paldeck percentages remain unavailable without a value or catalog", () => { + assert.equal(paldeckPercent(null, 100), null); + assert.equal(paldeckPercent(20, 0), null); + assert.equal(paldeckPercent(51, 200), 25.5); + assert.equal(paldeckPercent(999, 200), 100); +}); From af72ac5563c0a848885f46cc47cd444642b9b6ca Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:33:56 -0500 Subject: [PATCH 02/14] polish: plain-English panel copy and layout overlap fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verbiage pass over today's panel pages — Paldeck, Guilds, Live map, Activity, Pal explorer, Players, Events — replacing internal jargon (save-observed, pinned catalog, panel-observed, owner evidence, exact association) with plain language while keeping every data-honesty distinction (partial vs complete capture data, truncation notes, current-membership attribution). Layout fixes: - Map: stack the layer-toggle rows in one flow column so a wrapping toggle/stamp row can no longer overlap the tile-layer picker - Paldeck/Guilds/Activity page heads wrap instead of colliding at mid widths - Helm strip instrument labels no longer wrap/clip near 900px - Removed the dead Learn more button on the missing-tiles empty state Tests updated to pin the new copy; suite 76/76, lint and build clean. Co-Authored-By: Claude Fable 5 --- frontend/src/routes/activity/Activity.css | 2 +- frontend/src/routes/activity/Activity.tsx | 14 +++---- frontend/src/routes/activity/activityView.ts | 8 ++-- frontend/src/routes/events/Events.tsx | 4 +- frontend/src/routes/guilds/Guilds.css | 4 +- frontend/src/routes/guilds/Guilds.tsx | 28 +++++++------- frontend/src/routes/map/Map.css | 18 ++++----- frontend/src/routes/map/Map.tsx | 40 +++++++++----------- frontend/src/routes/paldeck/Paldeck.css | 4 +- frontend/src/routes/paldeck/Paldeck.tsx | 38 +++++++++---------- frontend/src/routes/pals/Pals.tsx | 22 +++++------ frontend/src/routes/pals/palExplorer.ts | 4 +- frontend/src/routes/players/Players.tsx | 12 +++--- frontend/src/styles/ui.css | 7 +++- frontend/tests/activity-route.test.mjs | 10 ++--- frontend/tests/events-route.test.mjs | 4 +- frontend/tests/guild-paldeck-routes.test.mjs | 8 ++-- frontend/tests/pal-explorer.test.mjs | 4 +- frontend/tests/player-activity.test.mjs | 2 +- 19 files changed, 116 insertions(+), 117 deletions(-) diff --git a/frontend/src/routes/activity/Activity.css b/frontend/src/routes/activity/Activity.css index 3f12cfa..8b29c23 100644 --- a/frontend/src/routes/activity/Activity.css +++ b/frontend/src/routes/activity/Activity.css @@ -1,5 +1,5 @@ .activity-page { display: flex; flex-direction: column; gap: var(--space-4); } -.activity-head { align-items: flex-end; } +.activity-head { align-items: flex-end; justify-content: space-between; flex-wrap: wrap; row-gap: var(--space-2); } .activity-window-tabs { display: flex; gap: 4px; padding: 3px; border: 1px solid var(--line); border-radius: var(--radius-ctl); background: var(--surface-2); } .activity-window-tabs button { border: 0; border-radius: 5px; padding: 5px 10px; color: var(--ink-3); background: transparent; font-size: var(--text-xs); cursor: pointer; } .activity-window-tabs button.is-active { color: var(--accent-ink); background: var(--surface); box-shadow: inset 0 0 0 1px var(--line-strong); } diff --git a/frontend/src/routes/activity/Activity.tsx b/frontend/src/routes/activity/Activity.tsx index 9f415d5..310ed0c 100644 --- a/frontend/src/routes/activity/Activity.tsx +++ b/frontend/src/routes/activity/Activity.tsx @@ -32,7 +32,7 @@ export default function ActivityRoute() {

Player activity

- panel-observed sessions · rolling windows · no lifetime claims + observed sessions · rolling windows
{WINDOWS.map((item) => ( @@ -46,15 +46,15 @@ export default function ActivityRoute() { ) : query.isPending || !activity ? ( ) : activity.activePlayers === 0 ? ( - + ) : ( <> - {activityCoverageNote(activity)} These are panel observations, not lifetime game history. + {activityCoverageNote(activity)} Only sessions observed by this panel are counted.
- +
@@ -89,19 +89,19 @@ export default function ActivityRoute() { - {activity.players.map((player) => )} + {activity.players.map((player) => )}
PlayerObservedSessions
{player.name || "Unknown player"}{player.firstObserved ? "First observed in window" : player.currentSession ? "Current session open" : player.guildName || "No current guild"}{formatDuration(player.durationSec)}{player.sessionCount}
{player.name || "Unknown player"}{player.firstObserved ? "New this window" : player.currentSession ? "Online now" : player.guildName || "No current guild"}{formatDuration(player.durationSec)}{player.sessionCount}
- + {activity.guilds.length === 0 ? : ( {activity.guilds.map((guild) => )}
GuildObservedPlayers
{guild.guildName || "Unnamed guild"}{guild.sessionCount} observed sessions{formatDuration(guild.durationSec)}{guild.activePlayers}
-

Sessions are attributed to each player's current save-derived guild. Historical guild membership is not stored.

+

Time is credited to each player's current guild; past membership is not stored.

)}
diff --git a/frontend/src/routes/activity/activityView.ts b/frontend/src/routes/activity/activityView.ts index 478f44f..4c1902e 100644 --- a/frontend/src/routes/activity/activityView.ts +++ b/frontend/src/routes/activity/activityView.ts @@ -7,12 +7,12 @@ export function topPeakBuckets(buckets: ActivityConcurrencyBucket[], limit = 3): } export function activityCoverageNote(activity: Pick): string { - if (activity.analysisTruncated) return "The defensive interval cap was reached; rankings and concurrency may be incomplete."; - if (!activity.trackingSince) return "No player sessions have been observed by this panel yet."; + if (activity.analysisTruncated) return "Analysis hit its cap; rankings and concurrency may be incomplete."; + if (!activity.trackingSince) return "No player sessions observed yet."; if (new Date(activity.trackingSince) > new Date(activity.since)) { - return `Coverage begins ${new Date(activity.trackingSince).toLocaleString()}, partway through this window.`; + return `Tracking began ${new Date(activity.trackingSince).toLocaleString()}, partway through this window.`; } - return `Coverage includes the full selected window; panel tracking began ${new Date(activity.trackingSince).toLocaleString()}.`; + return `Tracking since ${new Date(activity.trackingSince).toLocaleString()} — the full window is covered.`; } export function localBucketLabel(at: string, bucketSec: number): string { diff --git a/frontend/src/routes/events/Events.tsx b/frontend/src/routes/events/Events.tsx index 9c20ea7..78dc9c5 100644 --- a/frontend/src/routes/events/Events.tsx +++ b/frontend/src/routes/events/Events.tsx @@ -91,7 +91,7 @@ export default function EventsRoute() { ))}

- Lane counts cover the newest {events.length.toLocaleString()} events returned by the panel (bounded to {FETCH_LIMIT}). + Counts cover the newest {events.length.toLocaleString()} events (up to {FETCH_LIMIT}).

@@ -101,7 +101,7 @@ export default function EventsRoute() { placeholder="Search event messages…" aria-label="Search events" /> - changeKind(event.target.value as EventKindFilter)} aria-label="Filter event kind"> {availableKinds.map((item) => )} diff --git a/frontend/src/routes/guilds/Guilds.css b/frontend/src/routes/guilds/Guilds.css index d791b43..d3bcf7c 100644 --- a/frontend/src/routes/guilds/Guilds.css +++ b/frontend/src/routes/guilds/Guilds.css @@ -1,6 +1,6 @@ .guilds-page { gap: var(--space-4); } -.guilds-head { align-items: center; justify-content: space-between; } -.guilds-head > div { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; } +.guilds-head { align-items: center; justify-content: space-between; flex-wrap: wrap; row-gap: var(--space-2); } +.guilds-head > div { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; flex-wrap: wrap; } .guilds-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } .guild-card-body { display: flex; flex-direction: column; gap: 6px; color: var(--ink-2); font-size: var(--text-sm); } .guild-card-body span:last-child { color: var(--ink-3); } diff --git a/frontend/src/routes/guilds/Guilds.tsx b/frontend/src/routes/guilds/Guilds.tsx index fd792e6..1e0802e 100644 --- a/frontend/src/routes/guilds/Guilds.tsx +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -25,7 +25,7 @@ export default function GuildsRoute() {

{guildId ? detailQuery.data?.name || "Guild detail" : "Guilds"}

- current save roster · bases · known members + rosters · bases · members from the latest save
{guildId && All guilds}
@@ -46,8 +46,8 @@ export default function GuildsRoute() { {item.name || "Unnamed guild"}} hint={`${item.memberCount} members`} /> - {item.bases.length} save-derived {item.bases.length === 1 ? "base" : "bases"} - {item.members.length > 0 ? item.members.map((member) => member.name || "Unknown player").join(", ") : "No known member names"} + {item.bases.length} {item.bases.length === 1 ? "base" : "bases"} + {item.members.length > 0 ? item.members.map((member) => member.name || "Unknown player").join(", ") : "No known members"} ))} @@ -60,26 +60,26 @@ export default function GuildsRoute() { function GuildDetailView({ guild }: { guild: GuildDetail }) { return ( <> - This is the current parsed save roster. The 30-day activity below is panel-observed and attributed to current membership; it does not reconstruct historical transfers.{guild.activity.analysisTruncated ? " Analysis hit its safety cap." : ""} + Roster from the latest parsed save. Activity is panel-observed over the last 30 days and credited to current membership.{guild.activity.analysisTruncated ? " Analysis was truncated." : ""}
- Known members{guild.memberCount}{guild.members.filter((member) => member.online).length} online now - Bases{guild.bases.length}{guild.bases.reduce((total, base) => total + base.palCount, 0)} linked base workers - Associated Pals{guild.palCount}exact base or current-member association - Observed activity · 30d{formatDuration(guild.activity.durationSec)}{guild.activity.sessionCount} sessions · {guild.activity.activePlayers} players + Members{guild.memberCount}{guild.members.filter((member) => member.online).length} online now + Bases{guild.bases.length}{guild.bases.reduce((total, base) => total + base.palCount, 0)} base workers + Linked Pals{guild.palCount}at bases or owned by members + Activity · 30d{formatDuration(guild.activity.durationSec)}{guild.activity.sessionCount} sessions · {guild.activity.activePlayers} players
- + {guild.members.length === 0 ? : ( - {guild.members.map((member) => )} + {guild.members.map((member) => )}
PlayerLevelObserved · 30dProgress
{member.name || "Unknown player"}{member.online ? Online : `seen ${formatRelativeToNow(member.lastSeenAt)}`}{member.level}{formatDuration(member.observedDurationSec)}{member.observedSessionCount} sessions{member.paldeckUnlocked === null ? Unavailable : {member.paldeckUnlocked} save unlock counter}
{member.name || "Unknown player"}{member.online ? Online : `seen ${formatRelativeToNow(member.lastSeenAt)}`}{member.level}{formatDuration(member.observedDurationSec)}{member.observedSessionCount} sessions{member.paldeckUnlocked === null ? Unavailable : {member.paldeckUnlocked} Paldeck unlocks}
)}
- + {guild.bases.length === 0 ? : ( @@ -93,16 +93,16 @@ function GuildDetailView({ guild }: { guild: GuildDetail }) { - + Open Pal explorer - {guild.pals.length === 0 ? : ( + {guild.pals.length === 0 ? : ( {guild.pals.map((pal) => { const specimen = palSpecimenLabels(pal); return
-
{pal.displayName}Lv {pal.level} · {specimen.length ? specimen.map((label) => label === "Boss" ? "◆ Boss" : label).join(" · ") : "Standard"}{pal.association === "guild_base" ? "Exact guild base" : palOwnerSummary(pal)}
+
{pal.displayName}Lv {pal.level} · {specimen.length ? specimen.map((label) => label === "Boss" ? "◆ Boss" : label).join(" · ") : "Standard"}{pal.association === "guild_base" ? "Guild base" : palOwnerSummary(pal)}
Roster
; })} diff --git a/frontend/src/routes/map/Map.css b/frontend/src/routes/map/Map.css index 4728ca9..a3c6f55 100644 --- a/frontend/src/routes/map/Map.css +++ b/frontend/src/routes/map/Map.css @@ -69,14 +69,15 @@ border: 1px solid var(--line); } -.map-toggles { +/* stacked top-left overlay column: row 1 is the marker-layer toggles (+ warning stamps), + row 2 the tile-pyramid picker (e.g. Palpagos / World Tree) when the dataset has one — + normal flow inside the column, so a wrapping first row can never overlap the second */ +.map-overlays { position: absolute; top: 12px; left: 12px; z-index: 2; - display: flex; flex-wrap: wrap; gap: 6px; max-width: calc(100% - 24px); + display: flex; flex-direction: column; align-items: flex-start; gap: 8px; + max-width: calc(100% - 24px); } - -/* second row: the base tile-pyramid picker (e.g. Palpagos / World Tree) — only rendered when - the dataset reports more than one layer, so it sits just under the overlay-toggle row */ -.map-layer-toggles { top: 48px; } +.map-toggles { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; } .map-coord { position: absolute; bottom: 12px; right: 12px; z-index: 2; @@ -221,17 +222,14 @@ .map-card { height: 78dvh; min-height: 600px; } .map-actionbar { padding: 8px; gap: 6px; } .map-action { min-height: 40px; padding-inline: 9px; font-size: var(--text-sm); } + .map-overlays { left: 8px; top: 8px; max-width: calc(100% - 16px); } .map-toggles { flex-wrap: nowrap; - max-width: calc(100% - 16px); - left: 8px; - top: 8px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 3px; } - .map-layer-toggles { top: 48px; } .map-zoom { left: 8px; bottom: 8px; flex-direction: row; } .map-zoom button { width: 44px; height: 44px; } .map-zoom button + button { border-top: 0; border-left: 1px solid var(--line); } diff --git a/frontend/src/routes/map/Map.tsx b/frontend/src/routes/map/Map.tsx index c4451ca..0161680 100644 --- a/frontend/src/routes/map/Map.tsx +++ b/frontend/src/routes/map/Map.tsx @@ -483,7 +483,7 @@ export default function MapRoute() { - + {playerMarkerSelection.usedLive && liveSnapshot?.capturedAt ? ( live snapshot {formatRelativeToNow(liveSnapshot.capturedAt)} ) : healthQuery.data ? ( @@ -557,6 +557,7 @@ export default function MapRoute() { onPointerLeave={hasMap ? cancelPointer : undefined} > {hasMap && ( +
Game data unavailable} {liveSnapshot?.truncated && Live data incomplete}
- )} - - {hasMap && availableLayers.length > 1 && ( -
- {availableLayers.map((l) => ( - setActiveLayerId(l.id)}> - {l.label} - - ))} -
+ {availableLayers.length > 1 && ( +
+ {availableLayers.map((l) => ( + setActiveLayerId(l.id)}> + {l.label} + + ))} +
+ )} +
)} {tileState === "missing" && (
} title="Map tiles not installed">

- Live map rendering needs terrain tiles derived from the game's own assets. These are copyrighted by - Pocketpair and are not shipped with Palhelm — generate them once from your server's install. + Map tiles come from the game's own assets, which Palhelm can't ship (they're Pocketpair's). + Generate them once from your server's install:

docker exec palhelm palhelm fetch-map-tiles -
- -
)} @@ -720,12 +716,12 @@ export default function MapRoute() {
{liveMapActors.available && liveSnapshot && ( - + {liveSnapshot.diagnostics.unresolvedBasePals} unresolved {baseHealth.length === 0 ? ( -

No exact-linked live base workers are currently loaded.

+

No live base workers loaded right now.

) : (
{baseHealth.map((base) => ( @@ -769,7 +765,7 @@ function MapMarkerGroup({ type="button" className={`marker marker-action marker-${kind}${selectedTargetKey === target.key ? " is-selected" : ""}`} style={{ left: group.x, top: group.y }} - title={`Focus ${target.label} at exact coordinates`} + title={`Focus ${target.label}`} onClick={() => onTarget(target)} > @@ -788,7 +784,7 @@ function MapMarkerGroup({ style={{ left: group.x, top: group.y }} aria-label={`${group.members.length} nearby ${noun}: ${names.join(", ")}`} aria-expanded={expanded} - title={`${names.join(", ")} · zoom to separate; at maximum zoom, open the exact-marker chooser`} + title={names.join(", ")} onClick={() => onCluster(group)} > {group.members.length} diff --git a/frontend/src/routes/paldeck/Paldeck.css b/frontend/src/routes/paldeck/Paldeck.css index 019d9c8..712d1fa 100644 --- a/frontend/src/routes/paldeck/Paldeck.css +++ b/frontend/src/routes/paldeck/Paldeck.css @@ -1,6 +1,6 @@ .paldeck-page { gap: var(--space-4); } -.paldeck-head { justify-content: space-between; align-items: flex-end; } -.paldeck-head > div:first-child { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; } +.paldeck-head { justify-content: space-between; align-items: flex-end; flex-wrap: wrap; row-gap: var(--space-2); } +.paldeck-head > div:first-child { display: flex; align-items: baseline; gap: var(--space-3); min-width: 0; flex-wrap: wrap; } .paldeck-player-select { display: flex; flex-direction: column; gap: 4px; min-width: 220px; color: var(--ink-3); font-size: var(--text-xs); } .paldeck-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); } .paldeck-stat { display: flex; flex-direction: column; gap: 5px; } diff --git a/frontend/src/routes/paldeck/Paldeck.tsx b/frontend/src/routes/paldeck/Paldeck.tsx index 4be9d2b..f8f54bc 100644 --- a/frontend/src/routes/paldeck/Paldeck.tsx +++ b/frontend/src/routes/paldeck/Paldeck.tsx @@ -43,20 +43,20 @@ export default function PaldeckRoute() {
-

Save-observed Paldeck

- pinned 1.0 catalog · authoritative RecordData only +

Paldeck

+ capture progress from parsed saves · 1.0 catalog
{query.isError ? ( - Couldn't load Paldeck observations from the parsed player saves. + Couldn't load Paldeck data from player saves. ) : query.isPending || !data ? ( ) : ( @@ -92,29 +92,29 @@ function PaldeckContent({ data, search, setSearch, filter, setFilter, species }: <> {isPlayer - ? captureAvailable ? `Capture map observed ${formatRelativeToNow(observedAt)}.` : "This player's save has no decoded per-species capture map yet." - : `${data.coverage.playersWithCaptureCounts} of ${data.coverage.playersTotal} players have decoded capture maps; this is the observed server union.`} - {captureTruncated ? " The decoded capture map was truncated, so unseen entries are not conclusive." : " Missing observations are never counted as zero."} + ? captureAvailable ? `Capture data from ${formatRelativeToNow(observedAt)}.` : "No capture data decoded for this player yet." + : `Capture data covers ${data.coverage.playersWithCaptureCounts} of ${data.coverage.playersTotal} players.`} + {captureTruncated ? " The capture map was truncated, so “unseen” is not conclusive." : " Missing data is never counted as zero."} -
- - - Aggregate captures{data.captureTotal ?? "Unavailable"}player-save aggregate counter - Save unique counter{rawUnique ?? "Unavailable"}may include IDs outside pinned catalog +
+ + + Total captures{data.captureTotal ?? "Unavailable"}sum of save counters + Unique species counter{rawUnique ?? "Unavailable"}from the save · may include unlisted IDs
- +
setSearch(event.target.value)} placeholder="Search Pal name…" aria-label="Search Paldeck species" /> - setFilter(event.target.value as PaldeckSpeciesFilter)}> +
- {species.length === 0 ? : ( + {species.length === 0 ? : ( {species.map((item) => { const count = item.captureCount; @@ -124,7 +124,7 @@ function PaldeckContent({ data, search, setSearch, filter, setFilter, species }: return (
-
{item.displayName}{!item.known ? `Unknown observed id · ${item.characterId}` : count === null ? "Capture observation unavailable" : unsafeZero ? "Zero in available partial observations" : count === 0 ? "Complete observation: unseen" : `${count} save-observed capture${count === 1 ? "" : "s"}`}
+
{item.displayName}{!item.known ? `Unlisted ID · ${item.characterId}` : count === null ? "No capture data" : unsafeZero ? "None seen in partial data" : count === 0 ? "Not captured" : `${count} captured`}
{serverItem && serverItem.capturedByPlayers !== null && {serverItem.capturedByPlayers} players} {playerItem?.unlocked !== null && playerItem?.unlocked !== undefined && {playerItem.unlocked ? "Unlocked" : "Locked"}} @@ -136,11 +136,11 @@ function PaldeckContent({ data, search, setSearch, filter, setFilter, species }: )} -

Catalog {data.catalog.version} · {data.catalog.observedUnknownSpecies} observed IDs outside the pinned catalog. Counts are last-parsed save observations, not a lifetime timeline.

+

Catalog {data.catalog.version} · {data.catalog.observedUnknownSpecies} IDs outside the catalog. Counts reflect the latest parsed save.

); } function ProgressStat({ label, value, denominator, percent }: { label: string; value: number | null; denominator: number; percent: number | null }) { - return {label}{value === null ? "Unavailable" : `${value} / ${denominator}`}{percent === null ? "coverage unavailable" : `${percent}% of pinned catalog`}{percent !== null && }; + return {label}{value === null ? "Unavailable" : `${value} / ${denominator}`}{percent === null ? "needs full capture data" : `${percent}% of catalog`}{percent !== null && }; } diff --git a/frontend/src/routes/pals/Pals.tsx b/frontend/src/routes/pals/Pals.tsx index 32acd5f..6eec514 100644 --- a/frontend/src/routes/pals/Pals.tsx +++ b/frontend/src/routes/pals/Pals.tsx @@ -60,11 +60,11 @@ export default function PalsRoute() {

Pal explorer

- Search every save-derived Pal without opening players one at a time + every Pal on the server, from parsed saves
- +
diff --git a/frontend/src/routes/pals/palExplorer.ts b/frontend/src/routes/pals/palExplorer.ts index 0fc0735..e3c7a88 100644 --- a/frontend/src/routes/pals/palExplorer.ts +++ b/frontend/src/routes/pals/palExplorer.ts @@ -97,9 +97,9 @@ export function palOwnerSummary(pal: Pick

Players

- {playersQuery.data ? `${online} online · ${players.length} known from save data` : "loading…"} + {playersQuery.data ? `${online} online · ${players.length} known` : "loading…"}
@@ -375,7 +375,7 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k

Pals

- {partyPals.length > 0 ? `party of ${partyPals.length} · ${pals.length} owned` : `${pals.length} owned`} · from save data + {partyPals.length > 0 ? `party of ${partyPals.length} · ${pals.length} owned` : `${pals.length} owned`}
@@ -459,7 +459,7 @@ function PlayerActivitySummary({ activity }: { activity: PlayerActivity }) { panel tracking only
{activity.trackingSince === null ? ( -
No join or leave session has been observed by this panel yet.
+
No sessions observed yet.
) : ( <>
@@ -472,8 +472,8 @@ function PlayerActivitySummary({ activity }: { activity: PlayerActivity }) {

- Observed since {new Date(activity.trackingSince).toLocaleString()}. - {activity.recentSessionsTruncated ? " Recent-session detail is capped at 20 rows." : " This is not lifetime game history."} + Tracked since {new Date(activity.trackingSince).toLocaleString()}. + {activity.recentSessionsTruncated ? " Showing the 20 most recent sessions." : ""}

)} @@ -698,7 +698,7 @@ function WhitelistTab() { return ( - + {whitelistQuery.isError ? ( Couldn't load player notes. diff --git a/frontend/src/styles/ui.css b/frontend/src/styles/ui.css index a1bcc63..963f7b7 100644 --- a/frontend/src/styles/ui.css +++ b/frontend/src/styles/ui.css @@ -185,7 +185,7 @@ dialog.dialog { .instrument .label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: var(--track-caps); - color: var(--band-ink-2); display: block; + color: var(--band-ink-2); display: block; white-space: nowrap; } .instrument .value { font-family: var(--font-mono); font-size: var(--text-md); font-weight: 600; @@ -194,6 +194,11 @@ dialog.dialog { .instrument .value small { color: var(--band-ink-2); font-weight: 400; } .helmstrip .grow { flex: 1; } .helmstrip .actions { display: flex; align-items: center; gap: var(--space-2); } +/* mid-width: tighten instrument spacing so the strip's labels never wrap or clip */ +@media (max-width: 1150px) { + .helmstrip { gap: var(--space-3); padding: 0 var(--space-3); } + .instrument + .instrument { padding-left: var(--space-3); } +} /* state pill — a rubber stamp: rounded rect, inked border, mono caps */ .pill { diff --git a/frontend/tests/activity-route.test.mjs b/frontend/tests/activity-route.test.mjs index 3788257..5b520bd 100644 --- a/frontend/tests/activity-route.test.mjs +++ b/frontend/tests/activity-route.test.mjs @@ -14,9 +14,9 @@ test("activity route is authenticated, lazy, navigable, and uses bounded server assert.match(app, /path="activity"/); assert.match(shell, /to: "\/activity"/); assert.match(shell, /queryKey: \["activity"\]/); - assert.match(route, /panel-observed sessions/); - assert.match(route, /current membership attribution/); - assert.match(route, /not lifetime game history/); + assert.match(route, /observed sessions/); + assert.match(route, /credited to current membership/); + assert.match(route, /Only sessions observed by this panel are counted/); assert.match(client, /\/activity\?window=/); }); @@ -32,6 +32,6 @@ test("peak hours sort by average concurrency without mutating the API order", () test("coverage language exposes partial windows and defensive truncation", () => { assert.match(activityCoverageNote({ trackingSince: "2026-07-15T12:00:00Z", since: "2026-07-15T00:00:00Z", analysisTruncated: false }), /partway through/); - assert.match(activityCoverageNote({ trackingSince: "2026-07-01T00:00:00Z", since: "2026-07-15T00:00:00Z", analysisTruncated: false }), /full selected window/); - assert.match(activityCoverageNote({ trackingSince: null, since: "2026-07-15T00:00:00Z", analysisTruncated: true }), /defensive interval cap/); + assert.match(activityCoverageNote({ trackingSince: "2026-07-01T00:00:00Z", since: "2026-07-15T00:00:00Z", analysisTruncated: false }), /full window is covered/); + assert.match(activityCoverageNote({ trackingSince: null, since: "2026-07-15T00:00:00Z", analysisTruncated: true }), /hit its cap/); }); diff --git a/frontend/tests/events-route.test.mjs b/frontend/tests/events-route.test.mjs index bc77a69..8fcf978 100644 --- a/frontend/tests/events-route.test.mjs +++ b/frontend/tests/events-route.test.mjs @@ -18,8 +18,8 @@ test("events route replaces the dead hash link and is part of primary navigation assert.match(dashboard, /to="\/events"/); assert.match(events, /Events & audit/); assert.match(events, /PAGE_SIZE = 25/); - assert.match(events, /Lane counts cover the newest/); - assert.match(events, /Filter exact event kind/); + assert.match(events, /Counts cover the newest/); + assert.match(events, /Filter event kind/); assert.match(events, /api\.events\.list\(FETCH_LIMIT\)/); }); diff --git a/frontend/tests/guild-paldeck-routes.test.mjs b/frontend/tests/guild-paldeck-routes.test.mjs index 9696ce4..3e6642a 100644 --- a/frontend/tests/guild-paldeck-routes.test.mjs +++ b/frontend/tests/guild-paldeck-routes.test.mjs @@ -25,8 +25,8 @@ test("guild detail links members, bases, Pals, activity, and progression", async test("Paldeck screen distinguishes partial save observations from pinned progression", async () => { const source = await readFile(new URL("../src/routes/paldeck/Paldeck.tsx", import.meta.url), "utf8"); assert.match(source, /playersWithCaptureCounts === data\.coverage\.playersTotal/); - assert.match(source, /Pinned species captured/); - assert.match(source, /Save unique counter/); - assert.match(source, /Missing observations are never counted as zero/); - assert.match(source, /Unseen \(complete coverage only\)/); + assert.match(source, /Species captured/); + assert.match(source, /Unique species counter/); + assert.match(source, /Missing data is never counted as zero/); + assert.match(source, /Unseen \(needs full data\)/); }); diff --git a/frontend/tests/pal-explorer.test.mjs b/frontend/tests/pal-explorer.test.mjs index 485a8d2..881829e 100644 --- a/frontend/tests/pal-explorer.test.mjs +++ b/frontend/tests/pal-explorer.test.mjs @@ -22,8 +22,8 @@ test("Pal explorer narrows form strings to bounded API parameters", () => { }); test("owner evidence is explicit and unresolved owners are never guessed", () => { - assert.equal(palOwnerSummary({ ownerName: "Kestrel", ownerResolved: true, ownerSource: "personal_container" }), "Kestrel · current personal container"); - assert.equal(palOwnerSummary({ ownerName: "Kestrel", ownerResolved: true, ownerSource: "last_observed" }), "Kestrel · last observed owner"); + assert.equal(palOwnerSummary({ ownerName: "Kestrel", ownerResolved: true, ownerSource: "personal_container" }), "Kestrel"); + assert.equal(palOwnerSummary({ ownerName: "Kestrel", ownerResolved: true, ownerSource: "last_observed" }), "Kestrel · last known owner"); assert.equal(palOwnerSummary({ ownerName: "", ownerResolved: false, ownerSource: "unresolved" }), "Owner unavailable"); }); diff --git a/frontend/tests/player-activity.test.mjs b/frontend/tests/player-activity.test.mjs index d0d6226..f5b1fcc 100644 --- a/frontend/tests/player-activity.test.mjs +++ b/frontend/tests/player-activity.test.mjs @@ -10,7 +10,7 @@ test("player detail presents bounded observed activity separately from total tra ]); assert.match(route, /Observed activity/); assert.match(route, /panel tracking only/); - assert.match(route, /This is not lifetime game history/); + assert.match(route, /Tracked since/); assert.match(route, /last24Hours/); assert.match(route, /last7Days/); assert.match(route, /last30Days/); From 1450422a9a9edf1dc59f867b8ead762e9045921f Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:40:37 -0500 Subject: [PATCH 03/14] polish: extend plain-English and layout pass app-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining pages audited (Overview, Backups, Config, Console, Diagnostics, Settings, Login, Pal dialogs) — most were already clean; fixes for what wasn't: - Overview: FPS dip annotation no longer asserts 'world save' as the cause — it labels the dip without claiming a reason - Settings: 'Exact worker links' → 'Linked workers'; link-failure banner reworded in plain language - Diagnostics: shorter subtitle, technical rows left intact (operator page, contract-gap copy is test-pinned and deliberate) - Players/Pal box dialog: 'No pals in the latest save parse.' → 'No Pals in the latest save.' - Helm strip: in-game day and uptime instruments hide below 1000px so the strip no longer crowds and clips at mid widths Suite 76/76, lint and build clean; verified at 900px with Playwright. Co-Authored-By: Claude Fable 5 --- frontend/src/components/PalBoxDialog.tsx | 2 +- frontend/src/components/Shell.tsx | 4 ++-- frontend/src/routes/dashboard/Dashboard.tsx | 2 +- frontend/src/routes/diagnostics/Diagnostics.tsx | 2 +- frontend/src/routes/players/Players.tsx | 2 +- frontend/src/routes/settings/Settings.tsx | 4 ++-- frontend/src/styles/ui.css | 7 ++++++- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/PalBoxDialog.tsx b/frontend/src/components/PalBoxDialog.tsx index 849f179..ed6c6de 100644 --- a/frontend/src/components/PalBoxDialog.tsx +++ b/frontend/src/components/PalBoxDialog.tsx @@ -94,7 +94,7 @@ export function PalBoxDialog({ return ( {current === null ? ( -
No pals in the latest save parse.
+
No Pals in the latest save.
) : (
diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 731f2c5..30e8232 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -207,14 +207,14 @@ function HelmStrip() {
-
+
In-game Day {metrics ? metrics.day : "—"}
-
+
Uptime {metrics ? formatDuration(metrics.uptimeSec) : "—"} diff --git a/frontend/src/routes/dashboard/Dashboard.tsx b/frontend/src/routes/dashboard/Dashboard.tsx index b22b6f9..7474cbb 100644 --- a/frontend/src/routes/dashboard/Dashboard.tsx +++ b/frontend/src/routes/dashboard/Dashboard.tsx @@ -78,7 +78,7 @@ export default function Dashboard() { const sorted = [...fps].sort((a, b) => a - b); const median = sorted[Math.floor(sorted.length / 2)] ?? 0; if (minVal >= median * 0.9) return undefined; - return { index: minIdx, text: `${Math.round(minVal)} fps · world save` }; + return { index: minIdx, text: `${Math.round(minVal)} fps dip` }; }, [perfHistoryQuery.data]); const seenLast24h = useMemo(() => { diff --git a/frontend/src/routes/diagnostics/Diagnostics.tsx b/frontend/src/routes/diagnostics/Diagnostics.tsx index eb3f587..e68bc52 100644 --- a/frontend/src/routes/diagnostics/Diagnostics.tsx +++ b/frontend/src/routes/diagnostics/Diagnostics.tsx @@ -61,7 +61,7 @@ export default function DiagnosticsRoute() {

Diagnostics

- read-only operator evidence · cached pollers · no raw actor data + read-only health evidence · cached pollers
- {diagnostics?.linkLookupFailed && The snapshot loaded, but save-derived worker identity linkage failed.} + {diagnostics?.linkLookupFailed && The snapshot loaded, but workers couldn't be matched to their save identities.} {activity && (

workers · {activity.working} working · {activity.transporting} transporting · {activity.eating} eating · {activity.sleeping} sleeping · {activity.idle} idle · {activity.incapacitated} incapacitated · {activity.unknown} unknown diff --git a/frontend/src/styles/ui.css b/frontend/src/styles/ui.css index 963f7b7..79f081e 100644 --- a/frontend/src/styles/ui.css +++ b/frontend/src/styles/ui.css @@ -194,11 +194,16 @@ dialog.dialog { .instrument .value small { color: var(--band-ink-2); font-weight: 400; } .helmstrip .grow { flex: 1; } .helmstrip .actions { display: flex; align-items: center; gap: var(--space-2); } -/* mid-width: tighten instrument spacing so the strip's labels never wrap or clip */ +/* mid-width: tighten instrument spacing so the strip's labels never wrap or clip, + and drop the secondary instruments (in-game day, uptime) before they crowd out + the status pill and admin actions */ @media (max-width: 1150px) { .helmstrip { gap: var(--space-3); padding: 0 var(--space-3); } .instrument + .instrument { padding-left: var(--space-3); } } +@media (max-width: 1000px) { + .instrument-secondary { display: none; } +} /* state pill — a rubber stamp: rounded rect, inked border, mono caps */ .pill { From 54d7c4280d6f181466f4a32c59a61db0371b7467 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:56:45 -0500 Subject: [PATCH 04/14] fix(guilds): list only real player guilds with a base and confirmed member Palworld's save writes a group record into GroupSaveDataMap for things that are not player guilds (a solo player's auto-created organization and other non-guild group types). Those decode into guild rows with no base placed and no member whose save identity resolves to a known player, and the guild list had no filter, so they surfaced as empty placeholder guilds across the panel. Filter them out at the backend guild-list path (GuildJSON) so every consumer of GET /api/v1/guilds agrees: the guilds page, the players "Guilds" tab, the dashboard count, and the map's bases. A guild now lists only when it has at least one placed base AND one member matched to a known player. The guild detail endpoint is intentionally left unfiltered so a player row can still open its guild without a 404. Also updates the mock to hide an explicit placeholder org from the list while still resolving it by id, adds store and server tests, and corrects the guild docs and OpenAPI list description. Co-Authored-By: Claude Fable 5 --- backend/internal/server/guild_list_test.go | 56 ++++++++++++++++ backend/internal/server/openapi.json | 2 +- backend/internal/store/guild_list_test.go | 77 ++++++++++++++++++++++ backend/internal/store/store.go | 19 +++++- docs-site/src/content/docs/panel/guilds.md | 10 ++- frontend/src/api/mock.ts | 52 ++++++++++++++- 6 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 backend/internal/server/guild_list_test.go create mode 100644 backend/internal/store/guild_list_test.go diff --git a/backend/internal/server/guild_list_test.go b/backend/internal/server/guild_list_test.go new file mode 100644 index 0000000..9e7f336 --- /dev/null +++ b/backend/internal/server/guild_list_test.go @@ -0,0 +1,56 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" +) + +// TestGuildsListHidesPlaceholderGroupsButDetailStillResolves verifies the /api/v1/guilds +// endpoint only lists genuine player guilds (a placed base and a confirmed player member) +// while the /api/v1/guilds/{id} detail endpoint still resolves a filtered-out group, so a +// player row that links to its guild never 404s. +func TestGuildsListHidesPlaceholderGroupsButDetailStillResolves(t *testing.T) { + _, h, st := newKeyManagementTestServer(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + member := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + realGuild := "11111111111111111111111111111111" + orgGuild := "22222222222222222222222222222222" + + world := &sav.World{ + Players: []sav.Player{{UID: member, Nickname: "Member", GuildID: realGuild}}, + Guilds: []sav.Guild{ + {ID: realGuild, Name: "Real Guild", AdminUID: member, Members: []sav.GuildMember{{UID: member, Name: "Member"}}}, + {ID: orgGuild, Name: "Solo Org", AdminUID: member, Members: []sav.GuildMember{{UID: member, Name: "Member"}}}, + }, + Bases: []sav.BaseCamp{{ID: "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", GuildID: realGuild, Position: &sav.Vector{X: 1, Y: 2}}}, + } + if err := st.ReplaceWorld(ctx, world, now, time.Millisecond); err != nil { + t.Fatal(err) + } + + viewer := loginAs(t, h, "viewerpass") + listRR := sessionRequest(h, http.MethodGet, "/api/v1/guilds", "", viewer) + if listRR.Code != http.StatusOK { + t.Fatalf("guild list status=%d body=%s", listRR.Code, listRR.Body.String()) + } + var list []map[string]any + if err := json.Unmarshal(listRR.Body.Bytes(), &list); err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0]["name"] != "Real Guild" { + t.Fatalf("guild list = %#v, want only the real guild", list) + } + + // The solo org is filtered from the list but must still open by id. + detailRR := sessionRequest(h, http.MethodGet, "/api/v1/guilds/"+orgGuild, "", viewer) + if detailRR.Code != http.StatusOK { + t.Fatalf("filtered guild detail status=%d body=%s", detailRR.Code, detailRR.Body.String()) + } +} diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index 688b3c8..d801510 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -25,7 +25,7 @@ "/api/v1/players/{uid}/ban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player banned"}}}}, "/api/v1/players/{uid}/unban": {"post": {"parameters": [{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}], "responses": {"200": {"description": "Player unbanned"}}}}, "/api/v1/whitelist": {"get": {"responses": {"200": {"description": "Whitelist"}}}, "put": {"responses": {"200": {"description": "Whitelist replaced"}}}}, - "/api/v1/guilds": {"get": {"responses": {"200": {"description": "Guilds"}}}}, + "/api/v1/guilds": {"get": {"responses": {"200": {"description": "Real player guilds only: those with at least one placed base and one member matched to a known player. Placeholder groups (solo auto-organizations and other non-guild group types) are excluded from the list, but remain reachable via GET /api/v1/guilds/{id}."}}}}, "/api/v1/guilds/{id}": {"get": {"parameters": [{"name":"id","in":"path","required":true,"schema":{"type":"string","pattern":"^[0-9a-fA-F-]{1,36}$"}}], "responses": {"200": {"description": "Viewer-safe current-save guild detail with members, bases, bounded associated Pals, and current-membership-attributed 30-day panel activity", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/GuildDetail"}}}}, "404": {"description": "Unknown or invalid guild id"}}}}, "/api/v1/world": {"get": {"responses": {"200": {"description": "Save parse status"}}}}, "/api/v1/world/snapshot": {"get": {"responses": {"200": {"description": "Sanitized, memory-only live actor snapshot plus bounded session-only poller diagnostics for the authenticated panel"}}}}, diff --git a/backend/internal/store/guild_list_test.go b/backend/internal/store/guild_list_test.go new file mode 100644 index 0000000..e4dadd6 --- /dev/null +++ b/backend/internal/store/guild_list_test.go @@ -0,0 +1,77 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" +) + +// TestGuildJSONListsOnlyGuildsWithBaseAndConfirmedMember proves the guild-list filter: +// Palworld records a group for things that are not player guilds (solo auto-orgs, other +// non-guild groups), which decode into rows with no base placed and/or no member whose +// save identity resolves to a known player. The list must drop those, but the detail +// endpoint must still resolve a filtered guild so a player row can link through to it. +func TestGuildJSONListsOnlyGuildsWithBaseAndConfirmedMember(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "guild-list.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + + realMember := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ghostUID := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + realGuild := "11111111111111111111111111111111" + orgGuild := "22222222222222222222222222222222" + ghostGuild := "33333333333333333333333333333333" + emptyGuild := "44444444444444444444444444444444" + + world := &sav.World{ + Players: []sav.Player{{UID: realMember, Nickname: "Member", GuildID: realGuild}}, + Guilds: []sav.Guild{ + // Real player guild: a base is placed and a confirmed player is a member. + {ID: realGuild, Name: "Real Guild", AdminUID: realMember, Members: []sav.GuildMember{{UID: realMember, Name: "Member"}}}, + // Solo auto-org: confirmed member but no base placed. + {ID: orgGuild, Name: "Solo Org", AdminUID: realMember, Members: []sav.GuildMember{{UID: realMember, Name: "Member"}}}, + // Group with a base but only an unresolved (non-player) member. + {ID: ghostGuild, Name: "Ghost Group", AdminUID: ghostUID, Members: []sav.GuildMember{{UID: ghostUID, Name: ""}}}, + // Group with a base but no members at all. + {ID: emptyGuild, Name: "Empty Group", AdminUID: ""}, + }, + Bases: []sav.BaseCamp{ + {ID: "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", GuildID: realGuild, Position: &sav.Vector{X: 1, Y: 2}}, + {ID: "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3", GuildID: ghostGuild, Position: &sav.Vector{X: 3, Y: 4}}, + {ID: "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4", GuildID: emptyGuild, Position: &sav.Vector{X: 5, Y: 6}}, + }, + } + if err = s.ReplaceWorld(ctx, world, now, 0); err != nil { + t.Fatal(err) + } + + list, err := s.GuildJSON(ctx) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("guild list = %d guilds, want 1: %#v", len(list), list) + } + if list[0]["id"] != NormalizeUID(realGuild) || list[0]["name"] != "Real Guild" { + t.Fatalf("listed guild = %#v, want the real guild", list[0]) + } + + // Every filtered-out guild must still resolve through the detail endpoint so a player + // row can link to it without a 404. + for _, id := range []string{orgGuild, ghostGuild, emptyGuild} { + detail, derr := s.GuildDetail(ctx, id, now) + if derr != nil { + t.Fatalf("GuildDetail(%s) filtered from list must still resolve: %v", id, derr) + } + if detail.ID != NormalizeUID(id) { + t.Fatalf("GuildDetail(%s) = %#v", id, detail) + } + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 46fd00e..4fbaf13 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1109,9 +1109,24 @@ func (s *Store) WorldState(ctx context.Context) (WorldState, error) { return v, err } -// GuildJSON returns API-ready guild objects including members and bases. +// guildListRealFilter restricts a guild-list query to genuine player guilds. +// Palworld's save writes a group record into GroupSaveDataMap for things that are +// not player guilds — a solo player's auto-created organization and other non-guild +// group types — and those decode into guild rows with no base placed and no member +// whose save identity resolves to a known player. Requiring at least one placed base +// AND at least one member with a confirmed player identity drops those empty records +// so every panel consumer of the list (guilds page, players "Guilds" tab, dashboard +// count, map bases) agrees on the same set. The guild detail path deliberately does +// NOT apply this, so a player row can still open its guild even when the guild is +// filtered out of the list. +const guildListRealFilter = `WHERE EXISTS (SELECT 1 FROM bases b WHERE b.guild_id=guilds.id) + AND EXISTS (SELECT 1 FROM guild_members gm JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_id=guilds.id)` + +// GuildJSON returns API-ready guild objects including members and bases. Only guilds +// with at least one placed base and one confirmed player member are listed; see +// guildListRealFilter. func (s *Store) GuildJSON(ctx context.Context) ([]map[string]any, error) { - rows, err := s.db.QueryContext(ctx, "SELECT id,name,admin_uid FROM guilds ORDER BY name") + rows, err := s.db.QueryContext(ctx, "SELECT id,name,admin_uid FROM guilds "+guildListRealFilter+" ORDER BY name") if err != nil { return nil, err } diff --git a/docs-site/src/content/docs/panel/guilds.md b/docs-site/src/content/docs/panel/guilds.md index 394bfb3..c1d0973 100644 --- a/docs-site/src/content/docs/panel/guilds.md +++ b/docs-site/src/content/docs/panel/guilds.md @@ -5,8 +5,14 @@ sidebar: order: 5 --- -The **Guilds** route lists every guild from the latest parsed save. Open one for a dedicated view of -its current member roster, bases, associated Pals, and rolling 30-day activity. +The **Guilds** route lists the real player guilds from the latest parsed save. Open one for a +dedicated view of its current member roster, bases, associated Pals, and rolling 30-day activity. + +The save records a group for more than just player guilds — a solo player's automatic organization +and other non-guild groups also appear in the raw data with no base placed and no confirmed player +member. The list leaves those out and shows only guilds that have both at least one base built and at +least one member matched to a known player. Guilds you open by link still load in full even when they +are left out of the list, so a player who belongs to one of those groups can still reach it. - Member names link to player detail and their save-observed Paldeck progression. - Base coordinates link to the exact location on the authenticated live map. diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index 92f6c3f..aafeb7d 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -190,6 +190,40 @@ const players: Player[] = [ banned: true, whitelisted: false, }, + { + uid: "7C1B8D22-1234-4B7E-9A11-000000000006", + steamId: "76561198044456789", + name: "Ferro", + accountName: "ferro", + online: false, + level: 19, + guildId: "g-cinderwake", + guildName: "Cinderwake", + ping: null, + location: null, + firstSeenAt: "2026-07-06T11:00:00Z", + lastSeenAt: "2026-07-08T20:30:00Z", + playtimeSec: 9 * 3600 + 3 * 60, + banned: false, + whitelisted: false, + }, + { + uid: "2D8F3A55-1234-4B7E-9A11-000000000007", + steamId: "76561198077765432", + name: "Wren", + accountName: "wren", + online: false, + level: 22, + guildId: "g-palisade", + guildName: "Palisade", + ping: null, + location: null, + firstSeenAt: "2026-07-05T16:40:00Z", + lastSeenAt: "2026-07-09T18:05:00Z", + playtimeSec: 11 * 3600 + 27 * 60, + banned: false, + whitelisted: false, + }, ]; const guildNames = ["Nightloom", "Driftbone", "Cinderwake", "Palisade", "Thornmere", "Greywatch", "Amberfen"]; @@ -224,6 +258,20 @@ const guilds: Guild[] = guildNames.map((name, i) => { }; }); +// Palworld's save records a group for more than just player guilds: a solo player's +// auto-created organization and other non-guild groups show up with no base placed and +// no confirmed member. The Guilds list hides these, but guildDetail still resolves them +// so a player row can link through to its guild. This fixture makes that visible in mock +// mode — it never appears in listGuilds() yet still opens by id. +const placeholderGuilds: Guild[] = [ + { id: "g-driftless-org", name: "Driftless (solo org)", adminUid: "synthetic-solo", memberCount: 0, members: [], bases: [] }, +]; +const allGuilds: Guild[] = [...guilds, ...placeholderGuilds]; + +// A guild is listed only when it has at least one placed base and one confirmed member, +// mirroring the backend guild-list filter. +const isListableGuild = (g: Guild): boolean => g.bases.length > 0 && g.members.length > 0; + let whitelist: WhitelistEntry[] = [ { steamId: "76561198012345678", name: "Kestrel" }, { steamId: "76561198087654321", name: "VossR" }, @@ -639,13 +687,13 @@ export async function putWhitelist(entries: WhitelistEntry[]): Promise { requireSession(); await latency(); - return guilds; + return allGuilds.filter(isListableGuild); } export async function guildDetail(id: string): Promise { requireSession(); await latency(); - const guild = guilds.find((item) => item.id === id); + const guild = allGuilds.find((item) => item.id === id); if (!guild) throw new ApiRequestError(404, "not_found", "Guild not found."); const memberPlayers = players.filter((player) => player.guildId === guild.id); const now = new Date(); From 003b3953f3ab3428d4e2d34050078247a53c3dfb Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:03:14 -0500 Subject: [PATCH 05/14] fix: layer bounds check compared world axes swapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit worldInBounds read dataset bounds as [[dataY, dataX], ...] but THGL publishes [[dataXmin, dataYmin], [dataXmax, dataYmax]] — each layer's bounds equal exactly the world range its own transform maps onto the native pixel canvas, which pins the ordering unambiguously. The old fixture anchors were generated under the same swapped reading (its World Tree anchors sit at native pixel ~2002 on a 512px canvas), so tests were self-consistently wrong. Confirmed against the live 1.0 server (read-only): a player on Feybreak (world X -757845) and one northeast of spawn (world Y +408511) were both filtered off the Palpagos layer even though both positions render on its tiles, and a World Tree visitor (in-game coordinates around -2127..-1382) would have been drawn at the wrong spot on Palpagos instead of the tree layer. Fixture anchors regenerated from the transforms; added surveyed Feybreak anchor and a live-survey regression test. Co-Authored-By: Claude Fable 5 --- frontend/src/app/mapTransform.ts | 8 ++++-- .../tests/fixtures/map-transform-1.0.json | 28 ++++++++++++------- frontend/tests/mapTransform.test.mjs | 22 +++++++++++++-- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/frontend/src/app/mapTransform.ts b/frontend/src/app/mapTransform.ts index fc461f8..71787c7 100644 --- a/frontend/src/app/mapTransform.ts +++ b/frontend/src/app/mapTransform.ts @@ -77,12 +77,16 @@ export function layerMapToWorld(mapX: number, mapY: number, t: LayerTransform, t return { x: (pixelY - t.d) / t.c, y: (pixelX - t.b) / t.a }; } -/** Whether a world-cm point falls within a layer's published bounds (with a small margin). */ +/** Whether a world-cm point falls within a layer's published bounds. + * Bounds arrive as [[dataXmin, dataYmin], [dataXmax, dataYmax]] — the same world data-X/data-Y + * axes as the transform. Each layer's bounds equal exactly the world range its transform maps + * onto the native pixel canvas (pixelX = a*dataY + b over 0..tileSize ⇒ the dataY range; + * pixelY = c*dataX + d ⇒ the dataX range), which is how this ordering was verified. */ export function worldInBounds(worldX: number, worldY: number, bounds: [[number, number], [number, number]]): boolean { const [[x0, y0], [x1, y1]] = bounds; const minX = Math.min(x0, x1); const maxX = Math.max(x0, x1); const minY = Math.min(y0, y1); const maxY = Math.max(y0, y1); - return worldY >= minX && worldY <= maxX && worldX >= minY && worldX <= maxY; + return worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY; } diff --git a/frontend/tests/fixtures/map-transform-1.0.json b/frontend/tests/fixtures/map-transform-1.0.json index 5fb2327..cc5699b 100644 --- a/frontend/tests/fixtures/map-transform-1.0.json +++ b/frontend/tests/fixtures/map-transform-1.0.json @@ -1,5 +1,5 @@ { - "provenance": "Checked-in THGL 1.0 metadata from scripts/fetch-map-tiles.sh; only the Palpagos starting-area anchor is a surveyed gameplay location.", + "provenance": "Checked-in THGL 1.0 metadata from scripts/fetch-map-tiles.sh. Surveyed anchors come from real player positions read off a live 1.0 server (2026-07-15); metadata anchors are derived from each layer's own transform: bounds are [[dataXmin, dataYmin], [dataXmax, dataYmax]] and equal exactly the world range the transform maps onto the native pixel canvas.", "layers": [ { "id": "default", @@ -20,11 +20,19 @@ "game": { "x": 246, "y": -500 }, "nativePixel": { "x": 351.6598895637769, "y": 248.2946769740475 } }, + { + "id": "verified-feybreak-live-position", + "kind": "surveyed", + "world": { "x": -757845, "y": -61591 }, + "game": { "x": -478, "y": -1381 }, + "nativePixel": { "x": 234.23399226946438, "y": 391.2958586416345 } + }, { "id": "dataset-bounds-center", "kind": "metadata", - "world": { "x": 0, "y": -375000 }, - "nativePixel": { "x": 123.47653230259525, "y": 123.47653230259525 } + "world": { "x": -375000, "y": 0 }, + "game": { "x": -344, "y": -547 }, + "nativePixel": { "x": 256, "y": 256 } } ] }, @@ -43,21 +51,21 @@ { "id": "dataset-bounds-min", "kind": "metadata", - "world": { "x": -818196, "y": 347352.5 }, - "nativePixel": { "x": 1745.9525507830672, "y": 2257.949554852734 } + "world": { "x": 347352.5, "y": -818196 }, + "nativePixel": { "x": 0.0014979651666635618, "y": 511.99850203483356 } }, { "id": "dataset-bounds-center", "kind": "metadata", - "world": { "x": -647298.5, "y": 518250 }, - "game": { "x": 785, "y": -1140 }, - "nativePixel": { "x": 2001.9510528179007, "y": 2001.9510528179007 } + "world": { "x": 518250, "y": -647298.5 }, + "game": { "x": -1754, "y": 1399 }, + "nativePixel": { "x": 256.0000000000001, "y": 256.0000000000001 } }, { "id": "dataset-bounds-max", "kind": "metadata", - "world": { "x": -476401, "y": 689147.5 }, - "nativePixel": { "x": 2257.9495548527343, "y": 1745.9525507830672 } + "world": { "x": 689147.5, "y": -476401 }, + "nativePixel": { "x": 511.9985020348337, "y": 0.0014979651664361882 } } ] } diff --git a/frontend/tests/mapTransform.test.mjs b/frontend/tests/mapTransform.test.mjs index b4410fc..86f9da1 100644 --- a/frontend/tests/mapTransform.test.mjs +++ b/frontend/tests/mapTransform.test.mjs @@ -60,7 +60,7 @@ test("checked-in 1.0 layer anchors lock transform axes, offsets, and inverses", } }); -test("Palpagos and World Tree bounds use data-Y/data-X order and do not bleed layers", () => { +test("Palpagos and World Tree bounds use data-X/data-Y order and do not bleed layers", () => { const [palpagos, tree] = fixtures.layers; const palpagosPoint = palpagos.anchors[0].world; const treePoint = tree.anchors.find((anchor) => anchor.id === "dataset-bounds-center").world; @@ -70,7 +70,7 @@ test("Palpagos and World Tree bounds use data-Y/data-X order and do not bleed la assert.equal(worldInBounds(treePoint.x, treePoint.y, palpagos.bounds), false); for (const layer of fixtures.layers) { - const [[minY, minX], [maxY, maxX]] = layer.bounds; + const [[minX, minY], [maxX, maxY]] = layer.bounds; const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; assert.equal(worldInBounds(minX, minY, layer.bounds), true, `${layer.id} inclusive minimum`); @@ -79,3 +79,21 @@ test("Palpagos and World Tree bounds use data-Y/data-X order and do not bleed la assert.equal(worldInBounds(centerX, minY - 1, layer.bounds), false, `${layer.id} world-Y minimum`); } }); + +test("live-server survey positions stay on the layers where the players actually stood", () => { + const [palpagos, tree] = fixtures.layers; + // Read off the live 1.0 server on 2026-07-15: a player on Feybreak (far southwest, + // world X beyond -724k) and a player northeast of the starting area (world Y beyond + // +349k). The old axis-swapped bounds check filtered both off the Palpagos layer. + const feybreak = { x: -757845, y: -61591 }; + const northeast = { x: 119362, y: 408511 }; + assert.equal(worldInBounds(feybreak.x, feybreak.y, palpagos.bounds), true, "Feybreak is on Palpagos"); + assert.equal(worldInBounds(northeast.x, northeast.y, palpagos.bounds), true, "northeast is on Palpagos"); + assert.equal(worldInBounds(feybreak.x, feybreak.y, tree.bounds), false); + assert.equal(worldInBounds(northeast.x, northeast.y, tree.bounds), false); + // A World Tree visitor reads game-x between about -2127 and -1382 on the in-game map + // and must resolve to the tree layer, not Palpagos. + const treeCenter = tree.anchors.find((anchor) => anchor.id === "dataset-bounds-center"); + assert.deepEqual(worldToGame(treeCenter.world.x, treeCenter.world.y), treeCenter.game); + assert.equal(worldInBounds(treeCenter.world.x, treeCenter.world.y, palpagos.bounds), false); +}); From 65b4afe413c168d5bb695b7c05e6b25aa32d5379 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:20:05 -0500 Subject: [PATCH 06/14] Panel polish: real map deep-links, save-freshness hint, guild name fallback - "Show on map" (row dropdown + detail panel) now deep-links to /map?x=&y= via worldToGame instead of opening an unfocused map; both stay disabled when the player has no save location. - Player detail Position field notes "last save" so its provenance is clear without duplicating the toolbar's synced-ago timestamp. - New shared guildDisplayName helper labels unnamed guilds as "'s guild" (admin/founder preferred), applied to the guild list cards, guild detail title, and the Players "Guilds" tab. Co-Authored-By: Claude Fable 5 --- frontend/src/app/guildDisplay.ts | 42 +++++++++++++++++ frontend/src/routes/guilds/Guilds.tsx | 5 +- frontend/src/routes/players/Players.css | 1 + frontend/src/routes/players/Players.tsx | 18 +++++-- frontend/tests/guild-display.test.mjs | 63 +++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/guildDisplay.ts create mode 100644 frontend/tests/guild-display.test.mjs diff --git a/frontend/src/app/guildDisplay.ts b/frontend/src/app/guildDisplay.ts new file mode 100644 index 0000000..55c958c --- /dev/null +++ b/frontend/src/app/guildDisplay.ts @@ -0,0 +1,42 @@ +// Display label for a guild. +// +// Most guilds on a live server never set a name, so the raw save value is empty +// (or literally "Unnamed Guild"). Rather than show "Unnamed guild" over and over, +// borrow a member's name when we know one: the guild's admin/founder if the save +// identifies one, otherwise the first known member — e.g. "Ada's guild". With no +// known member names we keep the honest "Unnamed guild". +// +// Pure and structural on purpose: it takes anything shaped like a guild summary +// (a name, an optional admin uid, and a members array of uid+name), so both the +// guild list summary and the fuller guild detail can pass straight through. + +export const UNNAMED_GUILD_LABEL = "Unnamed guild"; + +export interface GuildLabelMember { + uid: string; + name: string; +} + +export interface GuildLabelInput { + name?: string | null; + adminUid?: string | null; + members?: readonly GuildLabelMember[] | null; +} + +/** A save name counts as "no real name" when it's blank or the default placeholder. */ +function isUnnamed(name: string | null | undefined): boolean { + const trimmed = (name ?? "").trim(); + return trimmed === "" || trimmed.toLowerCase() === "unnamed guild"; +} + +export function guildDisplayName(guild: GuildLabelInput): string { + const name = (guild.name ?? "").trim(); + if (!isUnnamed(name)) return name; + + const named = (guild.members ?? []).filter((member) => (member.name ?? "").trim() !== ""); + if (named.length === 0) return UNNAMED_GUILD_LABEL; + + const admin = guild.adminUid ? named.find((member) => member.uid === guild.adminUid) : undefined; + const chosen = admin ?? named[0]; + return `${chosen.name.trim()}'s guild`; +} diff --git a/frontend/src/routes/guilds/Guilds.tsx b/frontend/src/routes/guilds/Guilds.tsx index 1e0802e..5a16851 100644 --- a/frontend/src/routes/guilds/Guilds.tsx +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -4,6 +4,7 @@ import { api } from "../../api/client"; import { ApiRequestError, type GuildDetail } from "../../api/types"; import { formatDuration, formatRelativeToNow } from "../../app/format"; import { worldToGame } from "../../app/mapTransform"; +import { guildDisplayName } from "../../app/guildDisplay"; import { Banner } from "../../components/Banner"; import { Card, CardBody, CardHead } from "../../components/Card"; import { EmptyState } from "../../components/EmptyState"; @@ -24,7 +25,7 @@ export default function GuildsRoute() {

-

{guildId ? detailQuery.data?.name || "Guild detail" : "Guilds"}

+

{guildId ? (detailQuery.data ? guildDisplayName(detailQuery.data) : "Guild detail") : "Guilds"}

rosters · bases · members from the latest save
{guildId && All guilds} @@ -44,7 +45,7 @@ export default function GuildsRoute() {
{(listQuery.data ?? []).map((item) => ( - {item.name || "Unnamed guild"}} hint={`${item.memberCount} members`} /> + {guildDisplayName(item)}} hint={`${item.memberCount} members`} /> {item.bases.length} {item.bases.length === 1 ? "base" : "bases"} {item.members.length > 0 ? item.members.map((member) => member.name || "Unknown player").join(", ") : "No known members"} diff --git a/frontend/src/routes/players/Players.css b/frontend/src/routes/players/Players.css index c85ddea..3ae1e89 100644 --- a/frontend/src/routes/players/Players.css +++ b/frontend/src/routes/players/Players.css @@ -22,6 +22,7 @@ color: var(--ink-3); display: block; } .kv .val { font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; } +.kv .label .label-note { text-transform: none; letter-spacing: normal; color: var(--ink-3); opacity: 0.85; } .player-activity { border-top: 1px solid var(--line); } .player-activity .card-head { border-bottom: 1px solid var(--line); } diff --git a/frontend/src/routes/players/Players.tsx b/frontend/src/routes/players/Players.tsx index 67d7e38..08c0bd4 100644 --- a/frontend/src/routes/players/Players.tsx +++ b/frontend/src/routes/players/Players.tsx @@ -7,6 +7,7 @@ import { useIsAdmin } from "../../app/AuthProvider"; import { usePaletteBridge } from "../../app/paletteBridge"; import { formatDuration, formatRelativeToNow, truncateMiddle } from "../../app/format"; import { worldToGame } from "../../app/mapTransform"; +import { guildDisplayName } from "../../app/guildDisplay"; import { Card, CardBody, CardHead } from "../../components/Card"; import { Tabs } from "../../components/Tabs"; import { Pill } from "../../components/Pill"; @@ -242,7 +243,9 @@ function PlayersTab({ disabled={!p.location} onClick={() => { onSelect(p.uid); - navigate("/map"); + if (!p.location) return; + const spot = worldToGame(p.location.x, p.location.y); + navigate(`/map?x=${spot.x}&y=${spot.y}`); }} > Show on map @@ -351,7 +354,7 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k {d.guildId && d.guildName ? {d.guildName} : "—"}
- Position + Position last save {gamePos ? `${gamePos.x}, ${gamePos.y}` : "—"}
@@ -423,7 +426,12 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k - {d.banned ? ( @@ -643,9 +651,9 @@ function GuildsTab() {
BaseLevelPalsLocation
- {initials(g.name)} + {initials(guildDisplayName(g))}
-
{g.name}
+
{guildDisplayName(g)}
{g.id}
diff --git a/frontend/tests/guild-display.test.mjs b/frontend/tests/guild-display.test.mjs new file mode 100644 index 0000000..061934d --- /dev/null +++ b/frontend/tests/guild-display.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { guildDisplayName, UNNAMED_GUILD_LABEL } from "../src/app/guildDisplay.ts"; + +test("a real save name passes straight through", () => { + assert.equal( + guildDisplayName({ name: "Sootside Collective", adminUid: "u1", members: [{ uid: "u1", name: "Ada" }] }), + "Sootside Collective", + ); +}); + +test("an unnamed guild borrows the admin member's name", () => { + const label = guildDisplayName({ + name: "", + adminUid: "u2", + members: [ + { uid: "u1", name: "Ada" }, + { uid: "u2", name: "Bex" }, + ], + }); + assert.equal(label, "Bex's guild"); +}); + +test("the literal default name is treated as unnamed and borrows a member", () => { + const label = guildDisplayName({ + name: "Unnamed Guild", + adminUid: "u2", + members: [ + { uid: "u1", name: "Ada" }, + { uid: "u2", name: "Bex" }, + ], + }); + assert.equal(label, "Bex's guild"); +}); + +test("with no admin match it falls back to the first known member", () => { + const label = guildDisplayName({ + name: null, + adminUid: "missing", + members: [ + { uid: "u1", name: "Ada" }, + { uid: "u2", name: "Bex" }, + ], + }); + assert.equal(label, "Ada's guild"); +}); + +test("blank member names are skipped when choosing a fallback", () => { + const label = guildDisplayName({ + name: "", + members: [ + { uid: "u1", name: " " }, + { uid: "u2", name: "Cyd" }, + ], + }); + assert.equal(label, "Cyd's guild"); +}); + +test("an unnamed guild with zero known members stays Unnamed guild", () => { + assert.equal(guildDisplayName({ name: "", members: [] }), UNNAMED_GUILD_LABEL); + assert.equal(guildDisplayName({ name: "Unnamed Guild", members: [{ uid: "u1", name: "" }] }), UNNAMED_GUILD_LABEL); + assert.equal(guildDisplayName({ name: null }), UNNAMED_GUILD_LABEL); +}); From ea2b7eb523e71bd764904c40f3dc4be4d631ef1f Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:23:42 -0500 Subject: [PATCH 07/14] feat: show each Pal's condenser star rating end to end Parse the per-Pal Rank IntProperty from the character save parameter map (1 = never condensed, up to 5 = four stars; displayed stars = Rank-1) and carry it, nullable, through the store and every per-Pal API surface: player detail pals, the Pal explorer roster, and the integration pal views. A pal parsed before the field existed stays null, never a misleading 0. Frontend adds a single reusable PalStars component (pure condensedStars helper + co-located CSS) used by the shared PalDetailPanel ("Condensed" fact, showing "Unavailable" when absent), the Pal explorer card meta, and the Pal box dialog cell (both only for condensed pals). Scoped a stray descendant-span rule in PalDetails.css to direct children so the nested star spans lay out horizontally. Regenerated the synthetic 1.0 save fixture and added migration 010 (nullable rank column); Go and node tests cover the rank-present and rank-absent (null, not 0) honesty behavior. Co-Authored-By: Claude Fable 5 --- backend/internal/sav/character.go | 8 ++ backend/internal/sav/fixture_1_0_test.go | 27 ++++- backend/internal/sav/testdata/World_1_0.gvas | Bin 5735 -> 5811 bytes backend/internal/sav/types.go | 22 ++-- backend/internal/server/integration.go | 6 +- .../server/integration_data_audit_test.go | 4 +- backend/internal/server/openapi.json | 2 +- backend/internal/server/pals.go | 43 ++++---- .../store/migrations/010_pal_rank.sql | 4 + backend/internal/store/store.go | 35 +++++-- .../store/store_migration_audit_test.go | 20 ++-- backend/internal/store/store_test.go | 95 ++++++++++++++++-- frontend/src/api/mock.ts | 10 +- frontend/src/api/types.ts | 6 ++ frontend/src/components/PalBoxDialog.tsx | 2 + frontend/src/components/PalDetails.css | 2 +- frontend/src/components/PalDetails.tsx | 6 +- frontend/src/components/PalStars.css | 11 ++ frontend/src/components/PalStars.tsx | 22 ++++ frontend/src/components/palStars.ts | 15 +++ frontend/src/routes/pals/Pals.tsx | 2 + frontend/tests/pal-stars.test.mjs | 43 ++++++++ 22 files changed, 311 insertions(+), 74 deletions(-) create mode 100644 backend/internal/store/migrations/010_pal_rank.sql create mode 100644 frontend/src/components/PalStars.css create mode 100644 frontend/src/components/PalStars.tsx create mode 100644 frontend/src/components/palStars.ts create mode 100644 frontend/tests/pal-stars.test.mjs diff --git a/backend/internal/sav/character.go b/backend/internal/sav/character.go index 85e9e1b..d196821 100644 --- a/backend/internal/sav/character.go +++ b/backend/internal/sav/character.go @@ -83,6 +83,14 @@ func characterFromEntry(e mapEntry, stats *ParseStats) (*Player, *Pal, error) { pal.SlotIndex = int(idx) } } + // Rank is the Pal Condenser rank (1 = never condensed, up to 5 = 4 stars). + // Absent on characters that predate the field, so keep it nil rather than 0 to + // preserve the unavailable-vs-zero distinction. Soul-enhancement Rank_HP / + // Rank_Attack / Rank_Defence are deliberately not read here. + if v, ok := propertyInt(sp, "Rank"); ok { + rank := int(v) + pal.Rank = &rank + } for _, name := range []string{"Talent_HP", "Talent_Melee", "Talent_Shot", "Talent_Defense"} { if v, ok := propertyInt(sp, name); ok { pal.Talents[name] = int(v) diff --git a/backend/internal/sav/fixture_1_0_test.go b/backend/internal/sav/fixture_1_0_test.go index 5a9bd0f..7f40011 100644 --- a/backend/internal/sav/fixture_1_0_test.go +++ b/backend/internal/sav/fixture_1_0_test.go @@ -255,8 +255,10 @@ func slotIDStruct(container [16]byte, slotIndex int32) []byte { } // palSaveParam builds a pal's SaveParameter list. When slot is non-nil it is -// appended (a SlotId struct); wild/NPC pals pass nil so no SlotId is present. -func palSaveParam(charID string, level uint8, hp int64, talentHP uint8, owner [16]byte, slot []byte) []byte { +// appended (a SlotId struct); wild/NPC pals pass nil so no SlotId is present. A +// rank of 0 omits the Rank IntProperty entirely, modeling a pal parsed before the +// field existed (the decoder must leave Rank nil, not default it to 0). +func palSaveParam(charID string, level uint8, hp int64, talentHP uint8, rank int32, owner [16]byte, slot []byte) []byte { w := &gw{} w.bytes(strProp("CharacterID", "NameProperty", charID)) w.bytes(byteProp("Level", level)) @@ -264,6 +266,9 @@ func palSaveParam(charID string, level uint8, hp int64, talentHP uint8, owner [1 w.bytes(byteProp("Talent_HP", talentHP)) w.bytes(byteProp("Talent_Shot", 70)) w.bytes(byteProp("Talent_Defense", 60)) + if rank != 0 { + w.bytes(intProp("Rank", rank)) + } w.bytes(enumProp("Gender", "EPalGenderType", "EPalGenderType::Female")) w.bytes(stringArrayProp("PassiveSkillList", "NameProperty", "CraftSpeed_up2", "PAL_ALLAttack_up1")) w.bytes(stringArrayProp("EquipWaza", "EnumProperty", "EPalWazaID::AirCanon", "EPalWazaID::StoneShotgun")) @@ -364,11 +369,11 @@ func build10World(withDrift bool) []byte { chars.bytes(characterEntry(ownerUID, playerInstance, playerSaveParam("Ada", 5, 570000, ownerUID), group)) chars.bytes(characterEntry(ownerUID, pal1Instance, - palSaveParam("Grassmon", 12, 1500, 50, ownerUID, slotIDStruct(palBoxContainer, 0)), group)) + palSaveParam("Grassmon", 12, 1500, 50, 3, ownerUID, slotIDStruct(palBoxContainer, 0)), group)) chars.bytes(characterEntry(ownerUID, pal2Instance, - palSaveParam("Rockmon", 7, 900, 30, ownerUID, slotIDStruct(palBoxContainer, 1)), group)) + palSaveParam("Rockmon", 7, 900, 30, 1, ownerUID, slotIDStruct(palBoxContainer, 1)), group)) chars.bytes(characterEntry(ownerUID, pal3Instance, - palSaveParam("Wildmon", 3, 300, 10, ownerUID, nil), group)) + palSaveParam("Wildmon", 3, 300, 10, 0, ownerUID, nil), group)) inner.bytes(mapProp("CharacterSaveParameterMap", "StructProperty", "StructProperty", 4, chars.b)) // GroupSaveDataMap: one guild in the retail 1.x layout with two members and a @@ -479,6 +484,18 @@ func TestParseSynthetic1_0Fixture(t *testing.T) { if wild.ContainerID != "" || wild.SlotIndex != -1 { t.Fatalf("wild container = %q slot %d, want empty/-1", wild.ContainerID, wild.SlotIndex) } + // Condenser rank: Grassmon carries Rank 3 (two stars), Rockmon Rank 1 (never + // condensed, zero stars) — both decoded from the IntProperty. Wildmon omits the + // property, so its Rank must stay nil (unavailable), never a defaulted 0. + if grass.Rank == nil || *grass.Rank != 3 { + t.Fatalf("grass rank = %v, want 3", grass.Rank) + } + if rock.Rank == nil || *rock.Rank != 1 { + t.Fatalf("rock rank = %v, want 1", rock.Rank) + } + if wild.Rank != nil { + t.Fatalf("wild rank = %v, want nil (absent Rank property)", *wild.Rank) + } if len(w.Guilds) != 1 { t.Fatalf("guilds=%d, want 1", len(w.Guilds)) } diff --git a/backend/internal/sav/testdata/World_1_0.gvas b/backend/internal/sav/testdata/World_1_0.gvas index 580f7ee61046957346721c9e1af4492669ce9d25..0359cc36ea3fb5785266ab05aa71818f1eb09e53 100644 GIT binary patch delta 163 zcmaE^vsrgS9pk@=_2-$~g*OKQz SUleoXU`9wXZeGp5hYbM!94{~c delta 88 zcmdn2`&?&29pk2n_2-#>3vCWyWMW}FJXx1pl4(E7WJN)BMvKV>+=-jNvqm#+W)bdX g+903PZb00000 diff --git a/backend/internal/sav/types.go b/backend/internal/sav/types.go index f34a20e..e4725cc 100644 --- a/backend/internal/sav/types.go +++ b/backend/internal/sav/types.go @@ -63,14 +63,20 @@ type Player struct { // Pal describes a non-player character from CharacterSaveParameterMap. type Pal struct { - InstanceID string `json:"instanceId"` - CharacterID string `json:"characterId,omitempty"` - Level int32 `json:"level,omitempty"` - Exp int64 `json:"exp,omitempty"` - HP float64 `json:"hp,omitempty"` - OwnerUID string `json:"ownerUid,omitempty"` - IsLucky bool `json:"isLucky,omitempty"` - IsBoss bool `json:"isBoss,omitempty"` + InstanceID string `json:"instanceId"` + CharacterID string `json:"characterId,omitempty"` + Level int32 `json:"level,omitempty"` + Exp int64 `json:"exp,omitempty"` + HP float64 `json:"hp,omitempty"` + OwnerUID string `json:"ownerUid,omitempty"` + IsLucky bool `json:"isLucky,omitempty"` + IsBoss bool `json:"isBoss,omitempty"` + // Rank is the pal's Pal Condenser rank (Rank IntProperty). A never-condensed + // pal is Rank 1; each condenser star adds 1, up to Rank 5 (4 stars). Displayed + // stars are Rank-1. A nil pointer means the save carried no Rank property (an + // older parse or a character that predates the field); the API surfaces null so + // the UI can stay honest rather than show a misleading zero stars. + Rank *int `json:"rank,omitempty"` Talents map[string]int `json:"talents,omitempty"` Gender string `json:"gender,omitempty"` PassiveSkillIDs []string `json:"passiveSkillIds,omitempty"` diff --git a/backend/internal/server/integration.go b/backend/internal/server/integration.go index 0123057..a9db499 100644 --- a/backend/internal/server/integration.go +++ b/backend/internal/server/integration.go @@ -331,6 +331,7 @@ type integrationPalView struct { BaseID *string `json:"baseId"` HP *float64 `json:"hp"` Gender string `json:"gender"` + Rank *int `json:"rank"` Talents integrationPalTalents `json:"talents"` PassiveSkillIDs []string `json:"passiveSkillIds"` EquippedSkillIDs []string `json:"equippedSkillIds"` @@ -349,7 +350,7 @@ func newIntegrationPalView(p store.Pal) integrationPalView { Level: p.Level, IsAlpha: p.IsAlpha, IsLucky: p.IsLucky, InParty: p.InParty, PartySlot: p.PartySlot, BoxPage: p.BoxPage, BoxSlot: p.BoxSlot, Placement: integrationPalPlacement(p), BaseID: integrationString(p.BaseID), - HP: p.HP, Gender: p.Gender, + HP: p.HP, Gender: p.Gender, Rank: p.Rank, Talents: integrationPalTalents{HP: p.TalentHP, Melee: p.TalentMelee, Shot: p.TalentShot, Defense: p.TalentDefense}, PassiveSkillIDs: nonnilStrings(p.PassiveSkillIDs), EquippedSkillIDs: nonnilStrings(p.EquippedSkillIDs), } @@ -382,6 +383,7 @@ type integrationPalListView struct { OwnerResolved bool `json:"ownerResolved"` HP *float64 `json:"hp"` Gender string `json:"gender"` + Rank *int `json:"rank"` Talents integrationPalTalents `json:"talents"` PassiveSkillIDs []string `json:"passiveSkillIds"` EquippedSkillIDs []string `json:"equippedSkillIds"` @@ -614,7 +616,7 @@ func (s *Server) integrationPals(w http.ResponseWriter, r *http.Request) { Placement: integrationPalPlacement(row.Pal), BaseID: integrationString(row.BaseID), OwnerUID: row.OwnerUID, OwnerName: row.OwnerName, OwnerSource: row.OwnerSource, OwnerResolved: row.OwnerResolved, - HP: row.HP, Gender: row.Gender, + HP: row.HP, Gender: row.Gender, Rank: row.Rank, Talents: integrationPalTalents{HP: row.TalentHP, Melee: row.TalentMelee, Shot: row.TalentShot, Defense: row.TalentDefense}, PassiveSkillIDs: nonnilStrings(row.PassiveSkillIDs), EquippedSkillIDs: nonnilStrings(row.EquippedSkillIDs), }) diff --git a/backend/internal/server/integration_data_audit_test.go b/backend/internal/server/integration_data_audit_test.go index a59a69a..2d671c0 100644 --- a/backend/internal/server/integration_data_audit_test.go +++ b/backend/internal/server/integration_data_audit_test.go @@ -360,14 +360,14 @@ func TestAuditRedactionKeySetsExact(t *testing.T) { auditAssertKeys(t, "player detail", detail, detailKeys...) for _, p := range detail["pals"].([]any) { pal := p.(map[string]any) - auditAssertKeys(t, "detail pal", pal, "instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "hp", "gender", "talents", "passiveSkillIds", "equippedSkillIds") + auditAssertKeys(t, "detail pal", pal, "instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "hp", "gender", "rank", "talents", "passiveSkillIds", "equippedSkillIds") if pal["inParty"] != true || pal["partySlot"] != float64(2) || pal["boxPage"] != nil || pal["boxSlot"] != nil { t.Errorf("detail pal placement = %#v", pal) } } for _, p := range palsEnv["data"].([]any) { pal := p.(map[string]any) - auditAssertKeys(t, "pals row", pal, "instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "ownerUid", "ownerName", "ownerSource", "ownerResolved", "hp", "gender", "talents", "passiveSkillIds", "equippedSkillIds") + auditAssertKeys(t, "pals row", pal, "instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "ownerUid", "ownerName", "ownerSource", "ownerResolved", "hp", "gender", "rank", "talents", "passiveSkillIds", "equippedSkillIds") if pal["inParty"] != true || pal["partySlot"] != float64(2) || pal["boxPage"] != nil || pal["boxSlot"] != nil { t.Errorf("bulk pal placement = %#v", pal) } diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index b14886c..0c8aa42 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -221,7 +221,7 @@ "IntegrationKeyCreated": {"allOf": [{"$ref": "#/components/schemas/IntegrationKey"}, {"type": "object", "required": ["key"], "properties": {"key": {"type": "string", "description": "The plaintext bearer key. Present only in this one response, never again - store it now. This example is deliberately fake and does not match the real phk_ token grammar (wrong charset/length on purpose), so it can never validate and never trains a scanner to ignore the prefix.", "example": "phk_00000000_EXAMPLE-NOT-A-REAL-KEY"}}}]}, "IntegrationPlayer": {"type": "object", "required": ["uid", "name", "online", "level", "guildId", "guildName", "firstSeenAt", "lastSeenAt", "playtimeSec"], "properties": {"uid": {"type": "string", "description": "Save-derived GUID; the join key for /players/{uid}, pals[].ownerUid, and guild members."}, "name": {"type": "string"}, "online": {"type": "boolean"}, "level": {"type": "integer"}, "guildId": {"type": "string"}, "guildName": {"type": "string"}, "firstSeenAt": {"type": ["string", "null"], "format": "date-time"}, "lastSeenAt": {"type": ["string", "null"], "format": "date-time"}, "playtimeSec": {"type": "integer", "format": "int64"}, "captureTotal": {"type": "integer", "format": "int64", "minimum": 0, "description": "Lifetime RecordData.TribeCaptureCount. Omitted when the player save does not provide it."}, "uniquePalsCaptured": {"type": "integer", "minimum": 0, "description": "Count of positive species entries in RecordData.PalCaptureCount. Omitted when unavailable."}, "paldeckUnlocked": {"type": "integer", "minimum": 0, "description": "Count of true RecordData.PaldeckUnlockFlag entries (seen/unlocked, not necessarily caught). Omitted when unavailable."}}}, "IntegrationPalTalents": {"type": "object", "required": ["hp", "melee", "shot", "defense"], "properties": {"hp": {"type": ["integer", "null"], "minimum": 0}, "melee": {"type": ["integer", "null"], "minimum": 0}, "shot": {"type": ["integer", "null"], "minimum": 0}, "defense": {"type": ["integer", "null"], "minimum": 0}}}, - "IntegrationPal": {"type": "object", "required": ["instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "hp", "gender", "talents", "passiveSkillIds", "equippedSkillIds"], "properties": {"instanceId": {"type": "string"}, "characterId": {"type": "string"}, "displayName": {"type": "string"}, "level": {"type": "integer"}, "isAlpha": {"type": "boolean"}, "isLucky": {"type": "boolean"}, "inParty": {"type": "boolean"}, "partySlot": {"type": ["integer", "null"], "minimum": 0}, "boxPage": {"type": ["integer", "null"], "minimum": 0}, "boxSlot": {"type": ["integer", "null"], "minimum": 0, "maximum": 29}, "placement": {"type": "string", "enum": ["party", "box", "base", "unknown"], "description": "Safe derived placement. base means baseId joined the Pal's worker container; unknown is never inferred as base."}, "baseId": {"type": ["string", "null"], "description": "Derived base join key matching guilds[].bases[].id; null unless placement is base. Raw container GUIDs are never exposed."}, "hp": {"type": ["number", "null"]}, "gender": {"type": "string", "enum": ["", "male", "female", "unknown"]}, "talents": {"$ref": "#/components/schemas/IntegrationPalTalents"}, "passiveSkillIds": {"type": "array", "items": {"type": "string"}}, "equippedSkillIds": {"type": "array", "items": {"type": "string"}}}}, + "IntegrationPal": {"type": "object", "required": ["instanceId", "characterId", "displayName", "level", "isAlpha", "isLucky", "inParty", "partySlot", "boxPage", "boxSlot", "placement", "baseId", "hp", "gender", "rank", "talents", "passiveSkillIds", "equippedSkillIds"], "properties": {"instanceId": {"type": "string"}, "characterId": {"type": "string"}, "displayName": {"type": "string"}, "level": {"type": "integer"}, "isAlpha": {"type": "boolean"}, "isLucky": {"type": "boolean"}, "inParty": {"type": "boolean"}, "partySlot": {"type": ["integer", "null"], "minimum": 0}, "boxPage": {"type": ["integer", "null"], "minimum": 0}, "boxSlot": {"type": ["integer", "null"], "minimum": 0, "maximum": 29}, "placement": {"type": "string", "enum": ["party", "box", "base", "unknown"], "description": "Safe derived placement. base means baseId joined the Pal's worker container; unknown is never inferred as base."}, "baseId": {"type": ["string", "null"], "description": "Derived base join key matching guilds[].bases[].id; null unless placement is base. Raw container GUIDs are never exposed."}, "hp": {"type": ["number", "null"]}, "gender": {"type": "string", "enum": ["", "male", "female", "unknown"]}, "rank": {"type": ["integer", "null"], "minimum": 1, "maximum": 5, "description": "Pal Condenser rank: 1 (never condensed) through 5 (four stars). Displayed stars are rank-1. null when the save carried no Rank property; never inferred as 0."}, "talents": {"$ref": "#/components/schemas/IntegrationPalTalents"}, "passiveSkillIds": {"type": "array", "items": {"type": "string"}}, "equippedSkillIds": {"type": "array", "items": {"type": "string"}}}}, "IntegrationPlayerDetail": {"allOf": [{"$ref": "#/components/schemas/IntegrationPlayer"}, {"type": "object", "required": ["pals"], "properties": {"pals": {"type": "array", "items": {"$ref": "#/components/schemas/IntegrationPal"}}}}]}, "IntegrationPalListItem": {"allOf": [{"$ref": "#/components/schemas/IntegrationPal"}, {"type": "object", "required": ["ownerUid", "ownerName", "ownerSource", "ownerResolved"], "properties": {"ownerUid": {"type": "string"}, "ownerName": {"type": "string", "description": "Empty string when ownership is unresolved or the joined player has no name."}, "ownerSource": {"type": "string", "enum": ["save", "personal_container", "last_observed", "unresolved"], "description": "How player attribution was established. last_observed is historical attribution while a Pal is outside a personal container; it is not proof of current possession."}, "ownerResolved": {"type": "boolean", "description": "True when ownerUid joins a current public player row. Consult ownerSource for attribution certainty."}}}]}, "IntegrationGuildMember": {"type": "object", "required": ["uid", "name"], "properties": {"uid": {"type": "string"}, "name": {"type": "string"}}}, diff --git a/backend/internal/server/pals.go b/backend/internal/server/pals.go index fbe7b7c..c7ac627 100644 --- a/backend/internal/server/pals.go +++ b/backend/internal/server/pals.go @@ -25,25 +25,28 @@ type sessionPalTalents struct { // sessionPalExplorerView is an explicit viewer-safe allowlist. In particular, it never exposes // pals.raw_json or any player Steam/account fields through the server-wide roster. type sessionPalExplorerView struct { - InstanceID string `json:"instanceId"` - CharacterID string `json:"characterId"` - DisplayName string `json:"displayName"` - Level int `json:"level"` - IsAlpha bool `json:"isAlpha"` - IsLucky bool `json:"isLucky"` - IsBoss bool `json:"isBoss"` - InParty bool `json:"inParty"` - PartySlot *int `json:"partySlot"` - BoxPage *int `json:"boxPage"` - BoxSlot *int `json:"boxSlot"` - Placement string `json:"placement"` - BaseID *string `json:"baseId"` - OwnerUID string `json:"ownerUid"` - OwnerName string `json:"ownerName"` - OwnerSource string `json:"ownerSource"` - OwnerResolved bool `json:"ownerResolved"` - HP *float64 `json:"hp"` - Gender string `json:"gender"` + InstanceID string `json:"instanceId"` + CharacterID string `json:"characterId"` + DisplayName string `json:"displayName"` + Level int `json:"level"` + IsAlpha bool `json:"isAlpha"` + IsLucky bool `json:"isLucky"` + IsBoss bool `json:"isBoss"` + InParty bool `json:"inParty"` + PartySlot *int `json:"partySlot"` + BoxPage *int `json:"boxPage"` + BoxSlot *int `json:"boxSlot"` + Placement string `json:"placement"` + BaseID *string `json:"baseId"` + OwnerUID string `json:"ownerUid"` + OwnerName string `json:"ownerName"` + OwnerSource string `json:"ownerSource"` + OwnerResolved bool `json:"ownerResolved"` + HP *float64 `json:"hp"` + Gender string `json:"gender"` + // Rank is the Pal Condenser rank (1..5) or null when the save carried no Rank + // property. Displayed stars are rank-1; null stays "unavailable", never 0. + Rank *int `json:"rank"` Talents sessionPalTalents `json:"talents"` PassiveSkillIDs []string `json:"passiveSkillIds"` EquippedSkillIDs []string `json:"equippedSkillIds"` @@ -176,7 +179,7 @@ func newSessionPalExplorerView(p store.PalWithOwner) sessionPalExplorerView { InParty: p.InParty, PartySlot: p.PartySlot, BoxPage: p.BoxPage, BoxSlot: p.BoxSlot, Placement: integrationPalPlacement(p.Pal), BaseID: baseID, OwnerUID: p.OwnerUID, OwnerName: p.OwnerName, OwnerSource: p.OwnerSource, OwnerResolved: p.OwnerResolved, - HP: p.HP, Gender: p.Gender, + HP: p.HP, Gender: p.Gender, Rank: p.Rank, Talents: sessionPalTalents{HP: p.TalentHP, Melee: p.TalentMelee, Shot: p.TalentShot, Defense: p.TalentDefense}, PassiveSkillIDs: passives, EquippedSkillIDs: equipped, } diff --git a/backend/internal/store/migrations/010_pal_rank.sql b/backend/internal/store/migrations/010_pal_rank.sql new file mode 100644 index 0000000..1b5d4d3 --- /dev/null +++ b/backend/internal/store/migrations/010_pal_rank.sql @@ -0,0 +1,4 @@ +-- Pal Condenser rank (1 = never condensed, up to 5 = 4 stars). Nullable so a pal +-- parsed before this column existed reads back as NULL (unavailable), never a +-- misleading 0. Soul-enhancement Rank_HP/Rank_Attack/Rank_Defence are out of scope. +ALTER TABLE pals ADD COLUMN rank INTEGER; diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 6132989..889b561 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -973,7 +973,7 @@ func (s *Store) ReplaceWorld(ctx context.Context, w *sav.World, at time.Time, d } passiveSkillIDs, _ := json.Marshal(p.PassiveSkillIDs) equippedSkillIDs, _ := json.Marshal(p.EquippedSkillIDs) - _, err = tx.ExecContext(ctx, "INSERT INTO pals(instance_id,owner_uid,owner_source,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids,base_id,raw_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", instanceID, ownerUID, ownerSource, p.CharacterID, paldeck.Name(p.CharacterID), p.Level, p.IsBoss || paldeck.IsBossID(p.CharacterID), p.IsLucky, inParty, partySlot, boxPage, boxSlot, p.HP, p.Gender, nullableTalent(p.Talents, "Talent_HP"), nullableTalent(p.Talents, "Talent_Melee"), nullableTalent(p.Talents, "Talent_Shot"), nullableTalent(p.Talents, "Talent_Defense"), string(passiveSkillIDs), string(equippedSkillIDs), NormalizeUID(p.BaseID), string(b)) + _, err = tx.ExecContext(ctx, "INSERT INTO pals(instance_id,owner_uid,owner_source,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids,base_id,rank,raw_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", instanceID, ownerUID, ownerSource, p.CharacterID, paldeck.Name(p.CharacterID), p.Level, p.IsBoss || paldeck.IsBossID(p.CharacterID), p.IsLucky, inParty, partySlot, boxPage, boxSlot, p.HP, p.Gender, nullableTalent(p.Talents, "Talent_HP"), nullableTalent(p.Talents, "Talent_Melee"), nullableTalent(p.Talents, "Talent_Shot"), nullableTalent(p.Talents, "Talent_Defense"), string(passiveSkillIDs), string(equippedSkillIDs), NormalizeUID(p.BaseID), nullableRank(p.Rank), string(b)) if err != nil { return err } @@ -1084,6 +1084,16 @@ func nullableTalent(talents map[string]int, name string) any { return value } +// nullableRank stores the pal's Condenser rank as a nullable column: a nil pointer +// (the save carried no Rank property) persists as SQL NULL so the read path can keep +// the unavailable-vs-zero distinction rather than defaulting to a misleading 0. +func nullableRank(rank *int) any { + if rank == nil { + return nil + } + return *rank +} + // WorldState returns the most recent parse status. func (s *Store) WorldState(ctx context.Context) (WorldState, error) { var v WorldState @@ -1245,6 +1255,9 @@ type Pal struct { TalentShot, TalentDefense *int PassiveSkillIDs, EquippedSkillIDs []string BaseID string + // Rank is the Pal Condenser rank (1..5) or nil when the save carried no Rank + // property. Displayed stars are Rank-1; nil stays unavailable, never 0. + Rank *int } // PalWithOwner is one bulk-paginated pal row with its owner uid/name joined in, so the @@ -1374,7 +1387,7 @@ ORDER BY a.at`, since.Unix(), bucketSeconds) // PalsTyped returns one player's save-derived pals, typed (the integration-surface // counterpart to Pals, which returns map[string]any for the session UI). func (s *Store) PalsTyped(ctx context.Context, uid string) ([]Pal, error) { - rows, err := s.db.QueryContext(ctx, "SELECT instance_id,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids,base_id FROM pals WHERE owner_uid=?", NormalizeUID(uid)) + rows, err := s.db.QueryContext(ctx, "SELECT instance_id,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids,base_id,rank FROM pals WHERE owner_uid=?", NormalizeUID(uid)) if err != nil { return nil, err } @@ -1383,7 +1396,7 @@ func (s *Store) PalsTyped(ctx context.Context, uid string) ([]Pal, error) { for rows.Next() { var p Pal var passiveJSON, equippedJSON string - if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID); err != nil { + if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID, &p.Rank); err != nil { return nil, err } _ = json.Unmarshal([]byte(passiveJSON), &p.PassiveSkillIDs) @@ -1397,7 +1410,7 @@ func (s *Store) PalsTyped(ctx context.Context, uid string) ([]Pal, error) { // with owner uid/name left-joined from players (owner name is empty when the owner row has // no name, which the LEFT JOIN's COALESCE also covers if the owner row is somehow absent). func (s *Store) PalsPage(ctx context.Context, after string, limit int) ([]PalWithOwner, error) { - rows, err := s.db.QueryContext(ctx, `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.party_slot,p.box_page,p.box_slot,p.hp,p.gender,p.talent_hp,p.talent_melee,p.talent_shot,p.talent_defense,p.passive_skill_ids,p.equipped_skill_ids,p.base_id,p.owner_uid,COALESCE(pl.name,''),p.owner_source,pl.uid IS NOT NULL + rows, err := s.db.QueryContext(ctx, `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.party_slot,p.box_page,p.box_slot,p.hp,p.gender,p.talent_hp,p.talent_melee,p.talent_shot,p.talent_defense,p.passive_skill_ids,p.equipped_skill_ids,p.base_id,p.rank,p.owner_uid,COALESCE(pl.name,''),p.owner_source,pl.uid IS NOT NULL FROM pals p LEFT JOIN players pl ON pl.uid=p.owner_uid WHERE p.instance_id > ? ORDER BY p.instance_id ASC LIMIT ?`, after, limit) if err != nil { @@ -1408,7 +1421,7 @@ WHERE p.instance_id > ? ORDER BY p.instance_id ASC LIMIT ?`, after, limit) for rows.Next() { var p PalWithOwner var passiveJSON, equippedJSON string - if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID, &p.OwnerUID, &p.OwnerName, &p.OwnerSource, &p.OwnerResolved); err != nil { + if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID, &p.Rank, &p.OwnerUID, &p.OwnerName, &p.OwnerSource, &p.OwnerResolved); err != nil { return nil, err } _ = json.Unmarshal([]byte(passiveJSON), &p.PassiveSkillIDs) @@ -1422,7 +1435,7 @@ WHERE p.instance_id > ? ORDER BY p.instance_id ASC LIMIT ?`, after, limit) // filters in SQLite. Filtering before pagination is important: a client-side filter over one page // would silently omit matching Pals later in the roster. func (s *Store) PalsExplorerPage(ctx context.Context, filter PalExplorerQuery) ([]PalWithOwner, error) { - const selectPals = `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.party_slot,p.box_page,p.box_slot,p.hp,p.gender,p.talent_hp,p.talent_melee,p.talent_shot,p.talent_defense,p.passive_skill_ids,p.equipped_skill_ids,p.base_id,p.owner_uid,COALESCE(pl.name,''),p.owner_source,pl.uid IS NOT NULL + const selectPals = `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.party_slot,p.box_page,p.box_slot,p.hp,p.gender,p.talent_hp,p.talent_melee,p.talent_shot,p.talent_defense,p.passive_skill_ids,p.equipped_skill_ids,p.base_id,p.rank,p.owner_uid,COALESCE(pl.name,''),p.owner_source,pl.uid IS NOT NULL FROM pals p LEFT JOIN players pl ON pl.uid=p.owner_uid` var query strings.Builder query.WriteString(selectPals) @@ -1478,7 +1491,7 @@ FROM pals p LEFT JOIN players pl ON pl.uid=p.owner_uid` for rows.Next() { var p PalWithOwner var passiveJSON, equippedJSON string - if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID, &p.OwnerUID, &p.OwnerName, &p.OwnerSource, &p.OwnerResolved); err != nil { + if err = rows.Scan(&p.InstanceID, &p.CharacterID, &p.DisplayName, &p.Level, &p.IsAlpha, &p.IsLucky, &p.InParty, &p.PartySlot, &p.BoxPage, &p.BoxSlot, &p.HP, &p.Gender, &p.TalentHP, &p.TalentMelee, &p.TalentShot, &p.TalentDefense, &passiveJSON, &equippedJSON, &p.BaseID, &p.Rank, &p.OwnerUID, &p.OwnerName, &p.OwnerSource, &p.OwnerResolved); err != nil { return nil, err } _ = json.Unmarshal([]byte(passiveJSON), &p.PassiveSkillIDs) @@ -1495,7 +1508,7 @@ func escapeSQLLike(value string) string { // Pals returns a player's save-derived pals. func (s *Store) Pals(ctx context.Context, uid string) ([]map[string]any, error) { - rows, err := s.db.QueryContext(ctx, "SELECT instance_id,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,base_id,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids FROM pals WHERE owner_uid=?", NormalizeUID(uid)) + rows, err := s.db.QueryContext(ctx, "SELECT instance_id,character_id,display_name,level,is_alpha,is_lucky,in_party,party_slot,box_page,box_slot,base_id,hp,gender,talent_hp,talent_melee,talent_shot,talent_defense,passive_skill_ids,equipped_skill_ids,rank FROM pals WHERE owner_uid=?", NormalizeUID(uid)) if err != nil { return nil, err } @@ -1508,8 +1521,8 @@ func (s *Store) Pals(ctx context.Context, uid string) ([]map[string]any, error) var partySlot, boxPage, boxSlot *int var hp *float64 var gender, passiveJSON, equippedJSON string - var talentHP, talentMelee, talentShot, talentDefense *int - if err = rows.Scan(&i, &c, &n, &l, &a, &k, &inParty, &partySlot, &boxPage, &boxSlot, &baseID, &hp, &gender, &talentHP, &talentMelee, &talentShot, &talentDefense, &passiveJSON, &equippedJSON); err != nil { + var talentHP, talentMelee, talentShot, talentDefense, rank *int + if err = rows.Scan(&i, &c, &n, &l, &a, &k, &inParty, &partySlot, &boxPage, &boxSlot, &baseID, &hp, &gender, &talentHP, &talentMelee, &talentShot, &talentDefense, &passiveJSON, &equippedJSON, &rank); err != nil { return nil, err } passives, equipped := []string{}, []string{} @@ -1525,7 +1538,7 @@ func (s *Store) Pals(ctx context.Context, uid string) ([]map[string]any, error) "instanceId": i, "characterId": c, "displayName": n, "level": l, "isAlpha": a, "isLucky": k, "inParty": inParty, "partySlot": partySlot, "boxPage": boxPage, "boxSlot": boxSlot, "baseId": nullableString(baseID), - "placement": palPlacement(inParty, boxPage, baseID), "hp": hp, "gender": gender, + "placement": palPlacement(inParty, boxPage, baseID), "hp": hp, "gender": gender, "rank": rank, "talents": map[string]any{"hp": talentHP, "melee": talentMelee, "shot": talentShot, "defense": talentDefense}, "passiveSkillIds": passives, "equippedSkillIds": equipped, }) diff --git a/backend/internal/store/store_migration_audit_test.go b/backend/internal/store/store_migration_audit_test.go index 5b19c01..84a1ef8 100644 --- a/backend/internal/store/store_migration_audit_test.go +++ b/backend/internal/store/store_migration_audit_test.go @@ -132,8 +132,8 @@ func TestAuditUpgradeV030RealVolumeReadableThroughBothSurfaces(t *testing.T) { } defer st.Close() ctx := context.Background() - if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, err) + if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, err) } if v, err := st.GetKV(ctx, "operator-note"); err != nil || v != "preserve-me" { t.Fatalf("operator kv = %q, %v", v, err) @@ -293,8 +293,8 @@ func TestAuditInterruptedMigrationReplayPreservesData(t *testing.T) { t.Fatalf("reopen after simulated interrupted migration: %v", err) } defer reopened.Close() - if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version after replay = %q, %v; want repaired to 9", v, err) + if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 10", v, err) } keys, err := reopened.ListAPIKeys(ctx) if err != nil || len(keys) != 1 || keys[0].ID != "aaaa1111" || keys[0].Label != "survives-replay" { @@ -310,7 +310,7 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { t.Run("future version names both numbers", func(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") legacy := buildV030Database(t, path) - if _, err := legacy.Exec(`UPDATE kv SET value='10' WHERE key='schema_version'`); err != nil { + if _, err := legacy.Exec(`UPDATE kv SET value='11' WHERE key='schema_version'`); err != nil { t.Fatal(err) } if err := legacy.Close(); err != nil { @@ -318,9 +318,9 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { } _, err := Open(path) if err == nil { - t.Fatal("Open on schema_version 10 succeeded") + t.Fatal("Open on schema_version 11 succeeded") } - for _, needle := range []string{"10", "9", "newer than this binary supports"} { + for _, needle := range []string{"11", "10", "newer than this binary supports"} { if !strings.Contains(err.Error(), needle) { t.Errorf("fail-closed error %q does not mention %q", err, needle) } @@ -403,9 +403,9 @@ func TestAuditConcurrentOpenSameFile(t *testing.T) { if err != nil { t.Fatalf("round %d: reopen after concurrent Open: %v", round, err) } - if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "9" { + if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "10" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 9", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 10", round, v, err) } if _, err := st.ListAPIKeys(context.Background()); err != nil { st.Close() @@ -527,7 +527,7 @@ func TestAuditDowngradeV04DatabaseUnderV03OpenSemantics(t *testing.T) { t.Fatalf("v0.3 binary cannot open a v0.4 database (001 re-execution failed): %v", err) } var v string - if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "9" { + if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "10" { t.Fatalf("schema_version after v0.3-style open = %q, %v; INSERT OR IGNORE must not clobber it", v, err) } var name string diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index 4996ba5..ff29963 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -342,6 +342,85 @@ func TestReplaceWorldDerivesPartyAndBoxPlacement(t *testing.T) { } } +func TestReplaceWorldCarriesPalCondenserRank(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + rank3 := 3 + rank1 := 1 + w := &sav.World{ + Players: []sav.Player{{UID: "owner", OtomoContainerID: "party"}}, + Pals: []sav.Pal{ + // condensed twice (Rank 3), never condensed (Rank 1), and an older parse + // that carried no Rank property at all (nil, must stay unavailable). + {InstanceID: "condensed", OwnerUID: "owner", ContainerID: "party", SlotIndex: 0, Rank: &rank3}, + {InstanceID: "nevercond", OwnerUID: "owner", ContainerID: "party", SlotIndex: 1, Rank: &rank1}, + {InstanceID: "absent", OwnerUID: "owner", ContainerID: "party", SlotIndex: 2}, + }, + } + if err = s.ReplaceWorld(ctx, w, time.Now(), 0); err != nil { + t.Fatal(err) + } + + // Typed surface (integration player detail / PalsTyped). + typed, err := s.PalsTyped(ctx, "owner") + if err != nil { + t.Fatal(err) + } + byID := map[string]*int{} + for i := range typed { + byID[typed[i].InstanceID] = typed[i].Rank + } + if r := byID["condensed"]; r == nil || *r != 3 { + t.Fatalf("condensed pal rank = %v, want 3", r) + } + if r := byID["nevercond"]; r == nil || *r != 1 { + t.Fatalf("never-condensed pal rank = %v, want 1", r) + } + if r, ok := byID["absent"]; !ok || r != nil { + t.Fatalf("absent-rank pal rank = %v, want nil (not 0)", r) + } + + // Session map surface (player detail UI). Absent rank must be a nil *int, never 0. + session, err := s.Pals(ctx, "owner") + if err != nil { + t.Fatal(err) + } + for _, pal := range session { + rank, ok := pal["rank"].(*int) + if !ok { + t.Fatalf("session pal rank is %T, want *int", pal["rank"]) + } + switch pal["instanceId"] { + case "condensed": + if rank == nil || *rank != 3 { + t.Fatalf("session condensed rank = %v, want 3", rank) + } + case "absent": + if rank != nil { + t.Fatalf("session absent rank = %v, want nil (not 0)", rank) + } + } + } + + // Explorer/roster surface. + rows, err := s.PalsExplorerPage(ctx, PalExplorerQuery{Limit: 10}) + if err != nil { + t.Fatal(err) + } + for _, row := range rows { + if row.InstanceID == "absent" && row.Rank != nil { + t.Fatalf("explorer absent rank = %v, want nil", row.Rank) + } + if row.InstanceID == "condensed" && (row.Rank == nil || *row.Rank != 3) { + t.Fatalf("explorer condensed rank = %v, want 3", row.Rank) + } + } +} + func TestReplaceWorldCarriesLastOwnerAcrossGuildBaseDeployment(t *testing.T) { s, err := Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -612,8 +691,8 @@ func TestMigration004AddsNullablePalPlacementColumns(t *testing.T) { t.Fatal(err) } defer st.Close() - if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, getErr) + if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, getErr) } pals, err := st.PalsTyped(context.Background(), "owner") if err != nil || len(pals) != 1 { @@ -672,8 +751,8 @@ func TestFreshDatabaseReachesLatestSchemaAndAPIKeysUsable(t *testing.T) { defer s.Close() ctx := context.Background() v, err := s.GetKV(ctx, "schema_version") - if err != nil || v != "9" { - t.Fatalf("schema_version = %q, %v; want 9", v, err) + if err != nil || v != "10" { + t.Fatalf("schema_version = %q, %v; want 10", v, err) } hash := [32]byte{1, 2, 3} created, err := s.CreateAPIKey(ctx, "abcd1234", hash, "fresh-db-key", time.Now()) @@ -739,8 +818,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { ctx := context.Background() v, err := upgraded.GetKV(ctx, "schema_version") - if err != nil || v != "9" { - t.Fatalf("schema_version after upgrade = %q, %v; want 9", v, err) + if err != nil || v != "10" { + t.Fatalf("schema_version after upgrade = %q, %v; want 10", v, err) } if _, err = upgraded.ListAPIKeys(ctx); err != nil { t.Fatalf("api_keys table not usable after upgrade: %v", err) @@ -769,8 +848,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { t.Fatalf("second Open (already at latest version) returned an error: %v", err) } defer reopened.Close() - if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "9" { - t.Fatalf("schema_version after no-op reopen = %q, %v; want 9", v, err) + if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 10", v, err) } } diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index ed1d698..c2be11c 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -427,13 +427,13 @@ const palsByPlayer: Record = { Kestrel: [ { instanceId: "pal-k1", characterId: "Anubis", displayName: "Anubis", level: 34, isAlpha: true, isLucky: false, - hp: 1240.5, gender: "male", talents: { hp: 87, melee: 73, shot: 92, defense: 81 }, + hp: 1240.5, gender: "male", rank: 5, talents: { hp: 87, melee: 73, shot: 92, defense: 81 }, passiveSkillIds: ["CraftSpeed_up2", "ElementBoost_Earth_2_PAL"], equippedSkillIds: ["RockLance", "StoneShotgun", "GroundWave"], }, - { instanceId: "pal-k2", characterId: "Grizzbolt", displayName: "Grizzbolt", level: 31, isAlpha: false, isLucky: false }, - { instanceId: "pal-k3", characterId: "Faleris", displayName: "Faleris", level: 30, isAlpha: false, isLucky: false }, + { instanceId: "pal-k2", characterId: "Grizzbolt", displayName: "Grizzbolt", level: 31, isAlpha: false, isLucky: false, rank: 3 }, + { instanceId: "pal-k3", characterId: "Faleris", displayName: "Faleris", level: 30, isAlpha: false, isLucky: false, rank: 1 }, { instanceId: "pal-k4", characterId: "Digtoise", displayName: "Digtoise", level: 27, isAlpha: false, isLucky: false }, - { instanceId: "pal-k5", characterId: "Penking", displayName: "Penking", level: 25, isAlpha: false, isLucky: true }, + { instanceId: "pal-k5", characterId: "Penking", displayName: "Penking", level: 25, isAlpha: false, isLucky: true, rank: 2 }, { instanceId: "pal-k6", characterId: "Rayhound", displayName: "Rayhound", level: 24, isAlpha: false, isLucky: false }, { instanceId: "pal-k7", characterId: "Tombat", displayName: "Tombat", level: 22, isAlpha: false, isLucky: false }, { instanceId: "pal-k8", characterId: "Foxparks", displayName: "Foxparks", level: 19, isAlpha: false, isLucky: false }, @@ -443,7 +443,7 @@ const palsByPlayer: Record = { { instanceId: "pal-k12", characterId: "Pengullet", displayName: "Pengullet", level: 7, isAlpha: false, isLucky: false }, ], VossR: [ - { instanceId: "pal-v1", characterId: "Frostallion", displayName: "Frostallion", level: 32, isAlpha: false, isLucky: false }, + { instanceId: "pal-v1", characterId: "Frostallion", displayName: "Frostallion", level: 32, isAlpha: false, isLucky: false, rank: 4 }, { instanceId: "pal-v2", characterId: "Ragnahawk", displayName: "Ragnahawk", level: 28, isAlpha: false, isLucky: false }, { instanceId: "pal-v3", characterId: "Surfent", displayName: "Surfent", level: 26, isAlpha: false, isLucky: false }, { instanceId: "pal-v4", characterId: "Direhowl", displayName: "Direhowl", level: 20, isAlpha: false, isLucky: false }, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fd161cf..47ce12a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -115,6 +115,12 @@ export interface PlayerPal { baseId?: string | null; /** Individual save observations. Null means unavailable, not zero. */ hp?: number | null; + /** + * Pal Condenser rank: 1 (never condensed) through 5 (four stars). Displayed + * stars are rank-1. Null/undefined means the save carried no Rank property — + * shown as "Unavailable", never as zero stars. + */ + rank?: number | null; gender?: "male" | "female" | "unknown" | ""; talents?: { hp: number | null; diff --git a/frontend/src/components/PalBoxDialog.tsx b/frontend/src/components/PalBoxDialog.tsx index 849f179..0f911e4 100644 --- a/frontend/src/components/PalBoxDialog.tsx +++ b/frontend/src/components/PalBoxDialog.tsx @@ -4,6 +4,7 @@ import { Dialog } from "./ConfirmDialog"; import { PalIcon } from "./PalIcon"; import { IconChevronLeft, IconChevronRight } from "./icons"; import { PalDetailPanel, PalInfoButton } from "./PalDetails"; +import { PalStars } from "./PalStars"; const PARTY_SLOTS = 5; const BOX_SLOTS = 30; @@ -65,6 +66,7 @@ function PalCell({ pal, expanded, onInfo }: { pal: PlayerPal | null; expanded: b Lv {pal.level} {pal.isAlpha && α} {pal.isLucky && } + {pal.rank != null && pal.rank > 1 && } ); diff --git a/frontend/src/components/PalDetails.css b/frontend/src/components/PalDetails.css index c19ea0c..2969329 100644 --- a/frontend/src/components/PalDetails.css +++ b/frontend/src/components/PalDetails.css @@ -18,7 +18,7 @@ .pal-detail-fact, .pal-talent-box { min-width: 0; padding: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-ctl); } -.pal-detail-fact span, .pal-talent-box span { +.pal-detail-fact > span, .pal-talent-box > span { display: block; color: var(--ink-3); font-size: 10px; text-transform: uppercase; letter-spacing: var(--track-caps); } .pal-detail-fact strong { display: block; margin-top: 3px; font-size: var(--text-xs); overflow-wrap: anywhere; } diff --git a/frontend/src/components/PalDetails.tsx b/frontend/src/components/PalDetails.tsx index 53887cf..48f179b 100644 --- a/frontend/src/components/PalDetails.tsx +++ b/frontend/src/components/PalDetails.tsx @@ -1,7 +1,10 @@ +import type { ReactNode } from "react"; import type { PlayerPal } from "../api/types"; import { truncateMiddle } from "../app/format"; import { IconInfo } from "./icons"; import { humanizePalIdentifier, palGenderLabel, palPlacementLabel } from "./palDetails"; +import { condensedStars } from "./palStars"; +import { PalStars } from "./PalStars"; import { WorkSuitabilityBadges } from "./WorkSuitabilityBadges"; import { PAL_WORK_DATA_PROVENANCE } from "./workSuitabilities"; import "./PalDetails.css"; @@ -46,6 +49,7 @@ export function PalDetailPanel({ pal, id }: { pal: PlayerPal; id: string }) { + } />
@@ -74,7 +78,7 @@ export function PalDetailPanel({ pal, id }: { pal: PlayerPal; id: string }) { ); } -function Fact({ label, value }: { label: string; value: string }) { +function Fact({ label, value }: { label: string; value: ReactNode }) { return (
{label} diff --git a/frontend/src/components/PalStars.css b/frontend/src/components/PalStars.css new file mode 100644 index 0000000..bbd5947 --- /dev/null +++ b/frontend/src/components/PalStars.css @@ -0,0 +1,11 @@ +.pal-stars { + display: inline-flex; + gap: 1px; + line-height: 1; + white-space: nowrap; + color: var(--ink-3); + font-size: 12px; +} +.pal-star.is-filled { + color: #d9a441; +} diff --git a/frontend/src/components/PalStars.tsx b/frontend/src/components/PalStars.tsx new file mode 100644 index 0000000..4a3aabd --- /dev/null +++ b/frontend/src/components/PalStars.tsx @@ -0,0 +1,22 @@ +import { condensedStars, MAX_CONDENSE_STARS } from "./palStars"; +import "./PalStars.css"; + +/** + * Renders the 0–4 condenser stars for a Pal. Returns nothing when the rank is + * unavailable, so compact contexts stay clean and never imply "zero stars" for + * missing data; detail contexts should show their own "Unavailable" copy instead. + */ +export function PalStars({ rank, className }: { rank: number | null | undefined; className?: string }) { + const filled = condensedStars(rank); + if (filled === null) return null; + const label = `Condensed ${filled} of ${MAX_CONDENSE_STARS} stars`; + return ( + + {Array.from({ length: MAX_CONDENSE_STARS }, (_, i) => ( + + ))} + + ); +} diff --git a/frontend/src/components/palStars.ts b/frontend/src/components/palStars.ts new file mode 100644 index 0000000..ae0cadc --- /dev/null +++ b/frontend/src/components/palStars.ts @@ -0,0 +1,15 @@ +/** A Pal is never condensed at rank 1 and gains one star per condense, up to four. */ +export const MAX_CONDENSE_STARS = 4; + +/** + * Filled condenser stars for a raw Pal rank, or null when the rank is unavailable. + * + * Honesty rule: a null/undefined rank (the save carried no Rank property) returns + * null so callers can say "Unavailable" or render nothing — it is never coerced to + * zero stars. A present rank maps 1..5 to 0..4 filled stars, clamped so out-of-range + * saves can't render a broken row. + */ +export function condensedStars(rank: number | null | undefined): number | null { + if (rank === null || rank === undefined) return null; + return Math.max(0, Math.min(MAX_CONDENSE_STARS, Math.round(rank) - 1)); +} diff --git a/frontend/src/routes/pals/Pals.tsx b/frontend/src/routes/pals/Pals.tsx index cd0e211..671abcc 100644 --- a/frontend/src/routes/pals/Pals.tsx +++ b/frontend/src/routes/pals/Pals.tsx @@ -8,6 +8,7 @@ import { Card, CardBody, CardHead } from "../../components/Card"; import { EmptyState } from "../../components/EmptyState"; import { PalDetailPanel, PalInfoButton } from "../../components/PalDetails"; import { PalIcon } from "../../components/PalIcon"; +import { PalStars } from "../../components/PalStars"; import { palPlacementLabel } from "../../components/palDetails"; import { SearchField } from "../../components/Field"; import { @@ -176,6 +177,7 @@ function PalExplorerCard({ pal, expanded, onToggle }: { pal: PalExplorerPal; exp
{specimen.map((label) => {label === "Boss" ? "◆ Boss" : label})} {specimen.length === 0 && Standard} + {pal.rank != null && pal.rank > 1 && }
{palOwnerSummary(pal)} {palPlacementLabel(pal)} diff --git a/frontend/tests/pal-stars.test.mjs b/frontend/tests/pal-stars.test.mjs new file mode 100644 index 0000000..263e5e1 --- /dev/null +++ b/frontend/tests/pal-stars.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { readFile } from "node:fs/promises"; +import { condensedStars, MAX_CONDENSE_STARS } from "../src/components/palStars.ts"; + +test("condensed stars map rank 1..5 to 0..4 filled stars", () => { + assert.equal(MAX_CONDENSE_STARS, 4); + assert.equal(condensedStars(1), 0); + assert.equal(condensedStars(2), 1); + assert.equal(condensedStars(3), 2); + assert.equal(condensedStars(5), 4); +}); + +test("unavailable rank stays null and is never coerced to zero stars", () => { + // The honesty rule: missing data is null, not 0 — callers show "Unavailable" + // or render nothing rather than an all-empty star row. + assert.equal(condensedStars(null), null); + assert.equal(condensedStars(undefined), null); + // A present rank of 1 (never condensed) is a real 0 stars, distinct from null. + assert.equal(condensedStars(1), 0); + assert.notEqual(condensedStars(1), condensedStars(null)); +}); + +test("out-of-range ranks clamp instead of rendering a broken row", () => { + assert.equal(condensedStars(0), 0); + assert.equal(condensedStars(9), 4); + assert.equal(condensedStars(2.4), 1); +}); + +test("the reusable stars component is shared across every per-Pal surface", async () => { + const [details, pals, box] = await Promise.all([ + readFile(new URL("../src/components/PalDetails.tsx", import.meta.url), "utf8"), + readFile(new URL("../src/routes/pals/Pals.tsx", import.meta.url), "utf8"), + readFile(new URL("../src/components/PalBoxDialog.tsx", import.meta.url), "utf8"), + ]); + // Detail panel adds a Condensed fact that stays honest about missing ranks. + assert.match(details, /Condensed/); + assert.match(details, /condensedStars\(pal\.rank\) === null \? "Unavailable"/); + assert.match(details, / 1 && Date: Wed, 15 Jul 2026 14:26:45 -0500 Subject: [PATCH 08/14] test(frontend): add Playwright visual/console smoke harness Boots Vite in mock mode, logs in through the real UI, and visits every nav route (derived from Shell.tsx NAV_ITEMS via ssrLoadModule, plus /login) at 1440x940 and 700x940. Per page it asserts no pageerror/console.error, meaningful content, and no horizontal document overflow -- the tripwire that catches overlapping/clipped/hidden-marker layout regressions. Runs via `npm run test:smoke` (kept out of the fast unit `npm test`) and in a dedicated CI job that installs chromium and uploads screenshots on failure. Screenshots go to a gitignored output dir. Playwright pinned to 1.61.1 to match the prebuilt chromium browser cache. Also fixes a real mock-mode defect the harness surfaced: the panel opened an EventSource to /api/v1/events/stream even with no backend, 404ing on every authenticated page. SSE is now disabled under USE_MOCK; react-query polling already keeps the mock UI live. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 27 +++++ frontend/.gitignore | 3 + frontend/package-lock.json | 48 ++++++++ frontend/package.json | 2 + frontend/src/components/Shell.tsx | 5 +- frontend/tests/smoke/smoke.mjs | 178 ++++++++++++++++++++++++++++++ 6 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/smoke/smoke.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a1a8f4..2a91cab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,30 @@ jobs: - name: Check panel version consistency working-directory: frontend run: node --test tests/version.test.mjs + + smoke: + name: Frontend (Playwright smoke) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install + working-directory: frontend + run: npm ci --no-audit --no-fund + - name: Install Playwright browser + working-directory: frontend + run: npx playwright install --with-deps chromium + - name: Smoke (Playwright visual/console) + working-directory: frontend + run: npm run test:smoke + - name: Upload smoke screenshots on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-screenshots + path: frontend/tests/smoke/output/ + if-no-files-found: ignore diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..6b8ec06 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Playwright smoke-suite screenshots (human review on failure) +tests/smoke/output/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 53944b4..f6e4581 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "oxlint": "^1.71.0", + "playwright": "1.61.1", "typescript": "~6.0.2", "vite": "^8.1.1" } @@ -1697,6 +1698,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a3b138..0069503 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "build": "tsc -b && vite build", "lint": "oxlint", "test": "node --test tests/*.test.mjs", + "test:smoke": "node tests/smoke/smoke.mjs", "test:config-contract": "node --test tests/config-real-backend.test.mjs", "preview": "vite preview" }, @@ -26,6 +27,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "oxlint": "^1.71.0", + "playwright": "1.61.1", "typescript": "~6.0.2", "vite": "^8.1.1" } diff --git a/frontend/src/components/Shell.tsx b/frontend/src/components/Shell.tsx index 95d859c..50f2918 100644 --- a/frontend/src/components/Shell.tsx +++ b/frontend/src/components/Shell.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState, type ComponentType } from "react"; import { NavLink, Outlet } from "react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { api } from "../api/client"; +import { api, USE_MOCK } from "../api/client"; import { useAuth, useIsAdmin } from "../app/AuthProvider"; import { useToast } from "./Toast"; import { usePaletteBridge } from "../app/paletteBridge"; @@ -73,6 +73,9 @@ function LiveQueryBridge() { const queryClient = useQueryClient(); useSSE({ url: "/api/v1/events/stream", + // Mock mode has no backend to serve the stream; connecting only yields a 404 and + // a failed EventSource. react-query's refetchInterval polling keeps the mock UI live. + enabled: !USE_MOCK, onMessage: (eventName, data) => { if (eventName === "metrics") { queryClient.setQueryData(["metrics", "current"], data as MetricsCurrent); diff --git a/frontend/tests/smoke/smoke.mjs b/frontend/tests/smoke/smoke.mjs new file mode 100644 index 0000000..a97ab53 --- /dev/null +++ b/frontend/tests/smoke/smoke.mjs @@ -0,0 +1,178 @@ +// Playwright visual/console smoke harness for the Palhelm panel. +// +// Manual Playwright sweeps over mock mode caught every real layout bug this cycle +// (overlapping absolutely-positioned rows, clipped labels, hidden markers). This +// makes that check permanent and cheap: it boots Vite in mock mode, logs in through +// the real UI, then visits every nav route at two viewports and asserts each page +// - emits no pageerror and no console.error, +// - renders meaningful content (a real
/content region), and +// - never overflows the document horizontally (the overlap/clipping tripwire). +// +// Route list is derived from the single source of truth — NAV_ITEMS in +// components/Shell.tsx — via Vite's ssrLoadModule, never hardcoded, so a new nav +// entry is smoke-tested automatically and this file can't drift. +// +// Run: npm run test:smoke (not part of `npm test` — keep unit tests fast). + +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { mkdir, rm } from "node:fs/promises"; +import { createServer } from "vite"; +import { chromium } from "playwright"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FRONTEND_ROOT = resolve(HERE, "..", ".."); +const OUTPUT_DIR = resolve(HERE, "output"); +const PORT = 51789; // fixed, uncommon port; strictPort so a clash fails loudly +const ORIGIN = `http://localhost:${PORT}`; + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 940 }, + { name: "narrow", width: 700, height: 940 }, +]; + +// Console messages that are benign in dev/mock mode go here, each with a comment +// justifying why. Keep this list empty unless a message is provably not a bug. +const CONSOLE_ERROR_ALLOWLIST = [ + // (none) +]; + +function isAllowed(text) { + return CONSOLE_ERROR_ALLOWLIST.some((rx) => rx.test(text)); +} + +async function deriveRoutes(server) { + // Import NAV_ITEMS straight from the app so the route set can never drift from + // the rail/command-palette source of truth. `/login` is added explicitly because + // it lives outside the authenticated nav. + const shell = await server.ssrLoadModule("/src/components/Shell.tsx"); + const navRoutes = shell.NAV_ITEMS.map((item) => item.to); + return ["/login", ...navRoutes]; +} + +async function login(page) { + await page.goto(`${ORIGIN}/login?mock`, { waitUntil: "domcontentloaded" }); + await page.fill("#pw", "admin"); + await Promise.all([ + page.waitForURL((url) => new URL(url).pathname === "/", { timeout: 15000 }), + page.click('button[type="submit"]'), + ]); +} + +async function settle(page, route) { + // Mock calls resolve with 150-350ms simulated latency (see api/mock.ts), and lazy + // route chunks show a `.route-loader` Suspense fallback. Wait for the loader to + // clear and for skeletons to resolve so layout is final before we measure it. + await page + .waitForFunction(() => !document.querySelector(".route-loader"), { timeout: 15000 }) + .catch(() => {}); + const selector = route === "/login" ? ".login-card" : "main.content"; + await page.waitForSelector(selector, { state: "visible", timeout: 15000 }); + await page + .waitForFunction(() => document.querySelectorAll(".skel").length === 0, { timeout: 5000 }) + .catch(() => {}); +} + +async function checkPage(page, route) { + const failures = []; + const selector = route === "/login" ? ".login-card" : "main.content"; + + // meaningful content + const contentLen = await page.evaluate((sel) => { + const el = document.querySelector(sel); + return el ? (el.textContent || "").trim().length : -1; + }, selector); + if (contentLen < 1) { + failures.push(`no meaningful content in "${selector}" (textContent length ${contentLen})`); + } + + // no horizontal document overflow — the overlap/clipping tripwire + const overflow = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + innerWidth: window.innerWidth, + })); + if (overflow.scrollWidth > overflow.innerWidth + 1) { + failures.push( + `horizontal overflow: scrollWidth ${overflow.scrollWidth} > innerWidth ${overflow.innerWidth} + 1`, + ); + } + + return failures; +} + +async function run() { + await rm(OUTPUT_DIR, { recursive: true, force: true }); + await mkdir(OUTPUT_DIR, { recursive: true }); + + // VITE_MOCK=1 makes the app route every API call to the in-memory fixture; the + // per-visit `?mock` query param is a belt-and-suspenders guarantee for each full + // page load (USE_MOCK is evaluated once per document from either signal). + process.env.VITE_MOCK = "1"; + + const server = await createServer({ + root: FRONTEND_ROOT, + logLevel: "warn", + server: { port: PORT, strictPort: true }, + }); + await server.listen(); + + const routes = await deriveRoutes(server); + console.log(`Smoke: ${routes.length} routes x ${VIEWPORTS.length} viewports on ${ORIGIN}`); + + const browser = await chromium.launch(); + const allFailures = []; + + try { + for (const vp of VIEWPORTS) { + const context = await browser.newContext({ viewport: { width: vp.width, height: vp.height } }); + const page = await context.newPage(); + + // Per-page console/pageerror capture. Re-pointed at each route below. + let sink = []; + page.on("pageerror", (err) => sink.push(`pageerror: ${err.message}`)); + page.on("console", (msg) => { + if (msg.type() === "error" && !isAllowed(msg.text())) { + sink.push(`console.error: ${msg.text()}`); + } + }); + + await login(page); + + for (const route of routes) { + sink = []; + const label = `${vp.name} ${route}`; + await page.goto(`${ORIGIN}${route}?mock`, { waitUntil: "domcontentloaded" }); + await settle(page, route); + + const failures = [...sink, ...(await checkPage(page, route))]; + + const shot = resolve(OUTPUT_DIR, `${vp.name}--${route.replace(/\W+/g, "_") || "root"}.png`); + await page.screenshot({ path: shot, fullPage: true }); + + if (failures.length) { + allFailures.push(...failures.map((f) => `[${label}] ${f}`)); + console.error(`FAIL ${label}`); + for (const f of failures) console.error(` - ${f}`); + } else { + console.log(`ok ${label}`); + } + } + + await context.close(); + } + } finally { + await browser.close(); + await server.close(); + } + + if (allFailures.length) { + console.error(`\nSmoke FAILED with ${allFailures.length} issue(s). Screenshots in ${OUTPUT_DIR}`); + process.exit(1); + } + console.log(`\nSmoke PASSED. Screenshots in ${OUTPUT_DIR}`); +} + +run().catch((err) => { + console.error("Smoke harness crashed:", err); + process.exit(1); +}); From 4d23a960d5c988a10569a83da2a07e1a51a38c72 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:27:43 -0500 Subject: [PATCH 09/14] feat: real docs links, backup disk usage, and runtime config values Replace placeholder github.com/ links, hardcoded storage capacity, and disabled reference selects with real data, honoring the panel's honesty rules. Task 1 (links): Login footer and Settings About now point at the public repo github.com/8tp/palhelm (source, /issues, /releases) and the configured docs site https://docs.palhelm.com (from docs-site/astro.config.mjs `site`). Task 2 (storage): new authenticated read-only GET /api/v1/backups/storage reports the backup filesystem's real total/free bytes via statfs(2) through an injectable diskStat func; degrades to null fields when the stat fails. Host paths are never exposed. Backups Storage card and Diagnostics "Filesystem headroom" row show the real capacity/free, with plain fallbacks for older backends; the fake 50 GB constant is gone. Task 3 (config): GET /api/v1/server now reports sessionDays and saveSyncMinutes; PALHELM_SESSION_DAYS becomes a real config value wired into login-session expiry (default 7). Settings shows read-only value rows with the actual runtime values instead of disabled reference selects. openapi.json updated with ServerInfo and BackupStorage schemas and the new path. Go and frontend tests cover the new endpoint (including the stat-failure null path) and the reworded diagnostics copy. Co-Authored-By: Claude Fable 5 --- backend/internal/backup/backup.go | 4 + backend/internal/config/config.go | 5 + backend/internal/server/backups_storage.go | 34 ++++ .../internal/server/backups_storage_test.go | 149 ++++++++++++++++++ backend/internal/server/openapi.json | 5 +- .../internal/server/openapi_backups_test.go | 4 +- backend/internal/server/server.go | 18 ++- frontend/src/api/client.ts | 3 + frontend/src/api/mock.ts | 10 ++ frontend/src/api/types.ts | 11 ++ frontend/src/routes/backups/Backups.tsx | 52 ++++-- .../src/routes/diagnostics/Diagnostics.tsx | 17 +- frontend/src/routes/login/Login.tsx | 4 +- frontend/src/routes/settings/Settings.tsx | 46 ++++-- 14 files changed, 317 insertions(+), 45 deletions(-) create mode 100644 backend/internal/server/backups_storage.go create mode 100644 backend/internal/server/backups_storage_test.go diff --git a/backend/internal/backup/backup.go b/backend/internal/backup/backup.go index 3468729..f933969 100644 --- a/backend/internal/backup/backup.go +++ b/backend/internal/backup/backup.go @@ -158,6 +158,10 @@ func (e *Engine) SetCachedWorldGUID(resolve func() string) { e.cachedWorldGUID = func (e *Engine) dir() string { return filepath.Join(e.dataDir, "backups") } +// Dir returns the directory that holds backup archives. Callers use it to stat the +// backing filesystem for capacity reporting; it is never exposed to API clients. +func (e *Engine) Dir() string { return e.dir() } + // Reconcile imports and prunes archive index rows to match disk. func (e *Engine) Reconcile(ctx context.Context) error { if err := os.MkdirAll(e.dir(), 0o700); err != nil { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f71ca76..67d8bda 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -27,6 +27,8 @@ type Config struct { MetricsInterval, PlayersInterval, SaveSyncInterval time.Duration GameDataInterval, GameDataTimeout time.Duration IntegrationRateLimit int + // SessionDays is how long a login session cookie stays valid, in whole days. + SessionDays int } // Load reads the environment and applies documented defaults. @@ -77,6 +79,9 @@ func Load() (Config, error) { if c.IntegrationRateLimit, err = positiveInt("PALHELM_INTEGRATION_RATE_LIMIT", 60); err != nil { return c, err } + if c.SessionDays, err = positiveInt("PALHELM_SESSION_DAYS", 7); err != nil { + return c, err + } c.SessionSecret = os.Getenv("PALHELM_SESSION_SECRET") return c, nil } diff --git a/backend/internal/server/backups_storage.go b/backend/internal/server/backups_storage.go new file mode 100644 index 0000000..e449e11 --- /dev/null +++ b/backend/internal/server/backups_storage.go @@ -0,0 +1,34 @@ +package server + +import ( + "net/http" + "syscall" +) + +// diskStatFunc reports the total and available bytes of the filesystem backing a +// path. It is a field on Server so tests can drive the stat-failure branch. +type diskStatFunc func(path string) (total, avail uint64, err error) + +// statfsDiskUsage reads real filesystem capacity via statfs(2). Total and available +// bytes come from the block count and block size reported by the kernel. +func statfsDiskUsage(path string) (total, avail uint64, err error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, 0, err + } + bsize := uint64(st.Bsize) + return st.Blocks * bsize, st.Bavail * bsize, nil +} + +// backupStorage reports the real disk capacity and free space of the filesystem +// holding the backup volume. Host paths are never exposed. If the stat fails the +// fields are reported as null so callers degrade to the bytes they already know. +func (s *Server) backupStorage(w http.ResponseWriter, r *http.Request) { + total, avail, err := s.diskStat(s.backups.Dir()) + if err != nil { + s.log.Warn("backup storage statfs failed", "error", err) + writeJSON(w, 200, map[string]any{"totalBytes": nil, "freeBytes": nil}) + return + } + writeJSON(w, 200, map[string]any{"totalBytes": total, "freeBytes": avail}) +} diff --git a/backend/internal/server/backups_storage_test.go b/backend/internal/server/backups_storage_test.go new file mode 100644 index 0000000..72bf064 --- /dev/null +++ b/backend/internal/server/backups_storage_test.go @@ -0,0 +1,149 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/8tp/palhelm/internal/config" + "github.com/8tp/palhelm/internal/store" +) + +// storageTestServer builds a logged-in panel and returns an authenticated request helper. +func storageTestServer(t *testing.T) (*Server, func(method, path string) *httptest.ResponseRecorder) { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + cfg := config.Config{ + DataDir: dir, SaveDir: filepath.Join(dir, "Saved"), AdminPassword: "panelpass", + SessionSecret: strings.Repeat("s", 48), + MetricsInterval: time.Hour, PlayersInterval: time.Hour, SaveSyncInterval: time.Hour, + } + app, handler := New(cfg, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + + login := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"password":"panelpass"}`)) + login.Header.Set("Content-Type", "application/json") + lr := httptest.NewRecorder() + handler.ServeHTTP(lr, login) + if lr.Code != http.StatusOK { + t.Fatalf("login = %d: %s", lr.Code, lr.Body.String()) + } + cookie := lr.Result().Cookies()[0] + request := func(method, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + return rr + } + return app, request +} + +func TestBackupStorageReportsRealCapacity(t *testing.T) { + app, request := storageTestServer(t) + // The default statfs implementation is wired in New; assert it returns a sane, + // self-consistent capacity for the real backup filesystem. + app.diskStat = statfsDiskUsage + + rr := request(http.MethodGet, "/api/v1/backups/storage") + if rr.Code != http.StatusOK { + t.Fatalf("GET /backups/storage = %d: %s", rr.Code, rr.Body.String()) + } + var body struct { + TotalBytes *uint64 `json:"totalBytes"` + FreeBytes *uint64 `json:"freeBytes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.TotalBytes == nil || body.FreeBytes == nil { + t.Fatalf("expected real capacity, got %s", rr.Body.String()) + } + if *body.TotalBytes == 0 { + t.Fatalf("totalBytes should be positive: %s", rr.Body.String()) + } + if *body.FreeBytes > *body.TotalBytes { + t.Fatalf("freeBytes %d exceeds totalBytes %d", *body.FreeBytes, *body.TotalBytes) + } +} + +func TestBackupStorageStatFailureReportsNull(t *testing.T) { + app, request := storageTestServer(t) + // Inject a failing stat: the endpoint must degrade to null fields, never a fabricated + // capacity, and never surface the host path. + app.diskStat = func(string) (uint64, uint64, error) { return 0, 0, errors.New("statfs: no such file or directory") } + + rr := request(http.MethodGet, "/api/v1/backups/storage") + if rr.Code != http.StatusOK { + t.Fatalf("GET /backups/storage = %d: %s", rr.Code, rr.Body.String()) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + for _, field := range []string{"totalBytes", "freeBytes"} { + v, ok := raw[field] + if !ok { + t.Fatalf("response missing %q: %s", field, rr.Body.String()) + } + if string(v) != "null" { + t.Fatalf("%s = %s, want null", field, string(v)) + } + } +} + +func TestServerInfoExposesRuntimeConfig(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + cfg := config.Config{ + DataDir: dir, SaveDir: filepath.Join(dir, "Saved"), AdminPassword: "panelpass", + SessionSecret: strings.Repeat("s", 48), + MetricsInterval: time.Hour, PlayersInterval: time.Hour, SaveSyncInterval: 10 * time.Minute, + SessionDays: 14, + } + _, handler := New(cfg, st, slog.New(slog.NewTextHandler(io.Discard, nil))) + login := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewBufferString(`{"password":"panelpass"}`)) + login.Header.Set("Content-Type", "application/json") + lr := httptest.NewRecorder() + handler.ServeHTTP(lr, login) + if lr.Code != http.StatusOK { + t.Fatalf("login = %d", lr.Code) + } + cookie := lr.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /server = %d: %s", rr.Code, rr.Body.String()) + } + var body struct { + SessionDays int `json:"sessionDays"` + SaveSyncMinutes int `json:"saveSyncMinutes"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.SessionDays != 14 { + t.Errorf("sessionDays = %d, want 14", body.SessionDays) + } + if body.SaveSyncMinutes != 10 { + t.Errorf("saveSyncMinutes = %d, want 10", body.SaveSyncMinutes) + } +} diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index b14886c..a46efb1 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -8,7 +8,7 @@ "/api/v1/auth/login": {"post": {"security": [], "responses": {"200": {"description": "Session created"}}}}, "/api/v1/auth/logout": {"post": {"responses": {"200": {"description": "Session cleared"}}}}, "/api/v1/auth/session": {"get": {"responses": {"200": {"description": "Current session"}}}}, - "/api/v1/server": {"get": {"responses": {"200": {"description": "Server status"}}}}, + "/api/v1/server": {"get": {"responses": {"200": {"description": "Server status, panel version, and the panel's runtime session/save-sync configuration", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServerInfo"}}}}}}}, "/api/v1/server/health": {"get": {"responses": {"200": {"description": "Data-source health"}}}}, "/api/v1/server/announce": {"post": {"responses": {"200": {"description": "Announcement sent"}}}}, "/api/v1/server/save": {"post": {"responses": {"200": {"description": "Save requested"}}}}, @@ -50,6 +50,7 @@ "/api/v1/backups/{id}/restore/dry-run": {"post": {"parameters": [{"$ref": "#/components/parameters/BackupID"}], "responses": {"200": {"description": "Restore difference preview", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupDiff"}}}}, "400": {"description": "Invalid backup id", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Backup not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Another backup operation is running", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/backups/{id}/restore": {"post": {"parameters": [{"$ref": "#/components/parameters/BackupID"}], "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RestoreRequest"}}}}, "responses": {"200": {"description": "Backup restored", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupDiff"}}}}, "400": {"description": "Invalid confirmation", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Backup not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Server is reachable or another backup operation is running", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/backups/{id}": {"delete": {"parameters": [{"$ref": "#/components/parameters/BackupID"}], "responses": {"200": {"description": "Backup deleted", "content": {"application/json": {"schema": {"type": "object", "required": ["ok"], "properties": {"ok": {"type": "boolean", "const": true}}}}}}, "400": {"description": "Invalid backup id", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Backup not found", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Another backup operation is running", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, + "/api/v1/backups/storage": {"get": {"responses": {"200": {"description": "Real disk capacity and free space of the filesystem holding the backup volume. Host paths are never exposed; fields are null when the filesystem stat is unavailable.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupStorage"}}}}}}}, "/api/v1/backups/schedule": {"get": {"responses": {"200": {"description": "Persisted backup schedule", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupSchedule"}}}}}}, "put": {"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupScheduleUpdate"}}}}, "responses": {"200": {"description": "Updated backup schedule and actual next deadline", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BackupSchedule"}}}}, "400": {"description": "Invalid schedule", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/config": { "get": {"responses": {"200": {"description": "Merged desired/effective configuration and deployment capabilities", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ConfigDoc"}}}}}}, @@ -205,6 +206,8 @@ }, "schemas": { "Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "object", "required": ["code", "message"], "properties": {"code": {"type": "string"}, "message": {"type": "string"}, "manualCommand": {"type": "string"}}}}}, + "ServerInfo": {"type": "object", "required": ["name", "description", "version", "worldGuid", "state", "uptimeSec", "panelVersion", "sessionDays", "saveSyncMinutes"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "version": {"type": "string"}, "worldGuid": {"type": "string"}, "state": {"type": "string"}, "uptimeSec": {"type": "integer", "format": "int64"}, "panelVersion": {"type": "string"}, "sessionDays": {"type": "integer", "minimum": 1, "description": "Login session lifetime in whole days (PALHELM_SESSION_DAYS)."}, "saveSyncMinutes": {"type": "integer", "minimum": 0, "description": "Save-sync poll interval in whole minutes (PALHELM_SAVE_SYNC_INTERVAL)."}}}, + "BackupStorage": {"type": "object", "required": ["totalBytes", "freeBytes"], "properties": {"totalBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Total capacity of the backup filesystem, or null when unavailable."}, "freeBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Free space on the backup filesystem, or null when unavailable."}}}, "Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}}, "BackupEntry": {"type": "object", "required": ["path", "sizeBytes", "modifiedAt"], "properties": {"path": {"type": "string"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "modifiedAt": {"type": "string", "format": "date-time"}}}, "BackupChange": {"type": "object", "required": ["path", "kind"], "properties": {"path": {"type": "string"}, "kind": {"type": "string", "enum": ["add", "modify", "delete"]}, "fromSize": {"type": "integer", "format": "int64", "minimum": 0}, "toSize": {"type": "integer", "format": "int64", "minimum": 0}}}, diff --git a/backend/internal/server/openapi_backups_test.go b/backend/internal/server/openapi_backups_test.go index 91e95e6..672114d 100644 --- a/backend/internal/server/openapi_backups_test.go +++ b/backend/internal/server/openapi_backups_test.go @@ -36,6 +36,7 @@ func TestBackupsOpenAPIContract(t *testing.T) { "/api/v1/backups/{id}/restore": {"post": {"200", "400", "404", "409"}}, "/api/v1/backups/{id}": {"delete": {"200", "400", "404", "409"}}, "/api/v1/backups/schedule": {"get": {"200"}, "put": {"200", "400"}}, + "/api/v1/backups/storage": {"get": {"200"}}, } for path, methods := range want { pathItem := object(t, paths, path) @@ -60,10 +61,11 @@ func TestBackupsOpenAPIContract(t *testing.T) { assertResponseRef(t, paths, "/api/v1/backups", "post", "201", "#/components/schemas/Backup") assertResponseRef(t, paths, "/api/v1/backups/{id}/restore/dry-run", "post", "200", "#/components/schemas/BackupDiff") assertResponseRef(t, paths, "/api/v1/backups/schedule", "get", "200", "#/components/schemas/BackupSchedule") + assertResponseRef(t, paths, "/api/v1/backups/storage", "get", "200", "#/components/schemas/BackupStorage") components := object(t, doc, "components") schemas := object(t, components, "schemas") - for _, name := range []string{"Backup", "BackupEntry", "BackupChange", "BackupDiff", "BackupSchedule", "BackupScheduleUpdate", "RestoreRequest"} { + for _, name := range []string{"Backup", "BackupEntry", "BackupChange", "BackupDiff", "BackupSchedule", "BackupScheduleUpdate", "BackupStorage", "RestoreRequest"} { if _, ok := schemas[name]; !ok { t.Errorf("missing backup schema %s", name) } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index aa2a04f..86dd348 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -45,6 +45,7 @@ type Server struct { gamecfg *gameconfig.Editor integration *integrationAuth avatars *steamavatar.Resolver + diskStat diskStatFunc started time.Time log *slog.Logger } @@ -67,7 +68,7 @@ func New(cfg config.Config, st *store.Store, log *slog.Logger) (*Server, http.Ha // than taking down session/admin routes too. log.Error("load active integration API keys", "error", err) } - s := &Server{cfg: cfg, store: st, pal: pal, rcon: palworld.NewRCONClient(cfg.RCONAddr, cfg.PalworldPassword), poll: p, health: health, hub: hub, auth: newAuth(cfg.SessionSecret, cfg.AdminPassword, cfg.ViewerPassword, cfg.TrustedProxies...), shutdown: newOrchestrator(pal), integration: newIntegrationAuth(st, activeKeys, cfg.IntegrationRateLimit, log), avatars: steamavatar.New(cfg.SteamWebAPIKey), started: time.Now(), log: log} + s := &Server{cfg: cfg, store: st, pal: pal, rcon: palworld.NewRCONClient(cfg.RCONAddr, cfg.PalworldPassword), poll: p, health: health, hub: hub, auth: newAuth(cfg.SessionSecret, cfg.AdminPassword, cfg.ViewerPassword, cfg.TrustedProxies...), shutdown: newOrchestrator(pal), integration: newIntegrationAuth(st, activeKeys, cfg.IntegrationRateLimit, log), avatars: steamavatar.New(cfg.SteamWebAPIKey), diskStat: statfsDiskUsage, started: time.Now(), log: log} emitBackup := func(message string, meta any) { e := store.Event{At: time.Now().UTC(), Kind: "backup", Message: message, Meta: meta} _ = st.AddEvent(context.Background(), e) @@ -196,6 +197,7 @@ func (s *Server) routes() http.Handler { api.Head("/backups/{id}/download", s.downloadBackup) api.Get("/backups/{id}/contents", s.backupContents) api.Get("/backups/schedule", s.backupSchedule) + api.Get("/backups/storage", s.backupStorage) api.Get("/config", s.getConfig) api.Group(func(m chi.Router) { m.Use(adminOnly) @@ -283,14 +285,18 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { writeError(w, 401, "invalid_credentials", "The password is incorrect.") return } - expires := time.Now().Add(7 * 24 * time.Hour) + days := s.cfg.SessionDays + if days < 1 { + days = 7 + } + expires := time.Now().Add(time.Duration(days) * 24 * time.Hour) token, err := s.auth.token(role, expires) if err != nil { internal(w, err) return } secure := s.cfg.SecureCookies || r.TLS != nil || s.auth.forwardedHTTPS(r) - http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", Expires: expires, MaxAge: 7 * 24 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure}) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", Expires: expires, MaxAge: days * 24 * 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: secure}) writeJSON(w, 200, map[string]string{"role": role}) } func (s *Server) logout(w http.ResponseWriter, r *http.Request) { @@ -308,7 +314,11 @@ func (s *Server) serverInfo(w http.ResponseWriter, r *http.Request) { if err != nil { state = "unreachable" } - writeJSON(w, 200, map[string]any{"name": i.ServerName, "description": i.Description, "version": i.Version, "worldGuid": i.WorldGUID, "state": state, "uptimeSec": i.Uptime, "panelVersion": PanelVersion}) + days := s.cfg.SessionDays + if days < 1 { + days = 7 + } + writeJSON(w, 200, map[string]any{"name": i.ServerName, "description": i.Description, "version": i.Version, "worldGuid": i.WorldGUID, "state": state, "uptimeSec": i.Uptime, "panelVersion": PanelVersion, "sessionDays": days, "saveSyncMinutes": int(s.cfg.SaveSyncInterval.Minutes())}) } func (s *Server) serverHealth(w http.ResponseWriter, r *http.Request) { rest, rcon, save, at := s.health.Snapshot() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c8dbb27..d2a9126 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,6 +10,7 @@ import type { BackupContentEntry, BackupDryRun, BackupSchedule, + BackupStorage, ConfigDoc, ConfigValue, ConsoleLogEntry, @@ -206,6 +207,8 @@ export const api = { remove: (id: string): Promise => (USE_MOCK ? mock.deleteBackup(id) : request("DELETE", `/backups/${id}`)), schedule: (): Promise => USE_MOCK ? mock.getSchedule() : request("GET", "/backups/schedule"), + storage: (): Promise => + USE_MOCK ? mock.getStorage() : request("GET", "/backups/storage"), setSchedule: (s: BackupSchedule): Promise => USE_MOCK ? mock.setSchedule(s) : request("PUT", "/backups/schedule", s), }, diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index ed1d698..45b2437 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -15,6 +15,7 @@ import type { BackupContentEntry, BackupDryRun, BackupSchedule, + BackupStorage, ConfigDoc, ConfigSetting, ConfigValue, @@ -320,6 +321,8 @@ export async function getServer(): Promise { state: "running", uptimeSec: Math.floor((Date.now() - BOOT_AT) / 1000), panelVersion: "0.8.0", + sessionDays: 7, + saveSyncMinutes: 10, }; } @@ -995,6 +998,13 @@ export async function getSchedule(): Promise { return schedule; } +export async function getStorage(): Promise { + requireSession(); + await latency(); + // A plausible 500 GB volume; the used total is derived by the caller from the backup list. + return { totalBytes: 500_000_000_000, freeBytes: 421_500_000_000 }; +} + export async function setSchedule(next: BackupSchedule): Promise { requireAdmin(); await latency(150, 300); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index fd161cf..7abeb0a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -37,6 +37,10 @@ export interface ServerInfo { state: ServerState; uptimeSec: number; panelVersion: string; + // Panel runtime configuration, reported so Settings shows the values actually in effect + // rather than static reference numbers. Optional so older backends still type-check. + sessionDays?: number; + saveSyncMinutes?: number; } export type HealthState = "ok" | "error"; @@ -443,6 +447,13 @@ export interface BackupSchedule { nextRunAt: string | null; } +// Real disk capacity of the filesystem holding the backup volume. Fields are null when the +// backend can't stat the filesystem; host paths are never exposed. +export interface BackupStorage { + totalBytes: number | null; + freeBytes: number | null; +} + // ---------- Config ---------- export type ConfigValue = string | number | boolean; diff --git a/frontend/src/routes/backups/Backups.tsx b/frontend/src/routes/backups/Backups.tsx index d5ab64e..0adc187 100644 --- a/frontend/src/routes/backups/Backups.tsx +++ b/frontend/src/routes/backups/Backups.tsx @@ -18,10 +18,6 @@ import { useToast } from "../../components/Toast"; import { IconArchive, IconWarn } from "../../components/icons"; import "./Backups.css"; -// Backup volume capacity isn't exposed by the API (v1); shown as a fixed reference so the -// meter has a denominator. Revisit when the backend reports disk usage. -const STORAGE_CAPACITY_BYTES = 50_000_000_000; - const TRIGGER_TONE: Record = { scheduled: "idle", manual: "ok", @@ -36,6 +32,7 @@ export default function BackupsRoute() { const backupsQuery = useQuery({ queryKey: ["backups"], queryFn: () => api.backups.list() }); const scheduleQuery = useQuery({ queryKey: ["backups", "schedule"], queryFn: () => api.backups.schedule() }); + const storageQuery = useQuery({ queryKey: ["backups", "storage"], queryFn: () => api.backups.storage() }); const [search, setSearch] = useState(""); const [triggerFilter, setTriggerFilter] = useState<"all" | BackupTrigger>("all"); @@ -188,19 +185,40 @@ export default function BackupsRoute() { -
- Used -
- {formatBytes(totalBytes)} of {formatBytes(STORAGE_CAPACITY_BYTES)} -
-
- -
-
- {backups.length} snapshots kept - {oldest ? ` · oldest ${formatDuration((Date.now() - new Date(oldest.createdAt).getTime()) / 1000)} ago` : ""} -
-
+ {(() => { + const capacity = storageQuery.data?.totalBytes ?? null; + const free = storageQuery.data?.freeBytes ?? null; + const kept = `${backups.length} snapshots kept${ + oldest ? ` · oldest ${formatDuration((Date.now() - new Date(oldest.createdAt).getTime()) / 1000)} ago` : "" + }`; + return ( +
+ Used by backups +
+ {formatBytes(totalBytes)} + {capacity !== null && of {formatBytes(capacity)}} +
+ {capacity !== null ? ( + <> +
+ +
+
+ {kept} + {free !== null ? ` · ${formatBytes(free)} free on volume` : ""} +
+ + ) : ( +
+ {kept} +
+ Total volume capacity isn't reported by this panel build. +
+
+ )} +
+ ); + })()}
diff --git a/frontend/src/routes/diagnostics/Diagnostics.tsx b/frontend/src/routes/diagnostics/Diagnostics.tsx index eb3f587..4e9c373 100644 --- a/frontend/src/routes/diagnostics/Diagnostics.tsx +++ b/frontend/src/routes/diagnostics/Diagnostics.tsx @@ -33,12 +33,13 @@ export default function DiagnosticsRoute() { const snapshotQuery = useQuery({ queryKey: ["world", "snapshot"], queryFn: () => api.world.snapshot(), refetchInterval: FIFTEEN_SECONDS }); const backupsQuery = useQuery({ queryKey: ["backups"], queryFn: () => api.backups.list(), refetchInterval: 60_000 }); const scheduleQuery = useQuery({ queryKey: ["backups", "schedule"], queryFn: () => api.backups.schedule(), refetchInterval: 60_000 }); + const storageQuery = useQuery({ queryKey: ["backups", "storage"], queryFn: () => api.backups.storage(), refetchInterval: 60_000, retry: false }); async function refresh() { setRefreshing(true); try { await Promise.all([ - healthQuery.refetch(), worldQuery.refetch(), snapshotQuery.refetch(), backupsQuery.refetch(), scheduleQuery.refetch(), + healthQuery.refetch(), worldQuery.refetch(), snapshotQuery.refetch(), backupsQuery.refetch(), scheduleQuery.refetch(), storageQuery.refetch(), ]); } finally { setRefreshing(false); @@ -50,6 +51,7 @@ export default function DiagnosticsRoute() { const snapshot = snapshotQuery.data; const backups = backupsQuery.data; const schedule = scheduleQuery.data; + const storage = storageQuery.data; const latestBackup = backups?.[0]; const totalBackupBytes = backups?.reduce((total, item) => total + item.sizeBytes, 0) ?? 0; const coverage = snapshot ? linkCoverage(snapshot.diagnostics.linkedBasePals, snapshot.diagnostics.unresolvedBasePals) : null; @@ -140,8 +142,17 @@ export default function DiagnosticsRoute() { - - + {storage && storage.totalBytes !== null && storage.freeBytes !== null ? ( + + ) : ( + + )} + diff --git a/frontend/src/routes/login/Login.tsx b/frontend/src/routes/login/Login.tsx index a13f10b..cec36ca 100644 --- a/frontend/src/routes/login/Login.tsx +++ b/frontend/src/routes/login/Login.tsx @@ -63,10 +63,10 @@ export default function Login() {
Palhelm server administration - + open source - + docs
diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index d2b3e85..cd09ed0 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -117,12 +117,19 @@ export default function SettingsRoute() {
- - configured via PALHELM_SESSION_DAYS — shown here for reference + + configured via PALHELM_SESSION_DAYS
@@ -150,16 +157,16 @@ export default function SettingsRoute() { Apache-2.0
@@ -225,6 +232,9 @@ function SaveSyncCard() { const queryClient = useQueryClient(); const toast = useToast(); const worldQuery = useQuery({ queryKey: ["world"], queryFn: () => api.world.get() }); + // Shares the ["server"] cache with the main Settings query (react-query dedupes by key). + const serverQuery = useQuery({ queryKey: ["server"], queryFn: () => api.server.get() }); + const saveSyncMinutes = serverQuery.data?.saveSyncMinutes; const parseMutation = useMutation({ mutationFn: () => api.world.parse(), @@ -250,13 +260,15 @@ function SaveSyncCard() {
- - configured via PALHELM_SYNC_MINUTES — shown here for reference + + configured via PALHELM_SAVE_SYNC_INTERVAL
{isAdmin && (
From 44057feda7c4bc62c4d9981f18e902c6e4837839 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:30:35 -0500 Subject: [PATCH 10/14] Decode base camp world locations from the save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sav parser only read {id, guildId, workerContainerId} for each BaseCampSaveData entry, so every base was served with no position. The Guilds page then showed all bases at the same wrong in-game coordinate and the map stacked every base marker on one point. Decode each base's world transform from its RawData: after the id GUID, name, and a one-byte state, the FTransform stores a rotation quaternion, the world translation, and scale as f64 components; we read the translation. The decode is defensive — an embedded GUID that does not match the map key, a short buffer, or a non-finite/implausible coordinate yields no position, recorded as a tolerated skip, so a base is served with a null location rather than a misleading (0,0). Verified against a live 1.0 world: all 20 bases decode to within ~0.05 cm of their guild's in-game PalBox. Serve the decoded value everywhere, and make absent locations honest rather than (0,0): the session guild list (GuildJSON), the typed integration guild view, and the guild detail all emit null when a base has no decoded location. The frontend map already reads the list's base locations, so drop undecoded bases from the map markers and mark GuildBase.location nullable. Guild detail was already null-safe. openapi marks the integration base location nullable, matching the guild-detail schema. The bases table already stores nullable x/y (migration 001), so no schema change is needed; the store writes null when a base has no position. Co-Authored-By: Claude Fable 5 --- backend/internal/sav/base_location_test.go | 122 ++++++++++++++++++ backend/internal/sav/world.go | 79 +++++++++++- backend/internal/server/integration.go | 12 +- backend/internal/server/openapi.json | 2 +- .../server/openapi_integration_test.go | 15 +++ .../internal/store/base_location_null_test.go | 114 ++++++++++++++++ backend/internal/store/store.go | 18 ++- frontend/src/api/mock.ts | 2 +- frontend/src/api/types.ts | 4 +- frontend/src/routes/map/Map.tsx | 8 +- 10 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 backend/internal/sav/base_location_test.go create mode 100644 backend/internal/store/base_location_null_test.go diff --git a/backend/internal/sav/base_location_test.go b/backend/internal/sav/base_location_test.go new file mode 100644 index 0000000..ee0de0a --- /dev/null +++ b/backend/internal/sav/base_location_test.go @@ -0,0 +1,122 @@ +package sav + +import ( + "encoding/binary" + "math" + "testing" +) + +// encodeBaseRawData builds a PalBaseCampSaveData.RawData blob in the proven +// retail 1.x layout: id GUID, name fstring, state byte, FTransform (rotation +// quaternion + translation + scale, all f64), area_range f32, group GUID, and +// some trailing bytes the decoder must ignore. +func encodeBaseRawData(idBytes [16]byte, name string, tx, ty, tz float64) []byte { + var b []byte + f64 := func(v float64) { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], math.Float64bits(v)) + b = append(b, buf[:]...) + } + b = append(b, idBytes[:]...) // id GUID + // name: positive-length ANSI fstring (length includes the null terminator). + var l [4]byte + binary.LittleEndian.PutUint32(l[:], uint32(len(name)+1)) + b = append(b, l[:]...) + b = append(b, name...) + b = append(b, 0) + b = append(b, 0x01) // state byte + f64(0.0) // quat.x + f64(0.0) // quat.y + f64(0.7071) // quat.z + f64(0.7071) // quat.w + f64(tx) // translation.x + f64(ty) // translation.y + f64(tz) // translation.z + f64(1.0) // scale.x + f64(1.0) // scale.y + f64(1.0) // scale.z + var area [4]byte + binary.LittleEndian.PutUint32(area[:], math.Float32bits(3500)) + b = append(b, area[:]...) // area_range f32 + b = append(b, make([]byte, 16)...) // group_id_belong_to GUID + b = append(b, make([]byte, 40)...) // trailing worker/module bytes to ignore + return b +} + +func TestBaseLocationDecodesTranslation(t *testing.T) { + id := [16]byte{0x00, 0xd9, 0x34, 0x5d, 0x4e, 0x43, 0xa4, 0xea, 0x24, 0x48, 0xe6, 0x99, 0xcf, 0xb5, 0x3c, 0x79} + raw := encodeBaseRawData(id, "拠点テンプレート", -304214.09, 227626.09, 2883.5) + + // The embedded GUID as readGUID renders it is the canonical key; matching it + // is part of the decoder's contract, so derive it the same way. + key, err := readGUID(newReader(id[:])) + if err != nil { + t.Fatal(err) + } + + loc, ok := baseLocation(raw, key) + if !ok { + t.Fatalf("baseLocation returned !ok for a well-formed blob") + } + if math.Abs(loc.X-(-304214.09)) > 1e-3 || math.Abs(loc.Y-227626.09) > 1e-3 || math.Abs(loc.Z-2883.5) > 1e-3 { + t.Fatalf("decoded translation = (%.3f,%.3f,%.3f), want (-304214.09,227626.09,2883.5)", loc.X, loc.Y, loc.Z) + } + + // An empty baseID skips the key check and must still decode. + if loc2, ok := baseLocation(raw, ""); !ok || loc2.X != loc.X { + t.Fatalf("baseLocation with empty baseID = %v,%v", loc2, ok) + } +} + +func TestBaseLocationRejectsDrift(t *testing.T) { + id := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + key, _ := readGUID(newReader(id[:])) + raw := encodeBaseRawData(id, "camp", 100, 200, 300) + + // A GUID that does not match the map key is treated as drift, not a location. + if _, ok := baseLocation(raw, "ffffffff-ffff-ffff-ffff-ffffffffffff"); ok { + t.Fatalf("baseLocation accepted a blob whose embedded GUID mismatched the key") + } + // A buffer truncated before the translation must fail closed, not read garbage. + if _, ok := baseLocation(raw[:40], key); ok { + t.Fatalf("baseLocation accepted a truncated blob") + } + // Empty input must not panic and must fail closed. + if _, ok := baseLocation(nil, key); ok { + t.Fatalf("baseLocation accepted nil input") + } +} + +// TestBaseFromEntryDecodesRawDataPosition proves the property-tree path: a base +// entry carrying only a RawData byte property (no plain Position/Location +// property, as retail 1.x saves are shaped) yields a decoded Position. +func TestBaseFromEntryDecodesRawDataPosition(t *testing.T) { + id := [16]byte{0xaa, 0xbb, 0xcc, 0xdd, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} + key, _ := readGUID(newReader(id[:])) + raw := encodeBaseRawData(id, "camp", -12345.5, 67890.25, -42) + entry := mapEntry{ + Key: key, + Value: propertyMap{ + "RawData": &property{Value: raw}, + }, + } + stats := newStats() + base := baseFromEntry(entry, &stats) + if base.Position == nil { + t.Fatalf("baseFromEntry did not decode a Position from RawData") + } + if base.Position.X != -12345.5 || base.Position.Y != 67890.25 || base.Position.Z != -42 { + t.Fatalf("baseFromEntry Position = %+v, want (-12345.5,67890.25,-42)", *base.Position) + } + + // A base whose RawData cannot be decoded must yield a nil Position (served as + // null), never a zero vector, and record the tolerated skip. + badStats := newStats() + bad := baseFromEntry(mapEntry{Key: key, Value: propertyMap{"RawData": &property{Value: []byte{1, 2, 3}}}}, &badStats) + if bad.Position != nil { + t.Fatalf("undecodable RawData produced a non-nil Position %+v", *bad.Position) + } + if badStats.SkippedProperties == 0 { + t.Fatalf("undecodable base transform was not recorded as a tolerated skip") + } +} diff --git a/backend/internal/sav/world.go b/backend/internal/sav/world.go index d553fd6..e321d36 100644 --- a/backend/internal/sav/world.go +++ b/backend/internal/sav/world.go @@ -2,6 +2,7 @@ package sav import ( "errors" + "math" "os" "path/filepath" "strconv" @@ -75,7 +76,7 @@ func extractWorldSaveData(w *World, props propertyMap) { if p := root["BaseCampSaveData"]; p != nil { if entries, ok := p.Value.([]mapEntry); ok { for _, e := range entries { - w.Bases = append(w.Bases, baseFromEntry(e)) + w.Bases = append(w.Bases, baseFromEntry(e, &w.Stats)) } } else { w.Stats.DecodeFailures["bases"]++ @@ -125,15 +126,28 @@ func decodeGuildEntry(w *World, e mapEntry) { w.Guilds = append(w.Guilds, g) } -func baseFromEntry(e mapEntry) BaseCamp { +func baseFromEntry(e mapEntry, stats *ParseStats) BaseCamp { b := BaseCamp{} if id, ok := e.Key.(string); ok { b.ID = id } if v, ok := asProperties(e.Value); ok { b.GuildID = firstString(v, "GroupIdBelongTo", "GroupID", "GuildId", "GuildID") - if p, ok := firstVector(v, "Position", "Location"); ok { - b.Position = &p + // The base's world transform lives inside PalBaseCampSaveData.RawData; it is + // not exposed as an ordinary Position/Location property, so decode it from the + // raw bytes. A pre-1.0 save that instead carries a plain vector property is + // still honored as a fallback. + if raw, ok := propertyBytes(v, "RawData"); ok { + if loc, ok := baseLocation(raw, b.ID); ok { + b.Position = loc + } else { + stats.recordSkip("worldSaveData.BaseCampSaveData.Value.RawData.transform", "tolerated") + } + } + if b.Position == nil { + if p, ok := firstVector(v, "Position", "Location"); ok { + b.Position = &p + } } if worker, ok := propertyProperties(v, "WorkerDirector"); ok { if raw, ok := propertyBytes(worker, "RawData"); ok { @@ -144,6 +158,63 @@ func baseFromEntry(e mapEntry) BaseCamp { return b } +// baseLocation decodes the world-space translation of a base camp from +// PalBaseCampSaveData.RawData. The proven retail 1.x prefix is: +// +// id GUID (16 bytes) — must match the map key +// name fstring +// state 1 byte (EPalBaseCampWorkerStateType) +// transform FTransform: rotation quaternion (4 f64) + translation +// (3 f64) + scale3d (3 f64); modern 1.x saves store each +// component as f64 +// area_range f32 +// group_id_belong_to GUID +// ... (worker/module data this decoder ignores) +// +// Only the translation is needed. Any structural drift — short buffer, an +// embedded GUID that does not match the map key, a read error, or a non-finite +// or implausibly large component — yields (nil,false) so the caller serves a +// null location rather than a misleading (0,0). Verified against a live 1.0 +// world: all 20 bases decoded to within <1 cm of the guild's in-game PalBox. +func baseLocation(raw []byte, baseID string) (*Vector, bool) { + r := newReader(raw) + embedded, err := readGUID(r) + if err != nil || (baseID != "" && !strings.EqualFold(embedded, baseID)) { + return nil, false + } + if _, err = r.fstring(); err != nil { // name + return nil, false + } + // state byte, then the rotation quaternion (4 f64) we do not need. + if err = r.skip(1 + 4*8); err != nil { + return nil, false + } + x, err := r.f64() + if err != nil { + return nil, false + } + y, err := r.f64() + if err != nil { + return nil, false + } + z, err := r.f64() + if err != nil { + return nil, false + } + if !finiteBaseCoord(x) || !finiteBaseCoord(y) || !finiteBaseCoord(z) { + return nil, false + } + return &Vector{X: x, Y: y, Z: z}, true +} + +// finiteBaseCoord rejects NaN, infinities, and coordinates far outside any +// plausible Palworld world extent (~±700 km in cm), which would indicate the +// transform read landed on misaligned bytes rather than a real translation. +func finiteBaseCoord(v float64) bool { + const maxWorldCoord = 1e10 + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= -maxWorldCoord && v <= maxWorldCoord +} + // workerContainerID decodes PalBaseCampSaveData_WorkerDirector.RawData. The // stable prefix is [base GUID][FTransform: 10 float64][order byte][battle byte] // [worker-container GUID]. Palworld 1.0 appends four version bytes, so trailing diff --git a/backend/internal/server/integration.go b/backend/internal/server/integration.go index 0123057..c7b3ca5 100644 --- a/backend/internal/server/integration.go +++ b/backend/internal/server/integration.go @@ -416,9 +416,9 @@ type integrationLocationView struct { Y float64 `json:"y"` } type integrationBaseView struct { - ID string `json:"id"` - Location integrationLocationView `json:"location"` - Level int `json:"level"` + ID string `json:"id"` + Location *integrationLocationView `json:"location"` + Level int `json:"level"` } type integrationGuildView struct { ID string `json:"id"` @@ -655,7 +655,11 @@ func (s *Server) integrationGuilds(w http.ResponseWriter, r *http.Request) { } bases := make([]integrationBaseView, 0, len(g.Bases)) for _, b := range g.Bases { - bases = append(bases, integrationBaseView{ID: b.ID, Location: integrationLocationView{X: b.X, Y: b.Y}, Level: b.Level}) + var location *integrationLocationView // null, not (0,0), for an undecoded base transform. + if b.HasLocation { + location = &integrationLocationView{X: b.X, Y: b.Y} + } + bases = append(bases, integrationBaseView{ID: b.ID, Location: location, Level: b.Level}) } views = append(views, integrationGuildView{ID: g.ID, Name: g.Name, AdminUID: g.AdminUID, MemberCount: len(members), Members: members, Bases: bases}) } diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index d801510..de9f477 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -237,7 +237,7 @@ "IntegrationPalListItem": {"allOf": [{"$ref": "#/components/schemas/IntegrationPal"}, {"type": "object", "required": ["ownerUid", "ownerName", "ownerSource", "ownerResolved"], "properties": {"ownerUid": {"type": "string"}, "ownerName": {"type": "string", "description": "Empty string when ownership is unresolved or the joined player has no name."}, "ownerSource": {"type": "string", "enum": ["save", "personal_container", "last_observed", "unresolved"], "description": "How player attribution was established. last_observed is historical attribution while a Pal is outside a personal container; it is not proof of current possession."}, "ownerResolved": {"type": "boolean", "description": "True when ownerUid joins a current public player row. Consult ownerSource for attribution certainty."}}}]}, "IntegrationGuildMember": {"type": "object", "required": ["uid", "name"], "properties": {"uid": {"type": "string"}, "name": {"type": "string"}}}, "IntegrationLocation": {"type": "object", "required": ["x", "y"], "properties": {"x": {"type": "number"}, "y": {"type": "number"}}}, - "IntegrationBase": {"type": "object", "required": ["id", "location", "level"], "properties": {"id": {"type": "string"}, "location": {"$ref": "#/components/schemas/IntegrationLocation"}, "level": {"type": "integer"}}}, + "IntegrationBase": {"type": "object", "required": ["id", "location", "level"], "properties": {"id": {"type": "string"}, "location": {"oneOf": [{"$ref": "#/components/schemas/IntegrationLocation"}, {"type": "null"}]}, "level": {"type": "integer"}}}, "IntegrationGuild": {"type": "object", "required": ["id", "name", "adminUid", "memberCount", "members", "bases"], "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "adminUid": {"type": "string"}, "memberCount": {"type": "integer"}, "members": {"type": "array", "items": {"$ref": "#/components/schemas/IntegrationGuildMember"}}, "bases": {"type": "array", "items": {"$ref": "#/components/schemas/IntegrationBase"}}}}, "IntegrationMapTransform": {"type": "object", "required": ["a", "b", "c", "d"], "properties": {"a": {"type": "number"}, "b": {"type": "number"}, "c": {"type": "number"}, "d": {"type": "number"}}}, "IntegrationMapLayer": {"type": "object", "required": ["id", "minZoom", "maxZoom"], "properties": {"id": {"type": "string"}, "label": {"type": "string"}, "format": {"type": "string"}, "tileSize": {"type": "integer"}, "minZoom": {"type": "integer"}, "maxZoom": {"type": "integer"}, "transform": {"$ref": "#/components/schemas/IntegrationMapTransform"}, "bounds": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2}}}, diff --git a/backend/internal/server/openapi_integration_test.go b/backend/internal/server/openapi_integration_test.go index cfcfaec..1e80a97 100644 --- a/backend/internal/server/openapi_integration_test.go +++ b/backend/internal/server/openapi_integration_test.go @@ -197,6 +197,21 @@ func resolveSchema(doc map[string]any, schema map[string]any) map[string]any { } return map[string]any{"type": "object", "properties": props, "required": required} } + // A nullable object/ref is documented as oneOf:[, {"type":"null"}]. + // Resolve the non-null branch so a present value is validated field-for-field + // while a JSON null legitimately matches the null branch. + if oneOf, ok := schema["oneOf"].([]any); ok { + for _, sub := range oneOf { + subSchema, ok := sub.(map[string]any) + if !ok { + continue + } + if typ, _ := subSchema["type"].(string); typ == "null" { + continue + } + return resolveSchema(doc, subSchema) + } + } return schema } diff --git a/backend/internal/store/base_location_null_test.go b/backend/internal/store/base_location_null_test.go new file mode 100644 index 0000000..96a504f --- /dev/null +++ b/backend/internal/store/base_location_null_test.go @@ -0,0 +1,114 @@ +package store + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/8tp/palhelm/internal/sav" +) + +// TestBaseNullLocationStaysNull proves the honesty contract end to end: a base +// whose transform was never decoded (Position == nil) is stored and served as a +// null location on every guild surface, never a misleading (0,0). +func TestBaseNullLocationStaysNull(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "base-null.db")) + if err != nil { + t.Fatal(err) + } + defer s.Close() + ctx := context.Background() + now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) + member := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + guildID := "cccccccccccccccccccccccccccccccc" + placed := "dddddddddddddddddddddddddddddddd" + unplaced := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee0" + world := &sav.World{ + Players: []sav.Player{{UID: member, Nickname: "Member", GuildID: guildID}}, + Guilds: []sav.Guild{{ID: guildID, Name: "Guild", AdminUID: member, Members: []sav.GuildMember{{UID: member, Name: "Member"}}}}, + Bases: []sav.BaseCamp{ + {ID: placed, GuildID: guildID, Position: &sav.Vector{X: 100, Y: 200, Z: 300}}, + {ID: unplaced, GuildID: guildID, Position: nil}, // transform never decoded + }, + } + if err = s.ReplaceWorld(ctx, world, now, 0); err != nil { + t.Fatal(err) + } + + // Guild detail: the placed base carries a location, the unplaced one is null. + detail, err := s.GuildDetail(ctx, guildID, now) + if err != nil { + t.Fatal(err) + } + byID := map[string]*GuildDetailLocation{} + for _, b := range detail.Bases { + byID[b.ID] = b.Location + } + if loc := byID[placed]; loc == nil || loc.X != 100 || loc.Y != 200 { + t.Fatalf("placed base detail location = %#v, want {100,200}", loc) + } + if loc, ok := byID[unplaced]; !ok || loc != nil { + t.Fatalf("unplaced base detail location = %#v, want present-but-null", loc) + } + + // Typed integration surface: HasLocation distinguishes null from (0,0). + guilds, err := s.Guilds(ctx) + if err != nil { + t.Fatal(err) + } + if len(guilds) != 1 { + t.Fatalf("Guilds = %d, want 1", len(guilds)) + } + seen := 0 + for _, b := range guilds[0].Bases { + switch b.ID { + case placed: + if !b.HasLocation || b.X != 100 || b.Y != 200 { + t.Fatalf("placed GuildBase = %#v", b) + } + seen++ + case unplaced: + if b.HasLocation { + t.Fatalf("unplaced GuildBase reported HasLocation; must be null") + } + seen++ + } + } + if seen != 2 { + t.Fatalf("expected both bases in typed guild, saw %d", seen) + } + + // Session JSON surface (GuildJSON): the unplaced base's "location" is JSON null. + guildObjects, err := s.GuildJSON(ctx) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(guildObjects) + if err != nil { + t.Fatal(err) + } + var decoded []struct { + Bases []struct { + ID string `json:"id"` + Location *struct { + X, Y float64 + } `json:"location"` + } `json:"bases"` + } + if err = json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("GuildJSON unmarshal: %v (body %s)", err, raw) + } + if len(decoded) != 1 { + t.Fatalf("GuildJSON guilds = %d, want 1", len(decoded)) + } + for _, b := range decoded[0].Bases { + if b.ID == unplaced && b.Location != nil { + t.Fatalf("GuildJSON unplaced base location = %#v, want null", b.Location) + } + if b.ID == placed && (b.Location == nil || b.Location.X != 100) { + t.Fatalf("GuildJSON placed base location = %#v, want {100,200}", b.Location) + } + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 4fbaf13..2be07b7 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1171,7 +1171,11 @@ func (s *Store) GuildJSON(ctx context.Context) ([]map[string]any, error) { br.Close() return nil, e } - bases = append(bases, map[string]any{"id": bid, "location": map[string]any{"x": x.Float64, "y": y.Float64}, "level": level}) + var location any // null, not (0,0), when the base transform was never decoded. + if x.Valid && y.Valid { + location = map[string]any{"x": x.Float64, "y": y.Float64} + } + bases = append(bases, map[string]any{"id": bid, "location": location, "level": level}) } br.Close() out = append(out, map[string]any{"id": g.id, "name": g.name, "adminUid": g.admin, "memberCount": len(members), "members": members, "bases": bases}) @@ -1192,11 +1196,14 @@ type Guild struct { // GuildMember is one guild roster entry. type GuildMember struct{ UID, Name string } -// GuildBase is one persistent guild-owned base. +// GuildBase is one persistent guild-owned base. HasLocation is false when the +// base transform was never decoded (a pre-decoding save); X and Y are then zero +// and must be surfaced as a null location rather than a misleading (0,0). type GuildBase struct { - ID string - X, Y float64 - Level int + ID string + X, Y float64 + HasLocation bool + Level int } // Guilds returns every guild with its members and bases, typed (the integration-surface @@ -1247,6 +1254,7 @@ func (s *Store) Guilds(ctx context.Context) ([]Guild, error) { return nil, e } b.X, b.Y = x.Float64, y.Float64 + b.HasLocation = x.Valid && y.Valid g.Bases = append(g.Bases, b) } if e = br.Close(); e != nil { diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index aafeb7d..f9fd969 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -1049,7 +1049,7 @@ export async function getWorldSnapshot(): Promise { active: true, location: { x: player.location!.x, y: player.location!.y, z: 0 }, })), - { kind: "BaseCampPal", characterId: "Anubis", name: "Anubis", level: 35, hpPercent: 88, active: true, activity: "working", linked: true, instanceId: "mock-pal-1", baseId: guilds[0]?.bases[0]?.id, ownerName: players[0]?.name, location: { x: guilds[0]?.bases[0]?.location.x ?? 0, y: guilds[0]?.bases[0]?.location.y ?? 0, z: 0 } }, + { kind: "BaseCampPal", characterId: "Anubis", name: "Anubis", level: 35, hpPercent: 88, active: true, activity: "working", linked: true, instanceId: "mock-pal-1", baseId: guilds[0]?.bases[0]?.id, ownerName: players[0]?.name, location: { x: guilds[0]?.bases[0]?.location?.x ?? 0, y: guilds[0]?.bases[0]?.location?.y ?? 0, z: 0 } }, ], truncated: false, diagnostics: { lastRequestDurationMs: 184, lastAcceptedActorCount: 118, lastErrorCategory: "none", linkedBasePals: 18, unresolvedBasePals: 0, linkLookupFailed: false, scheduledDelayMs: 30000, nextAttemptAt: new Date(Date.now() + 18_000).toISOString() }, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 36e91c1..a6f724a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -247,7 +247,9 @@ export interface WhitelistEntry { // ---------- Guilds ---------- export interface GuildBase { id: string; - location: { x: number; y: number }; + // null when the base's world transform was never decoded (a pre-decoding + // save). Consumers must treat this as "location unavailable", never (0,0). + location: { x: number; y: number } | null; level: number; } diff --git a/frontend/src/routes/map/Map.tsx b/frontend/src/routes/map/Map.tsx index 0161680..3e6a820 100644 --- a/frontend/src/routes/map/Map.tsx +++ b/frontend/src/routes/map/Map.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { api, USE_MOCK } from "../../api/client"; -import type { MapDataset, MapDatasetLayer } from "../../api/types"; +import type { GuildBase, MapDataset, MapDatasetLayer } from "../../api/types"; import { layerMapToWorld, MAP_SIZE, @@ -298,7 +298,11 @@ export default function MapRoute() { const liveSnapshot = worldSnapshotQuery.isError || worldSnapshotQuery.isRefetchError ? undefined : worldSnapshotQuery.data; const playerMarkerSelection = selectPlayerMarkers(playersQuery.data ?? [], liveSnapshot); const playerMarkers = playerMarkerSelection.markers; - const bases = (guildsQuery.data ?? []).flatMap((g) => g.bases.map((b) => ({ ...b, guildName: g.name }))); + // Bases without a decoded location cannot be plotted; drop them so every + // downstream base marker has a real (never (0,0)) position. + const bases = (guildsQuery.data ?? []) + .flatMap((g) => g.bases.map((b) => ({ ...b, guildName: g.name }))) + .filter((b): b is typeof b & { location: NonNullable } => b.location !== null); const liveMapActors = selectLiveMapActors(liveSnapshot); const workers = liveMapActors.workers; const palBoxes = liveMapActors.palBoxes; From 805d9a24b3b42d857061f8d2e7ac698a432965cb Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:34:41 -0500 Subject: [PATCH 11/14] Cluster live map workers so busy bases stay readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workers layer drew one labeled chip per base worker, so toggling it on a real server (200+ loaded) painted a wall of overlapping labels. Route workers through the same screen-space clusterMapMarkers used by players and bases: nearby workers collapse into an "N workers" chip and zooming in separates them, with no separate max-zoom chooser needed. Danger stays honest and visible: an incapacitated or sub-25% HP worker keeps its danger accent, and a cluster with any troubled member takes the accent and spells it out ("12 workers · 2 hurt"). Single-worker chips are unchanged. PalBoxes are left as-is — they are one-per-base and naturally sparse, so they never produced the overlap problem and clustering them would not be free. Mock: the live-world fixture now seeds ~16 workers ringed around one PalBox at the Nightloom base (a couple hurt, one knocked out) so clustering is visible in mock mode. Pure grouping/danger/label logic is covered by new tests. Co-Authored-By: Claude Fable 5 --- frontend/src/api/mock.ts | 38 +++++++++++++- frontend/src/app/liveWorld.ts | 15 ++++++ frontend/src/app/mapClustering.ts | 2 +- frontend/src/routes/map/Map.css | 2 + frontend/src/routes/map/Map.tsx | 73 +++++++++++++++++++++------ frontend/tests/mapClustering.test.mjs | 55 ++++++++++++++++++++ 6 files changed, 166 insertions(+), 19 deletions(-) diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index f9fd969..be8b301 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -23,6 +23,7 @@ import type { GuildDetail, IntegrationKey, IntegrationKeyCreated, + LiveWorldActor, LiveWorldSnapshot, MapDataset, MetricsCurrent, @@ -1026,6 +1027,38 @@ export async function getWorld(): Promise { }; } +// A realistic single base: one PalBox with ~16 workers packed tightly around it, so mock mode +// exercises the map's Workers-layer clustering (they collapse to one chip at world zoom and +// separate as you zoom in). Positions are a fixed ring of small world-cm offsets from the base +// center, deterministic across renders. A couple are critically hurt and one is knocked out so +// the cluster's danger accent and "N workers · M hurt" label are visible. +const mockBaseCenter = guilds[0]?.bases[0]?.location ?? { x: 0, y: 0 }; +const mockBaseId = guilds[0]?.bases[0]?.id; +const mockWorkerNames = [ + "Anubis", "Grizzbolt", "Digtoise", "Penking", "Foxparks", "Lamball", "Cattiva", "Chikipi", + "Tombat", "Rayhound", "Melpaca", "Vixy", "Tanzee", "Lifmunk", "Fuack", "Depresso", +]; +const mockBaseWorkers: LiveWorldActor[] = mockWorkerNames.map((name, i) => { + const angle = (i / mockWorkerNames.length) * Math.PI * 2; + const radius = 2600 + (i % 4) * 1500; // world cm: tight enough to cluster, wide enough to split on zoom + const hurt = i === 3 || i === 9; // critically low HP + const down = i === 12; // knocked out + return { + kind: "BaseCampPal", + characterId: name, + name, + level: 8 + ((i * 7) % 28), + hpPercent: down ? 0 : hurt ? 14 : 70 + ((i * 13) % 30), + active: true, + activity: down ? "incapacitated" : i % 5 === 0 ? "transporting" : i % 3 === 0 ? "idle" : "working", + linked: true, + instanceId: `mock-worker-${i + 1}`, + baseId: mockBaseId, + ownerName: players[0]?.name, + location: { x: mockBaseCenter.x + Math.cos(angle) * radius, y: mockBaseCenter.y + Math.sin(angle) * radius, z: 0 }, + }; +}); + export async function getWorldSnapshot(): Promise { requireSession(); await latency(); @@ -1037,7 +1070,7 @@ export async function getWorldSnapshot(): Promise { sourceTime: "2026-07-14 13:00:00", fps: 57, fpsAvg: 55.4, - counts: { players: online.length, partyPals: online.length * 2, basePals: 18, wildPals: 84, npcs: 11, palBoxes: 2, unknown: 0 }, + counts: { players: online.length, partyPals: online.length * 2, basePals: mockBaseWorkers.length, wildPals: 84, npcs: 11, palBoxes: 1, unknown: 0 }, activity: { working: 9, transporting: 2, eating: 1, sleeping: 2, idle: 2, inactive: 1, combat: 0, incapacitated: 1, moving: 0, unknown: 0 }, actors: [ ...online.map((player) => ({ @@ -1049,7 +1082,8 @@ export async function getWorldSnapshot(): Promise { active: true, location: { x: player.location!.x, y: player.location!.y, z: 0 }, })), - { kind: "BaseCampPal", characterId: "Anubis", name: "Anubis", level: 35, hpPercent: 88, active: true, activity: "working", linked: true, instanceId: "mock-pal-1", baseId: guilds[0]?.bases[0]?.id, ownerName: players[0]?.name, location: { x: guilds[0]?.bases[0]?.location?.x ?? 0, y: guilds[0]?.bases[0]?.location?.y ?? 0, z: 0 } }, + { kind: "PalBox", guildName: guilds[0]?.name, activity: "unknown", location: { x: mockBaseCenter.x, y: mockBaseCenter.y, z: 0 } }, + ...mockBaseWorkers, ], truncated: false, diagnostics: { lastRequestDurationMs: 184, lastAcceptedActorCount: 118, lastErrorCategory: "none", linkedBasePals: 18, unresolvedBasePals: 0, linkLookupFailed: false, scheduledDelayMs: 30000, nextAttemptAt: new Date(Date.now() + 18_000).toISOString() }, diff --git a/frontend/src/app/liveWorld.ts b/frontend/src/app/liveWorld.ts index 9dfcf18..bd141ad 100644 --- a/frontend/src/app/liveWorld.ts +++ b/frontend/src/app/liveWorld.ts @@ -47,6 +47,21 @@ export function selectLiveMapActors(snapshot: LiveWorldSnapshot | undefined): Li }; } +/** A base worker counts as "in danger" when it is knocked out or critically low on HP. The map + * keeps this visible on the worker's own chip and propagates it to any cluster it collapses into, + * so an overview never hides a base that needs attention. Unknown HP is not treated as danger. */ +export function isWorkerInDanger(worker: LiveWorldActor): boolean { + return worker.activity === "incapacitated" || (worker.hpPercent !== undefined && worker.hpPercent < 25); +} + +/** Plain-English summary for a group of clustered base workers, e.g. "12 workers · 2 hurt". The + * hurt count is only appended when at least one worker is in danger, keeping healthy bases quiet. */ +export function summarizeWorkerCluster(workers: readonly LiveWorldActor[]): { label: string; hurt: number; danger: boolean } { + const hurt = workers.filter(isWorkerInDanger).length; + const label = hurt > 0 ? `${workers.length} workers · ${hurt} hurt` : `${workers.length} workers`; + return { label, hurt, danger: hurt > 0 }; +} + /** * Reconciles transient game-data coordinates onto the authoritative REST roster. * diff --git a/frontend/src/app/mapClustering.ts b/frontend/src/app/mapClustering.ts index 6b1747d..e4b199d 100644 --- a/frontend/src/app/mapClustering.ts +++ b/frontend/src/app/mapClustering.ts @@ -1,4 +1,4 @@ -export type ClusterMarkerKind = "player" | "base"; +export type ClusterMarkerKind = "player" | "base" | "worker"; /** A marker already projected into screen space. `value` retains the original entity/coordinates. */ export interface ClusterMarkerPoint { diff --git a/frontend/src/routes/map/Map.css b/frontend/src/routes/map/Map.css index a3c6f55..b620183 100644 --- a/frontend/src/routes/map/Map.css +++ b/frontend/src/routes/map/Map.css @@ -186,6 +186,8 @@ } .marker-worker.danger .marker-symbol { color: var(--danger); } .marker-worker.danger .chip { color: var(--danger-ink); border-color: var(--danger); } +.marker-worker.marker-cluster .marker-count { background: var(--ok); } +.marker-worker.marker-cluster.danger .marker-count { background: var(--danger); } .marker-palbox { display: flex; align-items: center; gap: 5px; } .marker-palbox .marker-symbol { color: var(--accent); } .marker-coordinate { display: flex; align-items: center; gap: 6px; z-index: 3; } diff --git a/frontend/src/routes/map/Map.tsx b/frontend/src/routes/map/Map.tsx index 3e6a820..307c24f 100644 --- a/frontend/src/routes/map/Map.tsx +++ b/frontend/src/routes/map/Map.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { api, USE_MOCK } from "../../api/client"; -import type { GuildBase, MapDataset, MapDatasetLayer } from "../../api/types"; +import type { GuildBase, LiveWorldActor, MapDataset, MapDatasetLayer } from "../../api/types"; import { layerMapToWorld, MAP_SIZE, @@ -29,7 +29,7 @@ import { zoomMapView, type MapSearchTarget, } from "../../app/mapInteraction"; -import { selectLiveMapActors, selectPlayerMarkers } from "../../app/liveWorld"; +import { isWorkerInDanger, selectLiveMapActors, selectPlayerMarkers, summarizeWorkerCluster } from "../../app/liveWorld"; import { Card, CardBody, CardHead } from "../../components/Card"; import { EmptyState } from "../../components/EmptyState"; import { ToggleChip } from "../../components/ToggleChip"; @@ -453,6 +453,28 @@ export default function MapRoute() { setExpandedClusterKey((current) => current === group.key ? null : group.key); }, [activeLayer, scaleBoundsFor, view]); + // Base workers reuse the same screen-space clustering as players and bases so a busy base + // (the live server currently loads 200+) collapses into one "N workers" chip instead of a + // wall of overlapping labels. Because clustering runs in screen space on every view change, + // zooming in separates the members automatically — no separate chooser is needed. + const workerMarkerGroups = useMemo(() => { + const points = workers + .filter((worker) => onLayer(activeLayer, worker.location.x, worker.location.y)) + .map((worker) => { + const mapPoint = layerWorldToMap(activeLayer, worker.location.x, worker.location.y); + const screenPoint = toScreen(mapPoint.x, mapPoint.y); + return { + key: `worker:${worker.instanceId}`, + kind: "worker" as const, + layerId: activeLayer.id, + x: screenPoint.x, + y: screenPoint.y, + value: worker, + }; + }); + return clusterMapMarkers(points, 48); + }, [activeLayer, workers, toScreen]); + const hasMap = tileState === "tiles" || tileState === "mockgrid"; useEffect(() => { @@ -658,20 +680,9 @@ export default function MapRoute() { onCluster={focusCluster} /> ))} - {layers.Workers && - workers - .filter((worker) => onLayer(activeLayer, worker.location.x, worker.location.y)) - .map((worker) => { - const m = layerWorldToMap(activeLayer, worker.location.x, worker.location.y); - const s = toScreen(m.x, m.y); - const danger = worker.activity === "incapacitated" || (worker.hpPercent !== undefined && worker.hpPercent < 25); - return ( -
- - {worker.name || worker.characterId || "Pal"} · {worker.activity} -
- ); - })} + {layers.Workers && workerMarkerGroups.map((group) => ( + + ))} {layers.PalBoxes && palBoxes .filter((box) => onLayer(activeLayer, box.location.x, box.location.y)) @@ -811,6 +822,36 @@ function MapMarkerGroup({ ); } +/** Renders one clustered group of live base workers: a lone worker keeps its per-Pal chip + * (name · activity), while a cluster shows "N workers" and, when any member is knocked out or + * critically hurt, takes the danger accent and spells out how many (e.g. "12 workers · 2 hurt") + * so a crowded base never hides one that needs help. Workers are read-only markers, so — unlike + * player/base clusters — there is nothing to click; zooming in is what separates them. */ +function WorkerMarkerGroup({ group }: { group: ClusterMarkerGroup }) { + if (group.type === "single") { + const worker = group.member.value; + const danger = isWorkerInDanger(worker); + return ( +
+ + {worker.name || worker.characterId || "Pal"} · {worker.activity} +
+ ); + } + + const { label, danger } = summarizeWorkerCluster(group.members.map(({ value }) => value)); + return ( +
+ {group.members.length} + {label} +
+ ); +} + /** Renders the full tile pyramid level `z` of `layer` in map space (each level covers the * 256-square). */ function TileGrid({ layer, z, onTileError }: { layer: ResolvedLayer; z: number; onTileError: () => void }) { diff --git a/frontend/tests/mapClustering.test.mjs b/frontend/tests/mapClustering.test.mjs index 59de8a0..d6f0e96 100644 --- a/frontend/tests/mapClustering.test.mjs +++ b/frontend/tests/mapClustering.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import { clusterMapMarkers } from "../src/app/mapClustering.ts"; +import { isWorkerInDanger, summarizeWorkerCluster } from "../src/app/liveWorld.ts"; function point(key, kind, layerId, x, y, worldX = x, worldY = y) { return { key, kind, layerId, x, y, value: { location: { x: worldX, y: worldY } } }; @@ -69,6 +70,60 @@ test("invalid clustering radius leaves every original marker accessible", () => assert.deepEqual(clusterMapMarkers(points, 0).map((group) => group.key), ["player:a", "player:b"]); }); +function worker(instanceId, x, y, over = {}) { + return { + key: `worker:${instanceId}`, + kind: "worker", + layerId: "default", + x, + y, + value: { instanceId, name: instanceId, activity: "working", hpPercent: 80, location: { x, y, z: 0 }, ...over }, + }; +} + +test("nearby base workers collapse into one worker cluster, keeping a lone worker standalone", () => { + const groups = clusterMapMarkers([ + worker("w-a", 0, 0), + worker("w-b", 20, 0), + worker("w-c", 400, 0), + ], 48); + const cluster = groups.find((group) => group.type === "cluster"); + const single = groups.find((group) => group.type === "single"); + assert.equal(cluster.members.length, 2); + assert.ok(cluster.key.includes("worker")); + assert.equal(single.member.value.instanceId, "w-c"); +}); + +test("a worker is in danger only when knocked out or critically hurt", () => { + const at = { location: { x: 0, y: 0, z: 0 } }; + assert.equal(isWorkerInDanger({ activity: "incapacitated", ...at }), true); + assert.equal(isWorkerInDanger({ activity: "working", hpPercent: 10, ...at }), true); + assert.equal(isWorkerInDanger({ activity: "working", hpPercent: 90, ...at }), false); + assert.equal(isWorkerInDanger({ activity: "working", ...at }), false); // unknown HP is not danger +}); + +test("a worker cluster label names how many members are hurt and flags danger", () => { + const at = { location: { x: 0, y: 0, z: 0 } }; + const summary = summarizeWorkerCluster([ + { activity: "working", hpPercent: 90, ...at }, + { activity: "incapacitated", ...at }, + { activity: "working", hpPercent: 12, ...at }, + ]); + assert.equal(summary.label, "3 workers · 2 hurt"); + assert.equal(summary.hurt, 2); + assert.equal(summary.danger, true); +}); + +test("a healthy worker cluster reads plainly with no hurt count", () => { + const at = { location: { x: 0, y: 0, z: 0 } }; + const summary = summarizeWorkerCluster([ + { activity: "working", hpPercent: 90, ...at }, + { activity: "idle", hpPercent: 88, ...at }, + ]); + assert.equal(summary.label, "2 workers"); + assert.equal(summary.danger, false); +}); + test("map clusters expose a direct exact-coordinate chooser when zoom cannot separate members", async () => { const route = await readFile(new URL("../src/routes/map/Map.tsx", import.meta.url), "utf8"); assert.match(route, /marker-cluster-menu/); From f1416f93ffaedbd1776ecbec1fb749d31ed55ac1 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:44:35 -0500 Subject: [PATCH 12/14] Decode and serve player-chosen base camp names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base-location decoder already walked past each base's name fstring in BaseCampSaveData.RawData; now it keeps it. Names are normalized at decode time: whitespace-only names and the engine's untranslated placeholder template "新規生成拠点テンプレート名(仮)" — which Palworld writes into every base the player never renamed, regardless of server locale — collapse to empty, so one honesty rule holds everywhere downstream: absent name = null, never "" or a synthetic label. On the live 1.0 validation save all 20 bases carry only the placeholder, so all 20 serve null. Migration 011 adds the nullable name column using the 004 rebuild-table pattern rather than a bare ALTER ADD COLUMN, keeping the interrupted- migration replay contract idempotent. Rows written before the migration stay NULL until the next save parse repopulates the table. The name rides every payload that carries base locations: session guild list (GuildJSON), guild detail bases, and the typed integration guild view, with openapi documenting it as nullable on both schemas. The guild detail page shows the real name when present and keeps the positional "Base N" fallback otherwise; mock fixtures exercise both paths. Co-Authored-By: Claude Fable 5 --- backend/internal/sav/base_location_test.go | 115 ++++++++++++++---- backend/internal/sav/types.go | 11 +- backend/internal/sav/world.go | 69 +++++++---- backend/internal/server/integration.go | 10 +- .../server/integration_data_audit_test.go | 2 +- backend/internal/server/openapi.json | 4 +- .../internal/store/base_location_null_test.go | 36 ++++-- backend/internal/store/guild_detail.go | 13 +- .../store/migrations/011_base_names.sql | 24 ++++ backend/internal/store/store.go | 27 ++-- .../store/store_migration_audit_test.go | 20 +-- backend/internal/store/store_test.go | 16 +-- frontend/src/api/mock.ts | 17 +-- frontend/src/api/types.ts | 5 + frontend/src/routes/guilds/Guilds.tsx | 2 +- 15 files changed, 267 insertions(+), 104 deletions(-) create mode 100644 backend/internal/store/migrations/011_base_names.sql diff --git a/backend/internal/sav/base_location_test.go b/backend/internal/sav/base_location_test.go index ee0de0a..d3a1443 100644 --- a/backend/internal/sav/base_location_test.go +++ b/backend/internal/sav/base_location_test.go @@ -4,13 +4,15 @@ import ( "encoding/binary" "math" "testing" + "unicode/utf16" ) // encodeBaseRawData builds a PalBaseCampSaveData.RawData blob in the proven // retail 1.x layout: id GUID, name fstring, state byte, FTransform (rotation // quaternion + translation + scale, all f64), area_range f32, group GUID, and -// some trailing bytes the decoder must ignore. -func encodeBaseRawData(idBytes [16]byte, name string, tx, ty, tz float64) []byte { +// some trailing bytes the decoder must ignore. wide selects the UTF-16 fstring +// encoding retail saves use for base names; false writes the ANSI form. +func encodeBaseRawData(idBytes [16]byte, name string, wide bool, tx, ty, tz float64) []byte { var b []byte f64 := func(v float64) { var buf [8]byte @@ -18,12 +20,25 @@ func encodeBaseRawData(idBytes [16]byte, name string, tx, ty, tz float64) []byte b = append(b, buf[:]...) } b = append(b, idBytes[:]...) // id GUID - // name: positive-length ANSI fstring (length includes the null terminator). var l [4]byte - binary.LittleEndian.PutUint32(l[:], uint32(len(name)+1)) - b = append(b, l[:]...) - b = append(b, name...) - b = append(b, 0) + if wide { + // UTF-16 fstring: negative unit count including the null terminator. + units := utf16.Encode([]rune(name)) + binary.LittleEndian.PutUint32(l[:], uint32(-(int32(len(units)) + 1))) + b = append(b, l[:]...) + for _, u := range units { + var ub [2]byte + binary.LittleEndian.PutUint16(ub[:], u) + b = append(b, ub[:]...) + } + b = append(b, 0, 0) + } else { + // ANSI fstring: positive byte length including the null terminator. + binary.LittleEndian.PutUint32(l[:], uint32(len(name)+1)) + b = append(b, l[:]...) + b = append(b, name...) + b = append(b, 0) + } b = append(b, 0x01) // state byte f64(0.0) // quat.x f64(0.0) // quat.y @@ -43,9 +58,10 @@ func encodeBaseRawData(idBytes [16]byte, name string, tx, ty, tz float64) []byte return b } -func TestBaseLocationDecodesTranslation(t *testing.T) { +func TestDecodeBaseRawTranslationAndName(t *testing.T) { id := [16]byte{0x00, 0xd9, 0x34, 0x5d, 0x4e, 0x43, 0xa4, 0xea, 0x24, 0x48, 0xe6, 0x99, 0xcf, 0xb5, 0x3c, 0x79} - raw := encodeBaseRawData(id, "拠点テンプレート", -304214.09, 227626.09, 2883.5) + // UTF-16 name, as retail saves store base names. + raw := encodeBaseRawData(id, "北の拠点 Alpha", true, -304214.09, 227626.09, 2883.5) // The embedded GUID as readGUID renders it is the canonical key; matching it // is part of the decoder's contract, so derive it the same way. @@ -54,46 +70,81 @@ func TestBaseLocationDecodesTranslation(t *testing.T) { t.Fatal(err) } - loc, ok := baseLocation(raw, key) + name, loc, ok := decodeBaseRaw(raw, key) if !ok { - t.Fatalf("baseLocation returned !ok for a well-formed blob") + t.Fatalf("decodeBaseRaw returned !ok for a well-formed blob") + } + if name != "北の拠点 Alpha" { + t.Fatalf("decoded name = %q, want %q", name, "北の拠点 Alpha") + } + if loc == nil { + t.Fatalf("decodeBaseRaw returned nil location for a well-formed blob") } if math.Abs(loc.X-(-304214.09)) > 1e-3 || math.Abs(loc.Y-227626.09) > 1e-3 || math.Abs(loc.Z-2883.5) > 1e-3 { t.Fatalf("decoded translation = (%.3f,%.3f,%.3f), want (-304214.09,227626.09,2883.5)", loc.X, loc.Y, loc.Z) } + // An ANSI-encoded name decodes identically. + ansiName, ansiLoc, ok := decodeBaseRaw(encodeBaseRawData(id, "East Camp", false, 1, 2, 3), key) + if !ok || ansiName != "East Camp" || ansiLoc == nil || ansiLoc.X != 1 { + t.Fatalf("ANSI blob = (%q,%v,%v)", ansiName, ansiLoc, ok) + } + // An empty baseID skips the key check and must still decode. - if loc2, ok := baseLocation(raw, ""); !ok || loc2.X != loc.X { - t.Fatalf("baseLocation with empty baseID = %v,%v", loc2, ok) + if name2, loc2, ok := decodeBaseRaw(raw, ""); !ok || name2 != name || loc2 == nil || loc2.X != loc.X { + t.Fatalf("decodeBaseRaw with empty baseID = (%q,%v,%v)", name2, loc2, ok) } } -func TestBaseLocationRejectsDrift(t *testing.T) { +func TestDecodeBaseRawRejectsDrift(t *testing.T) { id := [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} key, _ := readGUID(newReader(id[:])) - raw := encodeBaseRawData(id, "camp", 100, 200, 300) + raw := encodeBaseRawData(id, "camp", false, 100, 200, 300) - // A GUID that does not match the map key is treated as drift, not a location. - if _, ok := baseLocation(raw, "ffffffff-ffff-ffff-ffff-ffffffffffff"); ok { - t.Fatalf("baseLocation accepted a blob whose embedded GUID mismatched the key") + // A GUID that does not match the map key is treated as drift, not a base. + if _, _, ok := decodeBaseRaw(raw, "ffffffff-ffff-ffff-ffff-ffffffffffff"); ok { + t.Fatalf("decodeBaseRaw accepted a blob whose embedded GUID mismatched the key") + } + // A buffer truncated after the name but before the translation keeps the name + // and fails the location closed (nil), never reading garbage coordinates. + if name, loc, ok := decodeBaseRaw(raw[:40], key); !ok || name != "camp" || loc != nil { + t.Fatalf("truncated blob = (%q,%v,%v), want (camp,nil,true)", name, loc, ok) } - // A buffer truncated before the translation must fail closed, not read garbage. - if _, ok := baseLocation(raw[:40], key); ok { - t.Fatalf("baseLocation accepted a truncated blob") + // A buffer truncated inside the name fails the whole decode. + if _, _, ok := decodeBaseRaw(raw[:20], key); ok { + t.Fatalf("decodeBaseRaw accepted a blob truncated inside the name") } // Empty input must not panic and must fail closed. - if _, ok := baseLocation(nil, key); ok { - t.Fatalf("baseLocation accepted nil input") + if _, _, ok := decodeBaseRaw(nil, key); ok { + t.Fatalf("decodeBaseRaw accepted nil input") + } +} + +func TestNormalizeBaseName(t *testing.T) { + cases := map[string]string{ + "North Fort": "North Fort", + " North Fort ": "North Fort", + "": "", + " ": "", + "\t\n": "", + "新規生成拠点テンプレート名0(仮)": "", // engine placeholder, live-save shape + "新規生成拠点テンプレート名19(仮)": "", + "新規生成拠点テンプレート名": "", // prefix alone is still the placeholder + } + for in, want := range cases { + if got := normalizeBaseName(in); got != want { + t.Errorf("normalizeBaseName(%q) = %q, want %q", in, got, want) + } } } // TestBaseFromEntryDecodesRawDataPosition proves the property-tree path: a base // entry carrying only a RawData byte property (no plain Position/Location -// property, as retail 1.x saves are shaped) yields a decoded Position. +// property, as retail 1.x saves are shaped) yields a decoded Position and Name. func TestBaseFromEntryDecodesRawDataPosition(t *testing.T) { id := [16]byte{0xaa, 0xbb, 0xcc, 0xdd, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12} key, _ := readGUID(newReader(id[:])) - raw := encodeBaseRawData(id, "camp", -12345.5, 67890.25, -42) + raw := encodeBaseRawData(id, "Outpost 7", true, -12345.5, 67890.25, -42) entry := mapEntry{ Key: key, Value: propertyMap{ @@ -108,6 +159,17 @@ func TestBaseFromEntryDecodesRawDataPosition(t *testing.T) { if base.Position.X != -12345.5 || base.Position.Y != 67890.25 || base.Position.Z != -42 { t.Fatalf("baseFromEntry Position = %+v, want (-12345.5,67890.25,-42)", *base.Position) } + if base.Name != "Outpost 7" { + t.Fatalf("baseFromEntry Name = %q, want %q", base.Name, "Outpost 7") + } + + // The engine's unnamed placeholder collapses to "" (served as null upstream). + unnamed := baseFromEntry(mapEntry{Key: key, Value: propertyMap{ + "RawData": &property{Value: encodeBaseRawData(id, "新規生成拠点テンプレート名3(仮)", true, 1, 2, 3)}, + }}, &stats) + if unnamed.Name != "" { + t.Fatalf("placeholder base name = %q, want empty", unnamed.Name) + } // A base whose RawData cannot be decoded must yield a nil Position (served as // null), never a zero vector, and record the tolerated skip. @@ -116,6 +178,9 @@ func TestBaseFromEntryDecodesRawDataPosition(t *testing.T) { if bad.Position != nil { t.Fatalf("undecodable RawData produced a non-nil Position %+v", *bad.Position) } + if bad.Name != "" { + t.Fatalf("undecodable RawData produced a name %q", bad.Name) + } if badStats.SkippedProperties == 0 { t.Fatalf("undecodable base transform was not recorded as a tolerated skip") } diff --git a/backend/internal/sav/types.go b/backend/internal/sav/types.go index 359999d..cce1826 100644 --- a/backend/internal/sav/types.go +++ b/backend/internal/sav/types.go @@ -116,10 +116,15 @@ type GuildMember struct { LastOnline int64 `json:"lastOnline,omitempty"` } -// BaseCamp describes base information available without decoding its RawData. +// BaseCamp describes one BaseCampSaveData entry. type BaseCamp struct { - ID string `json:"id"` - GuildID string `json:"guildId,omitempty"` + ID string `json:"id"` + GuildID string `json:"guildId,omitempty"` + // Name is the player-chosen base name decoded from RawData, normalized by + // normalizeBaseName: empty when the base was never renamed (whitespace-only + // names and the engine's placeholder template both count as unnamed). Empty + // is served as null by the API, never a synthetic label. + Name string `json:"name,omitempty"` Position *Vector `json:"position,omitempty"` // WorkerContainerID is decoded from WorkerDirector.RawData and retained only // for internal joins. Public projections expose BaseID, never this raw GUID. diff --git a/backend/internal/sav/world.go b/backend/internal/sav/world.go index e321d36..4589b99 100644 --- a/backend/internal/sav/world.go +++ b/backend/internal/sav/world.go @@ -133,12 +133,16 @@ func baseFromEntry(e mapEntry, stats *ParseStats) BaseCamp { } if v, ok := asProperties(e.Value); ok { b.GuildID = firstString(v, "GroupIdBelongTo", "GroupID", "GuildId", "GuildID") - // The base's world transform lives inside PalBaseCampSaveData.RawData; it is - // not exposed as an ordinary Position/Location property, so decode it from the - // raw bytes. A pre-1.0 save that instead carries a plain vector property is + // The base's name and world transform live inside PalBaseCampSaveData.RawData; + // neither is exposed as an ordinary property, so decode them from the raw + // bytes. A pre-1.0 save that instead carries a plain vector property is // still honored as a fallback. if raw, ok := propertyBytes(v, "RawData"); ok { - if loc, ok := baseLocation(raw, b.ID); ok { + name, loc, ok := decodeBaseRaw(raw, b.ID) + if ok { + b.Name = normalizeBaseName(name) + } + if loc != nil { b.Position = loc } else { stats.recordSkip("worldSaveData.BaseCampSaveData.Value.RawData.transform", "tolerated") @@ -158,11 +162,11 @@ func baseFromEntry(e mapEntry, stats *ParseStats) BaseCamp { return b } -// baseLocation decodes the world-space translation of a base camp from -// PalBaseCampSaveData.RawData. The proven retail 1.x prefix is: +// decodeBaseRaw decodes the name and world-space translation of a base camp +// from PalBaseCampSaveData.RawData. The proven retail 1.x prefix is: // // id GUID (16 bytes) — must match the map key -// name fstring +// name fstring (UTF-16 in retail saves) // state 1 byte (EPalBaseCampWorkerStateType) // transform FTransform: rotation quaternion (4 f64) + translation // (3 f64) + scale3d (3 f64); modern 1.x saves store each @@ -171,40 +175,61 @@ func baseFromEntry(e mapEntry, stats *ParseStats) BaseCamp { // group_id_belong_to GUID // ... (worker/module data this decoder ignores) // -// Only the translation is needed. Any structural drift — short buffer, an -// embedded GUID that does not match the map key, a read error, or a non-finite -// or implausibly large component — yields (nil,false) so the caller serves a -// null location rather than a misleading (0,0). Verified against a live 1.0 -// world: all 20 bases decoded to within <1 cm of the guild's in-game PalBox. -func baseLocation(raw []byte, baseID string) (*Vector, bool) { +// ok reports whether the structural prefix (GUID + name) decoded; the raw name +// is returned as stored (normalizeBaseName decides what is displayable). The +// location is nil — served as null, never a misleading (0,0) — on any +// structural drift past the name: a short buffer, a read error, or a +// non-finite/implausibly large component. A GUID that does not match the map +// key fails the whole decode. Verified against a live 1.0 world: all 20 bases +// decoded to within <1 cm of the guild's in-game PalBox. +func decodeBaseRaw(raw []byte, baseID string) (name string, loc *Vector, ok bool) { r := newReader(raw) embedded, err := readGUID(r) if err != nil || (baseID != "" && !strings.EqualFold(embedded, baseID)) { - return nil, false + return "", nil, false } - if _, err = r.fstring(); err != nil { // name - return nil, false + if name, err = r.fstring(); err != nil { + return "", nil, false } // state byte, then the rotation quaternion (4 f64) we do not need. if err = r.skip(1 + 4*8); err != nil { - return nil, false + return name, nil, true } x, err := r.f64() if err != nil { - return nil, false + return name, nil, true } y, err := r.f64() if err != nil { - return nil, false + return name, nil, true } z, err := r.f64() if err != nil { - return nil, false + return name, nil, true } if !finiteBaseCoord(x) || !finiteBaseCoord(y) || !finiteBaseCoord(z) { - return nil, false + return name, nil, true + } + return name, &Vector{X: x, Y: y, Z: z}, true +} + +// baseNamePlaceholderPrefix is the engine-side default written into every base +// camp the player never renamed: "新規生成拠点テンプレート名(仮)" — literally +// "newly generated base template name (tentative)". Palworld writes this +// placeholder regardless of the server's locale (the in-game UI substitutes a +// localized label), so it is not a player-chosen name and must not be shown. +const baseNamePlaceholderPrefix = "新規生成拠点テンプレート名" + +// normalizeBaseName maps a raw stored base name to its displayable form: empty +// when the base is effectively unnamed. Whitespace-only names and the engine's +// untranslated placeholder template collapse to "" so every downstream surface +// can apply one rule — empty means absent means null, never a synthetic value. +func normalizeBaseName(name string) string { + name = strings.TrimSpace(name) + if strings.HasPrefix(name, baseNamePlaceholderPrefix) { + return "" } - return &Vector{X: x, Y: y, Z: z}, true + return name } // finiteBaseCoord rejects NaN, infinities, and coordinates far outside any diff --git a/backend/internal/server/integration.go b/backend/internal/server/integration.go index c7b3ca5..7e1a6a3 100644 --- a/backend/internal/server/integration.go +++ b/backend/internal/server/integration.go @@ -416,7 +416,9 @@ type integrationLocationView struct { Y float64 `json:"y"` } type integrationBaseView struct { - ID string `json:"id"` + ID string `json:"id"` + // Name is null when the base was never renamed; never "" or a placeholder. + Name *string `json:"name"` Location *integrationLocationView `json:"location"` Level int `json:"level"` } @@ -659,7 +661,11 @@ func (s *Server) integrationGuilds(w http.ResponseWriter, r *http.Request) { if b.HasLocation { location = &integrationLocationView{X: b.X, Y: b.Y} } - bases = append(bases, integrationBaseView{ID: b.ID, Location: location, Level: b.Level}) + var name *string // null, not "", for an unnamed base. + if b.Name != "" { + name = &b.Name + } + bases = append(bases, integrationBaseView{ID: b.ID, Name: name, Location: location, Level: b.Level}) } views = append(views, integrationGuildView{ID: g.ID, Name: g.Name, AdminUID: g.AdminUID, MemberCount: len(members), Members: members, Bases: bases}) } diff --git a/backend/internal/server/integration_data_audit_test.go b/backend/internal/server/integration_data_audit_test.go index a59a69a..5038e20 100644 --- a/backend/internal/server/integration_data_audit_test.go +++ b/backend/internal/server/integration_data_audit_test.go @@ -389,7 +389,7 @@ func TestAuditRedactionKeySetsExact(t *testing.T) { } for _, b := range guild["bases"].([]any) { bm := b.(map[string]any) - auditAssertKeys(t, "guild base", bm, "id", "location", "level") + auditAssertKeys(t, "guild base", bm, "id", "name", "location", "level") auditAssertKeys(t, "base location", bm["location"].(map[string]any), "x", "y") } } diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index de9f477..b2d4a2f 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -215,7 +215,7 @@ "PlayerPaldeckCoverage": {"type":"object","required":["source","captureCountsAvailable","unlockFlagsAvailable","captureCountsTruncated","unlockFlagsTruncated","captureObservedAt","unlockObservedAt"],"properties":{"source":{"type":"string","const":"player_save_record_data"},"captureCountsAvailable":{"type":"boolean"},"unlockFlagsAvailable":{"type":"boolean"},"captureCountsTruncated":{"type":"boolean"},"unlockFlagsTruncated":{"type":"boolean"},"captureObservedAt":{"type":["string","null"],"format":"date-time"},"unlockObservedAt":{"type":["string","null"],"format":"date-time"}}}, "PlayerPaldeckSpecies": {"type":"object","required":["characterId","displayName","known","captureCount","unlocked"],"properties":{"characterId":{"type":"string"},"displayName":{"type":"string"},"known":{"type":"boolean"},"captureCount":{"type":["integer","null"],"format":"int64","minimum":0},"unlocked":{"type":["boolean","null"]}}}, "PlayerPaldeck": {"type":"object","required":["player","coverage","catalog","captureTotal","uniquePalsCaptured","paldeckUnlocked","species"],"properties":{"player":{"type":"object","required":["uid","name"],"properties":{"uid":{"type":"string"},"name":{"type":"string"}}},"coverage":{"$ref":"#/components/schemas/PlayerPaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PlayerPaldeckSpecies"}}}}, - "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","location","level","palCount"],"properties":{"id":{"type":"string"},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, + "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","name","location","level","palCount"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, "Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}}, "BackupEntry": {"type": "object", "required": ["path", "sizeBytes", "modifiedAt"], "properties": {"path": {"type": "string"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "modifiedAt": {"type": "string", "format": "date-time"}}}, "BackupChange": {"type": "object", "required": ["path", "kind"], "properties": {"path": {"type": "string"}, "kind": {"type": "string", "enum": ["add", "modify", "delete"]}, "fromSize": {"type": "integer", "format": "int64", "minimum": 0}, "toSize": {"type": "integer", "format": "int64", "minimum": 0}}}, @@ -237,7 +237,7 @@ "IntegrationPalListItem": {"allOf": [{"$ref": "#/components/schemas/IntegrationPal"}, {"type": "object", "required": ["ownerUid", "ownerName", "ownerSource", "ownerResolved"], "properties": {"ownerUid": {"type": "string"}, "ownerName": {"type": "string", "description": "Empty string when ownership is unresolved or the joined player has no name."}, "ownerSource": {"type": "string", "enum": ["save", "personal_container", "last_observed", "unresolved"], "description": "How player attribution was established. last_observed is historical attribution while a Pal is outside a personal container; it is not proof of current possession."}, "ownerResolved": {"type": "boolean", "description": "True when ownerUid joins a current public player row. Consult ownerSource for attribution certainty."}}}]}, "IntegrationGuildMember": {"type": "object", "required": ["uid", "name"], "properties": {"uid": {"type": "string"}, "name": {"type": "string"}}}, "IntegrationLocation": {"type": "object", "required": ["x", "y"], "properties": {"x": {"type": "number"}, "y": {"type": "number"}}}, - "IntegrationBase": {"type": "object", "required": ["id", "location", "level"], "properties": {"id": {"type": "string"}, "location": {"oneOf": [{"$ref": "#/components/schemas/IntegrationLocation"}, {"type": "null"}]}, "level": {"type": "integer"}}}, + "IntegrationBase": {"type": "object", "required": ["id", "name", "location", "level"], "properties": {"id": {"type": "string"}, "name": {"type": ["string", "null"]}, "location": {"oneOf": [{"$ref": "#/components/schemas/IntegrationLocation"}, {"type": "null"}]}, "level": {"type": "integer"}}}, "IntegrationGuild": {"type": "object", "required": ["id", "name", "adminUid", "memberCount", "members", "bases"], "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "adminUid": {"type": "string"}, "memberCount": {"type": "integer"}, "members": {"type": "array", "items": {"$ref": "#/components/schemas/IntegrationGuildMember"}}, "bases": {"type": "array", "items": {"$ref": "#/components/schemas/IntegrationBase"}}}}, "IntegrationMapTransform": {"type": "object", "required": ["a", "b", "c", "d"], "properties": {"a": {"type": "number"}, "b": {"type": "number"}, "c": {"type": "number"}, "d": {"type": "number"}}}, "IntegrationMapLayer": {"type": "object", "required": ["id", "minZoom", "maxZoom"], "properties": {"id": {"type": "string"}, "label": {"type": "string"}, "format": {"type": "string"}, "tileSize": {"type": "integer"}, "minZoom": {"type": "integer"}, "maxZoom": {"type": "integer"}, "transform": {"$ref": "#/components/schemas/IntegrationMapTransform"}, "bounds": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2}}}, diff --git a/backend/internal/store/base_location_null_test.go b/backend/internal/store/base_location_null_test.go index 96a504f..92be19d 100644 --- a/backend/internal/store/base_location_null_test.go +++ b/backend/internal/store/base_location_null_test.go @@ -29,8 +29,8 @@ func TestBaseNullLocationStaysNull(t *testing.T) { Players: []sav.Player{{UID: member, Nickname: "Member", GuildID: guildID}}, Guilds: []sav.Guild{{ID: guildID, Name: "Guild", AdminUID: member, Members: []sav.GuildMember{{UID: member, Name: "Member"}}}}, Bases: []sav.BaseCamp{ - {ID: placed, GuildID: guildID, Position: &sav.Vector{X: 100, Y: 200, Z: 300}}, - {ID: unplaced, GuildID: guildID, Position: nil}, // transform never decoded + {ID: placed, GuildID: guildID, Name: "North Fort", Position: &sav.Vector{X: 100, Y: 200, Z: 300}}, + {ID: unplaced, GuildID: guildID, Position: nil}, // transform never decoded, never renamed }, } if err = s.ReplaceWorld(ctx, world, now, 0); err != nil { @@ -42,15 +42,21 @@ func TestBaseNullLocationStaysNull(t *testing.T) { if err != nil { t.Fatal(err) } - byID := map[string]*GuildDetailLocation{} + byID := map[string]GuildDetailBase{} for _, b := range detail.Bases { - byID[b.ID] = b.Location + byID[b.ID] = b } - if loc := byID[placed]; loc == nil || loc.X != 100 || loc.Y != 200 { + if loc := byID[placed].Location; loc == nil || loc.X != 100 || loc.Y != 200 { t.Fatalf("placed base detail location = %#v, want {100,200}", loc) } - if loc, ok := byID[unplaced]; !ok || loc != nil { - t.Fatalf("unplaced base detail location = %#v, want present-but-null", loc) + if name := byID[placed].Name; name == nil || *name != "North Fort" { + t.Fatalf("placed base detail name = %#v, want North Fort", name) + } + if b, ok := byID[unplaced]; !ok || b.Location != nil { + t.Fatalf("unplaced base detail location = %#v, want present-but-null", b.Location) + } + if name := byID[unplaced].Name; name != nil { + t.Fatalf("unnamed base detail name = %q, want null (never a synthetic label)", *name) } // Typed integration surface: HasLocation distinguishes null from (0,0). @@ -65,7 +71,7 @@ func TestBaseNullLocationStaysNull(t *testing.T) { for _, b := range guilds[0].Bases { switch b.ID { case placed: - if !b.HasLocation || b.X != 100 || b.Y != 200 { + if !b.HasLocation || b.X != 100 || b.Y != 200 || b.Name != "North Fort" { t.Fatalf("placed GuildBase = %#v", b) } seen++ @@ -73,6 +79,9 @@ func TestBaseNullLocationStaysNull(t *testing.T) { if b.HasLocation { t.Fatalf("unplaced GuildBase reported HasLocation; must be null") } + if b.Name != "" { + t.Fatalf("unnamed GuildBase carries name %q", b.Name) + } seen++ } } @@ -91,7 +100,8 @@ func TestBaseNullLocationStaysNull(t *testing.T) { } var decoded []struct { Bases []struct { - ID string `json:"id"` + ID string `json:"id"` + Name *string `json:"name"` Location *struct { X, Y float64 } `json:"location"` @@ -104,11 +114,11 @@ func TestBaseNullLocationStaysNull(t *testing.T) { t.Fatalf("GuildJSON guilds = %d, want 1", len(decoded)) } for _, b := range decoded[0].Bases { - if b.ID == unplaced && b.Location != nil { - t.Fatalf("GuildJSON unplaced base location = %#v, want null", b.Location) + if b.ID == unplaced && (b.Location != nil || b.Name != nil) { + t.Fatalf("GuildJSON unplaced base = location %#v name %#v, want both null", b.Location, b.Name) } - if b.ID == placed && (b.Location == nil || b.Location.X != 100) { - t.Fatalf("GuildJSON placed base location = %#v, want {100,200}", b.Location) + if b.ID == placed && (b.Location == nil || b.Location.X != 100 || b.Name == nil || *b.Name != "North Fort") { + t.Fatalf("GuildJSON placed base = location %#v name %#v", b.Location, b.Name) } } } diff --git a/backend/internal/store/guild_detail.go b/backend/internal/store/guild_detail.go index f34955b..b58571a 100644 --- a/backend/internal/store/guild_detail.go +++ b/backend/internal/store/guild_detail.go @@ -31,7 +31,10 @@ type GuildDetailMember struct { } type GuildDetailBase struct { - ID string `json:"id"` + ID string `json:"id"` + // Name is null when the base was never renamed (or the save predates name + // decoding); consumers fall back to a positional "Base N" label. + Name *string `json:"name"` Location *GuildDetailLocation `json:"location"` Level int `json:"level"` PalCount int `json:"palCount"` @@ -130,17 +133,21 @@ FROM guild_members gm LEFT JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_ } result.MemberCount = len(result.Members) - baseRows, err := s.db.QueryContext(ctx, `SELECT b.id,b.x,b.y,b.level,COUNT(p.instance_id) FROM bases b LEFT JOIN pals p ON p.base_id=b.id WHERE b.guild_id=? GROUP BY b.id,b.x,b.y,b.level ORDER BY b.id`, guildID) + baseRows, err := s.db.QueryContext(ctx, `SELECT b.id,b.name,b.x,b.y,b.level,COUNT(p.instance_id) FROM bases b LEFT JOIN pals p ON p.base_id=b.id WHERE b.guild_id=? GROUP BY b.id,b.name,b.x,b.y,b.level ORDER BY b.id`, guildID) if err != nil { return GuildDetail{}, err } for baseRows.Next() { var base GuildDetailBase + var name sql.NullString var x, y sql.NullFloat64 - if err = baseRows.Scan(&base.ID, &x, &y, &base.Level, &base.PalCount); err != nil { + if err = baseRows.Scan(&base.ID, &name, &x, &y, &base.Level, &base.PalCount); err != nil { baseRows.Close() return GuildDetail{}, err } + if name.Valid && name.String != "" { + base.Name = &name.String + } if x.Valid && y.Valid { base.Location = &GuildDetailLocation{X: x.Float64, Y: y.Float64} } diff --git a/backend/internal/store/migrations/011_base_names.sql b/backend/internal/store/migrations/011_base_names.sql new file mode 100644 index 0000000..bd6c3c9 --- /dev/null +++ b/backend/internal/store/migrations/011_base_names.sql @@ -0,0 +1,24 @@ +-- 011: player-chosen base camp names, decoded from BaseCampSaveData.RawData. +-- NULL means the base was never renamed (or the row predates name decoding); +-- the API serves NULL, never "" or a synthetic label. Rows written before this +-- migration stay NULL until the next save parse repopulates the table. +-- +-- Rebuild-table pattern (like 004) rather than a bare ALTER ADD COLUMN so an +-- interrupted-migration replay stays idempotent: replaying this file on a +-- database that already carries the column succeeds instead of failing on a +-- duplicate column. +CREATE TABLE IF NOT EXISTS bases (id TEXT PRIMARY KEY, guild_id TEXT, x REAL, y REAL, level INTEGER NOT NULL DEFAULT 0); + +DROP TABLE IF EXISTS bases_v011; +CREATE TABLE bases_v011 ( + id TEXT PRIMARY KEY, + guild_id TEXT, + name TEXT, + x REAL, + y REAL, + level INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO bases_v011(id,guild_id,x,y,level) +SELECT id,guild_id,x,y,level FROM bases; +DROP TABLE bases; +ALTER TABLE bases_v011 RENAME TO bases; diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 2be07b7..2f6e4e0 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1012,12 +1012,15 @@ func (s *Store) ReplaceWorld(ctx context.Context, w *sav.World, at time.Time, d return err } for _, b := range w.Bases { - var x, y any + var x, y, name any if b.Position != nil { x = b.Position.X y = b.Position.Y } - _, err = tx.ExecContext(ctx, "INSERT INTO bases(id,guild_id,x,y) VALUES(?,?,?,?)", NormalizeUID(b.ID), NormalizeUID(b.GuildID), x, y) + if b.Name != "" { // unnamed stays NULL, never "" or a synthetic label + name = b.Name + } + _, err = tx.ExecContext(ctx, "INSERT INTO bases(id,guild_id,name,x,y) VALUES(?,?,?,?,?)", NormalizeUID(b.ID), NormalizeUID(b.GuildID), name, x, y) if err != nil { return err } @@ -1159,15 +1162,16 @@ func (s *Store) GuildJSON(ctx context.Context) ([]map[string]any, error) { } mr.Close() bases := []map[string]any{} - br, e := s.db.QueryContext(ctx, "SELECT id,x,y,level FROM bases WHERE guild_id=?", g.id) + br, e := s.db.QueryContext(ctx, "SELECT id,name,x,y,level FROM bases WHERE guild_id=?", g.id) if e != nil { return nil, e } for br.Next() { var bid string + var baseName sql.NullString var x, y sql.NullFloat64 var level int - if e = br.Scan(&bid, &x, &y, &level); e != nil { + if e = br.Scan(&bid, &baseName, &x, &y, &level); e != nil { br.Close() return nil, e } @@ -1175,7 +1179,11 @@ func (s *Store) GuildJSON(ctx context.Context) ([]map[string]any, error) { if x.Valid && y.Valid { location = map[string]any{"x": x.Float64, "y": y.Float64} } - bases = append(bases, map[string]any{"id": bid, "location": location, "level": level}) + var name any // null, never "" or a synthetic label, for an unnamed base. + if baseName.Valid && baseName.String != "" { + name = baseName.String + } + bases = append(bases, map[string]any{"id": bid, "name": name, "location": location, "level": level}) } br.Close() out = append(out, map[string]any{"id": g.id, "name": g.name, "adminUid": g.admin, "memberCount": len(members), "members": members, "bases": bases}) @@ -1199,8 +1207,11 @@ type GuildMember struct{ UID, Name string } // GuildBase is one persistent guild-owned base. HasLocation is false when the // base transform was never decoded (a pre-decoding save); X and Y are then zero // and must be surfaced as a null location rather than a misleading (0,0). +// Name is empty when the base was never renamed (or the save predates name +// decoding) and must likewise be surfaced as null, never a synthetic label. type GuildBase struct { ID string + Name string X, Y float64 HasLocation bool Level int @@ -1242,17 +1253,19 @@ func (s *Store) Guilds(ctx context.Context) ([]Guild, error) { if e = mr.Close(); e != nil { return nil, e } - br, e := s.db.QueryContext(ctx, "SELECT id,x,y,level FROM bases WHERE guild_id=?", g.ID) + br, e := s.db.QueryContext(ctx, "SELECT id,name,x,y,level FROM bases WHERE guild_id=?", g.ID) if e != nil { return nil, e } for br.Next() { var b GuildBase + var name sql.NullString var x, y sql.NullFloat64 - if e = br.Scan(&b.ID, &x, &y, &b.Level); e != nil { + if e = br.Scan(&b.ID, &name, &x, &y, &b.Level); e != nil { br.Close() return nil, e } + b.Name = name.String b.X, b.Y = x.Float64, y.Float64 b.HasLocation = x.Valid && y.Valid g.Bases = append(g.Bases, b) diff --git a/backend/internal/store/store_migration_audit_test.go b/backend/internal/store/store_migration_audit_test.go index 84a1ef8..f144dd9 100644 --- a/backend/internal/store/store_migration_audit_test.go +++ b/backend/internal/store/store_migration_audit_test.go @@ -132,8 +132,8 @@ func TestAuditUpgradeV030RealVolumeReadableThroughBothSurfaces(t *testing.T) { } defer st.Close() ctx := context.Background() - if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "10" { - t.Fatalf("schema_version = %q, %v; want 10", v, err) + if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "11" { + t.Fatalf("schema_version = %q, %v; want 11", v, err) } if v, err := st.GetKV(ctx, "operator-note"); err != nil || v != "preserve-me" { t.Fatalf("operator kv = %q, %v", v, err) @@ -293,8 +293,8 @@ func TestAuditInterruptedMigrationReplayPreservesData(t *testing.T) { t.Fatalf("reopen after simulated interrupted migration: %v", err) } defer reopened.Close() - if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { - t.Fatalf("schema_version after replay = %q, %v; want repaired to 10", v, err) + if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "11" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 11", v, err) } keys, err := reopened.ListAPIKeys(ctx) if err != nil || len(keys) != 1 || keys[0].ID != "aaaa1111" || keys[0].Label != "survives-replay" { @@ -310,7 +310,7 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { t.Run("future version names both numbers", func(t *testing.T) { path := filepath.Join(t.TempDir(), "future.db") legacy := buildV030Database(t, path) - if _, err := legacy.Exec(`UPDATE kv SET value='11' WHERE key='schema_version'`); err != nil { + if _, err := legacy.Exec(`UPDATE kv SET value='12' WHERE key='schema_version'`); err != nil { t.Fatal(err) } if err := legacy.Close(); err != nil { @@ -318,9 +318,9 @@ func TestAuditSchemaVersionFailClosedMessageAndCorruptValue(t *testing.T) { } _, err := Open(path) if err == nil { - t.Fatal("Open on schema_version 11 succeeded") + t.Fatal("Open on schema_version 12 succeeded") } - for _, needle := range []string{"11", "10", "newer than this binary supports"} { + for _, needle := range []string{"12", "11", "newer than this binary supports"} { if !strings.Contains(err.Error(), needle) { t.Errorf("fail-closed error %q does not mention %q", err, needle) } @@ -403,9 +403,9 @@ func TestAuditConcurrentOpenSameFile(t *testing.T) { if err != nil { t.Fatalf("round %d: reopen after concurrent Open: %v", round, err) } - if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "10" { + if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "11" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 10", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 11", round, v, err) } if _, err := st.ListAPIKeys(context.Background()); err != nil { st.Close() @@ -527,7 +527,7 @@ func TestAuditDowngradeV04DatabaseUnderV03OpenSemantics(t *testing.T) { t.Fatalf("v0.3 binary cannot open a v0.4 database (001 re-execution failed): %v", err) } var v string - if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "10" { + if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "11" { t.Fatalf("schema_version after v0.3-style open = %q, %v; INSERT OR IGNORE must not clobber it", v, err) } var name string diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index ef6eddc..8856e27 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -612,8 +612,8 @@ func TestMigration004AddsNullablePalPlacementColumns(t *testing.T) { t.Fatal(err) } defer st.Close() - if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "10" { - t.Fatalf("schema_version = %q, %v; want 10", v, getErr) + if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "11" { + t.Fatalf("schema_version = %q, %v; want 11", v, getErr) } pals, err := st.PalsTyped(context.Background(), "owner") if err != nil || len(pals) != 1 { @@ -672,8 +672,8 @@ func TestFreshDatabaseReachesLatestSchemaAndAPIKeysUsable(t *testing.T) { defer s.Close() ctx := context.Background() v, err := s.GetKV(ctx, "schema_version") - if err != nil || v != "10" { - t.Fatalf("schema_version = %q, %v; want 10", v, err) + if err != nil || v != "11" { + t.Fatalf("schema_version = %q, %v; want 11", v, err) } hash := [32]byte{1, 2, 3} created, err := s.CreateAPIKey(ctx, "abcd1234", hash, "fresh-db-key", time.Now()) @@ -739,8 +739,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { ctx := context.Background() v, err := upgraded.GetKV(ctx, "schema_version") - if err != nil || v != "10" { - t.Fatalf("schema_version after upgrade = %q, %v; want 10", v, err) + if err != nil || v != "11" { + t.Fatalf("schema_version after upgrade = %q, %v; want 11", v, err) } if _, err = upgraded.ListAPIKeys(ctx); err != nil { t.Fatalf("api_keys table not usable after upgrade: %v", err) @@ -769,8 +769,8 @@ func TestUpgradeFromV030SchemaAppliesAPIKeysMigration(t *testing.T) { t.Fatalf("second Open (already at latest version) returned an error: %v", err) } defer reopened.Close() - if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "10" { - t.Fatalf("schema_version after no-op reopen = %q, %v; want 10", v, err) + if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "11" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 11", v, err) } } diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index be8b301..5f9c774 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -229,17 +229,19 @@ const players: Player[] = [ const guildNames = ["Nightloom", "Driftbone", "Cinderwake", "Palisade", "Thornmere", "Greywatch", "Amberfen"]; // Base spots in in-game display coords (roughly matching the mockup marker layout). -const baseSpots: Record = { +// name mirrors the API: null when the base was never renamed in-game (the +// common case), so mock mode exercises the "Base N" fallback alongside real names. +const baseSpots: Record = { "g-nightloom": [ - { x: -660, y: 490 }, - { x: -80, y: -430 }, + { x: -660, y: 490, name: "Nightloom HQ" }, + { x: -80, y: -430, name: null }, ], "g-driftbone": [ - { x: 430, y: 370 }, - { x: 610, y: -160 }, + { x: 430, y: 370, name: null }, + { x: 610, y: -160, name: "Coal Ridge" }, ], - "g-cinderwake": [{ x: -300, y: -640 }], - "g-palisade": [{ x: 250, y: 720 }], + "g-cinderwake": [{ x: -300, y: -640, name: null }], + "g-palisade": [{ x: 250, y: 720, name: null }], }; const guilds: Guild[] = guildNames.map((name, i) => { const id = `g-${name.toLowerCase()}`; @@ -253,6 +255,7 @@ const guilds: Guild[] = guildNames.map((name, i) => { members, bases: spots.map((spot, b) => ({ id: `${id}-base-${b}`, + name: spot.name, location: gameToWorld(spot.x, spot.y), level: 3 + ((i + b) % 5), })), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index a6f724a..b8b685f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -247,6 +247,9 @@ export interface WhitelistEntry { // ---------- Guilds ---------- export interface GuildBase { id: string; + // null when the base was never renamed by a player (the game's default + // placeholder counts as unnamed); fall back to a positional label. + name: string | null; // null when the base's world transform was never decoded (a pre-decoding // save). Consumers must treat this as "location unavailable", never (0,0). location: { x: number; y: number } | null; @@ -284,6 +287,8 @@ export interface GuildDetailMember { export interface GuildDetailBase { id: string; + // null when the base was never renamed; render a positional "Base N" label. + name: string | null; location: PlayerLocation | null; level: number; palCount: number; diff --git a/frontend/src/routes/guilds/Guilds.tsx b/frontend/src/routes/guilds/Guilds.tsx index 5a16851..fdae622 100644 --- a/frontend/src/routes/guilds/Guilds.tsx +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -86,7 +86,7 @@ function GuildDetailView({ guild }: { guild: GuildDetail }) { {guild.bases.map((base, index) => { const game = base.location ? worldToGame(base.location.x, base.location.y) : null; - return ; + return ; })}
BaseLevelPalsLocation
Base {index + 1}{base.level}{base.palCount}{game ? {game.x}, {game.y} : Unavailable}
{base.name ?? `Base ${index + 1}`}{base.level}{base.palCount}{game ? {game.x}, {game.y} : Unavailable}
From f4185586b3eeff1efd1a4b36a3c5105754b93b2c Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:00:45 -0500 Subject: [PATCH 13/14] Integrate cross-feature wiring after worktree merges - Map base markers and search now label bases with the base's own save name, falling back to the guild display label (including the unnamed-guild member fallback) - Guild detail pals carry the condenser rank end to end (store query, openapi, mock, types) and render the shared PalStars component - markerKind reads the search target's own kind now that cluster points can also be workers Co-Authored-By: Claude Fable 5 --- backend/internal/server/openapi.json | 2 +- backend/internal/store/guild_detail.go | 5 +++-- frontend/src/api/mock.ts | 1 + frontend/src/api/types.ts | 2 ++ frontend/src/routes/guilds/Guilds.tsx | 3 ++- frontend/src/routes/map/Map.tsx | 11 ++++++++--- 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index 596dc9b..dfd161c 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -216,7 +216,7 @@ "PlayerPaldeckCoverage": {"type":"object","required":["source","captureCountsAvailable","unlockFlagsAvailable","captureCountsTruncated","unlockFlagsTruncated","captureObservedAt","unlockObservedAt"],"properties":{"source":{"type":"string","const":"player_save_record_data"},"captureCountsAvailable":{"type":"boolean"},"unlockFlagsAvailable":{"type":"boolean"},"captureCountsTruncated":{"type":"boolean"},"unlockFlagsTruncated":{"type":"boolean"},"captureObservedAt":{"type":["string","null"],"format":"date-time"},"unlockObservedAt":{"type":["string","null"],"format":"date-time"}}}, "PlayerPaldeckSpecies": {"type":"object","required":["characterId","displayName","known","captureCount","unlocked"],"properties":{"characterId":{"type":"string"},"displayName":{"type":"string"},"known":{"type":"boolean"},"captureCount":{"type":["integer","null"],"format":"int64","minimum":0},"unlocked":{"type":["boolean","null"]}}}, "PlayerPaldeck": {"type":"object","required":["player","coverage","catalog","captureTotal","uniquePalsCaptured","paldeckUnlocked","species"],"properties":{"player":{"type":"object","required":["uid","name"],"properties":{"uid":{"type":"string"},"name":{"type":"string"}}},"coverage":{"$ref":"#/components/schemas/PlayerPaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PlayerPaldeckSpecies"}}}}, - "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","name","location","level","palCount"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, + "GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","name","location","level","palCount"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","rank","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"rank":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"Pal Condenser rank: 1 (never condensed) through 5 (four stars). null when the save carried no Rank property; never inferred as 0."},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}}, "ServerInfo": {"type": "object", "required": ["name", "description", "version", "worldGuid", "state", "uptimeSec", "panelVersion", "sessionDays", "saveSyncMinutes"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "version": {"type": "string"}, "worldGuid": {"type": "string"}, "state": {"type": "string"}, "uptimeSec": {"type": "integer", "format": "int64"}, "panelVersion": {"type": "string"}, "sessionDays": {"type": "integer", "minimum": 1, "description": "Login session lifetime in whole days (PALHELM_SESSION_DAYS)."}, "saveSyncMinutes": {"type": "integer", "minimum": 0, "description": "Save-sync poll interval in whole minutes (PALHELM_SAVE_SYNC_INTERVAL)."}}}, "BackupStorage": {"type": "object", "required": ["totalBytes", "freeBytes"], "properties": {"totalBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Total capacity of the backup filesystem, or null when unavailable."}, "freeBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Free space on the backup filesystem, or null when unavailable."}}}, "Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}}, diff --git a/backend/internal/store/guild_detail.go b/backend/internal/store/guild_detail.go index b58571a..cd0e1c4 100644 --- a/backend/internal/store/guild_detail.go +++ b/backend/internal/store/guild_detail.go @@ -45,6 +45,7 @@ type GuildDetailPal struct { CharacterID string `json:"characterId"` DisplayName string `json:"displayName"` Level int `json:"level"` + Rank *int `json:"rank"` IsAlpha bool `json:"isAlpha"` IsLucky bool `json:"isLucky"` IsBoss bool `json:"isBoss"` @@ -161,7 +162,7 @@ FROM guild_members gm LEFT JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_ return GuildDetail{}, err } result.PalsTruncated = result.PalCount > maxGuildDetailPals - palRows, err := s.db.QueryContext(ctx, `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.is_alpha,p.is_lucky,p.in_party,p.box_page,p.base_id,p.owner_uid,COALESCE(owner.name,''),p.owner_source,owner.uid IS NOT NULL,b.id IS NOT NULL + palRows, err := s.db.QueryContext(ctx, `SELECT p.instance_id,p.character_id,p.display_name,p.level,p.rank,p.is_alpha,p.is_lucky,p.in_party,p.box_page,p.base_id,p.owner_uid,COALESCE(owner.name,''),p.owner_source,owner.uid IS NOT NULL,b.id IS NOT NULL FROM pals p LEFT JOIN players owner ON owner.uid=p.owner_uid LEFT JOIN guild_members gm ON gm.player_uid=p.owner_uid AND gm.guild_id=? @@ -176,7 +177,7 @@ ORDER BY p.display_name,p.instance_id LIMIT ?`, guildID, guildID, maxGuildDetail var inParty, hasBase bool var boxPage sql.NullInt64 var baseID string - if err = palRows.Scan(&pal.InstanceID, &pal.CharacterID, &pal.DisplayName, &pal.Level, &pal.IsAlpha, &pal.IsLucky, &inParty, &boxPage, &baseID, &pal.OwnerUID, &pal.OwnerName, &pal.OwnerSource, &pal.OwnerResolved, &hasBase); err != nil { + if err = palRows.Scan(&pal.InstanceID, &pal.CharacterID, &pal.DisplayName, &pal.Level, &pal.Rank, &pal.IsAlpha, &pal.IsLucky, &inParty, &boxPage, &baseID, &pal.OwnerUID, &pal.OwnerName, &pal.OwnerSource, &pal.OwnerResolved, &hasBase); err != nil { palRows.Close() return GuildDetail{}, err } diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index a931534..a446b23 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -710,6 +710,7 @@ export async function guildDetail(id: string): Promise { characterId: pal.characterId, displayName: pal.displayName, level: pal.level, + rank: pal.rank ?? null, isAlpha: pal.isAlpha, isLucky: pal.isLucky, isBoss: pal.characterId.toLowerCase().startsWith("boss_"), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 847f120..b8664dd 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -309,6 +309,8 @@ export interface GuildDetailPal { characterId: string; displayName: string; level: number; + /** Pal Condenser rank (1–5, stars = rank-1); null when the save carried no Rank property. */ + rank?: number | null; isAlpha: boolean; isLucky: boolean; isBoss: boolean; diff --git a/frontend/src/routes/guilds/Guilds.tsx b/frontend/src/routes/guilds/Guilds.tsx index fdae622..f85661a 100644 --- a/frontend/src/routes/guilds/Guilds.tsx +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -9,6 +9,7 @@ import { Banner } from "../../components/Banner"; import { Card, CardBody, CardHead } from "../../components/Card"; import { EmptyState } from "../../components/EmptyState"; import { PalIcon } from "../../components/PalIcon"; +import { PalStars } from "../../components/PalStars"; import { Pill } from "../../components/Pill"; import { palExplorerHref, palOwnerSummary, palSpecimenLabels } from "../pals/palExplorer"; import "./Guilds.css"; @@ -103,7 +104,7 @@ function GuildDetailView({ guild }: { guild: GuildDetail }) { const specimen = palSpecimenLabels(pal); return
-
{pal.displayName}Lv {pal.level} · {specimen.length ? specimen.map((label) => label === "Boss" ? "◆ Boss" : label).join(" · ") : "Standard"}{pal.association === "guild_base" ? "Guild base" : palOwnerSummary(pal)}
+
{pal.displayName}Lv {pal.level} · {specimen.length ? specimen.map((label) => label === "Boss" ? "◆ Boss" : label).join(" · ") : "Standard"}{pal.rank != null && pal.rank > 1 && <> · }{pal.association === "guild_base" ? "Guild base" : palOwnerSummary(pal)}
Roster
; })} diff --git a/frontend/src/routes/map/Map.tsx b/frontend/src/routes/map/Map.tsx index 307c24f..cd3ce5b 100644 --- a/frontend/src/routes/map/Map.tsx +++ b/frontend/src/routes/map/Map.tsx @@ -15,6 +15,7 @@ import { type MapPoint, } from "../../app/mapTransform"; import { formatRelativeToNow, formatWorldGuid } from "../../app/format"; +import { guildDisplayName } from "../../app/guildDisplay"; import { tileZoomForScale } from "../../app/mapTiles"; import { clusterMapMarkers, type ClusterMarkerGroup } from "../../app/mapClustering"; import { @@ -299,9 +300,11 @@ export default function MapRoute() { const playerMarkerSelection = selectPlayerMarkers(playersQuery.data ?? [], liveSnapshot); const playerMarkers = playerMarkerSelection.markers; // Bases without a decoded location cannot be plotted; drop them so every - // downstream base marker has a real (never (0,0)) position. + // downstream base marker has a real (never (0,0)) position. Labels prefer the + // base's own save name, then the guild's display label (with the unnamed-guild + // member fallback). const bases = (guildsQuery.data ?? []) - .flatMap((g) => g.bases.map((b) => ({ ...b, guildName: g.name }))) + .flatMap((g) => g.bases.map((b) => ({ ...b, guildName: b.name ?? guildDisplayName(g) }))) .filter((b): b is typeof b & { location: NonNullable } => b.location !== null); const liveMapActors = selectLiveMapActors(liveSnapshot); const workers = liveMapActors.workers; @@ -755,7 +758,9 @@ export default function MapRoute() { } function markerKind(group: ClusterMarkerGroup): "player" | "base" { - return group.type === "single" ? group.member.kind : group.members[0].kind; + // Read the target's own kind: the cluster point's kind field widened to include + // "worker", but MapMarkerGroup only ever receives player/base groups. + return group.type === "single" ? group.member.value.kind : group.members[0].value.kind; } function MapMarkerGroup({ From 22883860632cfcb28ad7b464792d885dd79fcc03 Mon Sep 17 00:00:00 2001 From: 8tp <79799382+8tp@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:19:37 -0500 Subject: [PATCH 14/14] fix(guilds): count a player's guild back-reference as membership evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA on the real save found a base-owning guild whose group roster decodes empty while two known players still reference it via their own guild_id — the list filter hid that guild and dropped its five base markers from the map. Membership evidence now counts from either direction: a roster member that resolves to a known player, or a known player pointing back at the guild. Placeholder organizations (no bases, no players either way) are still filtered. Co-Authored-By: Claude Fable 5 --- backend/internal/store/guild_list_test.go | 21 ++++++++++++++++----- backend/internal/store/store.go | 16 ++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/backend/internal/store/guild_list_test.go b/backend/internal/store/guild_list_test.go index e4dadd6..19c09dc 100644 --- a/backend/internal/store/guild_list_test.go +++ b/backend/internal/store/guild_list_test.go @@ -24,14 +24,21 @@ func TestGuildJSONListsOnlyGuildsWithBaseAndConfirmedMember(t *testing.T) { now := time.Date(2026, 7, 15, 12, 0, 0, 0, time.UTC) realMember := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + backrefPlayer := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ghostUID := "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" realGuild := "11111111111111111111111111111111" orgGuild := "22222222222222222222222222222222" ghostGuild := "33333333333333333333333333333333" emptyGuild := "44444444444444444444444444444444" + backrefGuild := "55555555555555555555555555555555" world := &sav.World{ - Players: []sav.Player{{UID: realMember, Nickname: "Member", GuildID: realGuild}}, + Players: []sav.Player{ + {UID: realMember, Nickname: "Member", GuildID: realGuild}, + // Real 1.0 saves carry base-owning guilds whose group roster decodes empty + // while the players themselves still point at the guild via guild_id. + {UID: backrefPlayer, Nickname: "Backref", GuildID: backrefGuild}, + }, Guilds: []sav.Guild{ // Real player guild: a base is placed and a confirmed player is a member. {ID: realGuild, Name: "Real Guild", AdminUID: realMember, Members: []sav.GuildMember{{UID: realMember, Name: "Member"}}}, @@ -41,11 +48,14 @@ func TestGuildJSONListsOnlyGuildsWithBaseAndConfirmedMember(t *testing.T) { {ID: ghostGuild, Name: "Ghost Group", AdminUID: ghostUID, Members: []sav.GuildMember{{UID: ghostUID, Name: ""}}}, // Group with a base but no members at all. {ID: emptyGuild, Name: "Empty Group", AdminUID: ""}, + // Base placed, empty group roster, but a known player references the guild. + {ID: backrefGuild, Name: "Backref Guild", AdminUID: ""}, }, Bases: []sav.BaseCamp{ {ID: "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1", GuildID: realGuild, Position: &sav.Vector{X: 1, Y: 2}}, {ID: "b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3b3", GuildID: ghostGuild, Position: &sav.Vector{X: 3, Y: 4}}, {ID: "b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4", GuildID: emptyGuild, Position: &sav.Vector{X: 5, Y: 6}}, + {ID: "b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5b5", GuildID: backrefGuild, Position: &sav.Vector{X: 7, Y: 8}}, }, } if err = s.ReplaceWorld(ctx, world, now, 0); err != nil { @@ -56,11 +66,12 @@ func TestGuildJSONListsOnlyGuildsWithBaseAndConfirmedMember(t *testing.T) { if err != nil { t.Fatal(err) } - if len(list) != 1 { - t.Fatalf("guild list = %d guilds, want 1: %#v", len(list), list) + if len(list) != 2 { + t.Fatalf("guild list = %d guilds, want 2: %#v", len(list), list) } - if list[0]["id"] != NormalizeUID(realGuild) || list[0]["name"] != "Real Guild" { - t.Fatalf("listed guild = %#v, want the real guild", list[0]) + listed := map[any]bool{list[0]["id"]: true, list[1]["id"]: true} + if !listed[NormalizeUID(realGuild)] || !listed[NormalizeUID(backrefGuild)] { + t.Fatalf("listed guilds = %#v, want the real guild and the player-backref guild", list) } // Every filtered-out guild must still resolve through the detail endpoint so a player diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index fb8e009..b5f1687 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -1127,13 +1127,17 @@ func (s *Store) WorldState(ctx context.Context) (WorldState, error) { // not player guilds — a solo player's auto-created organization and other non-guild // group types — and those decode into guild rows with no base placed and no member // whose save identity resolves to a known player. Requiring at least one placed base -// AND at least one member with a confirmed player identity drops those empty records -// so every panel consumer of the list (guilds page, players "Guilds" tab, dashboard -// count, map bases) agrees on the same set. The guild detail path deliberately does -// NOT apply this, so a player row can still open its guild even when the guild is -// filtered out of the list. +// AND at least one confirmed player drops those empty records so every panel consumer +// of the list (guilds page, players "Guilds" tab, dashboard count, map bases) agrees +// on the same set. Membership evidence counts from either direction: a group-roster +// member that resolves to a known player, or a known player whose own record points +// back at the guild — real 1.0 saves carry base-owning guilds with an empty group +// roster whose players still reference them via guild_id. The guild detail path +// deliberately does NOT apply this, so a player row can still open its guild even +// when the guild is filtered out of the list. const guildListRealFilter = `WHERE EXISTS (SELECT 1 FROM bases b WHERE b.guild_id=guilds.id) - AND EXISTS (SELECT 1 FROM guild_members gm JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_id=guilds.id)` + AND (EXISTS (SELECT 1 FROM guild_members gm JOIN players p ON p.uid=gm.player_uid WHERE gm.guild_id=guilds.id) + OR EXISTS (SELECT 1 FROM players p WHERE p.guild_id=guilds.id))` // GuildJSON returns API-ready guild objects including members and bases. Only guilds // with at least one placed base and one confirmed player member are listed; see