diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a1a8f4..619a5f4 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 @@ -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@v6 + - uses: actions/setup-node@v6 + 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/.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/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/sav/base_location_test.go b/backend/internal/sav/base_location_test.go new file mode 100644 index 0000000..d3a1443 --- /dev/null +++ b/backend/internal/sav/base_location_test.go @@ -0,0 +1,187 @@ +package sav + +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. 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 + binary.LittleEndian.PutUint64(buf[:], math.Float64bits(v)) + b = append(b, buf[:]...) + } + b = append(b, idBytes[:]...) // id GUID + var l [4]byte + 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 + 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 TestDecodeBaseRawTranslationAndName(t *testing.T) { + id := [16]byte{0x00, 0xd9, 0x34, 0x5d, 0x4e, 0x43, 0xa4, 0xea, 0x24, 0x48, 0xe6, 0x99, 0xcf, 0xb5, 0x3c, 0x79} + // 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. + key, err := readGUID(newReader(id[:])) + if err != nil { + t.Fatal(err) + } + + name, loc, ok := decodeBaseRaw(raw, key) + if !ok { + 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 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 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", false, 100, 200, 300) + + // 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 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 := 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 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, "Outpost 7", true, -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) + } + 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. + 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 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/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/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/testdata/World_1_0.gvas b/backend/internal/sav/testdata/World_1_0.gvas index 580f7ee..0359cc3 100644 Binary files a/backend/internal/sav/testdata/World_1_0.gvas and b/backend/internal/sav/testdata/World_1_0.gvas differ diff --git a/backend/internal/sav/types.go b/backend/internal/sav/types.go index f34a20e..e2a57e1 100644 --- a/backend/internal/sav/types.go +++ b/backend/internal/sav/types.go @@ -59,18 +59,33 @@ 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. 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"` @@ -107,10 +122,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 8de58a9..4589b99 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,32 @@ 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 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 { + 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") + } + } + 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 +162,84 @@ func baseFromEntry(e mapEntry) BaseCamp { return b } +// 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 (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 +// component as f64 +// area_range f32 +// group_id_belong_to GUID +// ... (worker/module data this decoder ignores) +// +// 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 + } + 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 name, nil, true + } + x, err := r.f64() + if err != nil { + return name, nil, true + } + y, err := r.f64() + if err != nil { + return name, nil, true + } + z, err := r.f64() + if err != nil { + return name, nil, true + } + if !finiteBaseCoord(x) || !finiteBaseCoord(y) || !finiteBaseCoord(z) { + 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 name +} + +// 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 @@ -302,6 +398,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 +417,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 +427,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/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/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/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/integration.go b/backend/internal/server/integration.go index 0123057..dbad57d 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"` @@ -416,9 +418,11 @@ 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"` + // 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"` } type integrationGuildView struct { ID string `json:"id"` @@ -614,7 +618,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), }) @@ -655,7 +659,15 @@ 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} + } + 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..fbd0608 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) } @@ -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 b14886c..dfd161c 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"}}}}, @@ -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": {"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"}}}}, "/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"}}}}, @@ -50,6 +53,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 +209,16 @@ }, "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","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"}}}, "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}}}, @@ -221,12 +235,12 @@ "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"}}}, "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", "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/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/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/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/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/server/server.go b/backend/internal/server/server.go index aa2a04f..4864776 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) @@ -178,9 +179,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) @@ -196,6 +200,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 +288,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 +317,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/backend/internal/store/base_location_null_test.go b/backend/internal/store/base_location_null_test.go new file mode 100644 index 0000000..92be19d --- /dev/null +++ b/backend/internal/store/base_location_null_test.go @@ -0,0 +1,124 @@ +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, 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 { + 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]GuildDetailBase{} + for _, b := range detail.Bases { + byID[b.ID] = b + } + 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 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). + 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 || b.Name != "North Fort" { + t.Fatalf("placed GuildBase = %#v", b) + } + seen++ + case unplaced: + 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++ + } + } + 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"` + Name *string `json:"name"` + 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 || 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 || 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 new file mode 100644 index 0000000..cd0e1c4 --- /dev/null +++ b/backend/internal/store/guild_detail.go @@ -0,0 +1,255 @@ +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"` + // 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"` +} + +type GuildDetailPal struct { + InstanceID string `json:"instanceId"` + 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"` + 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.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, &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} + } + 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.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=? +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.Rank, &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/guild_list_test.go b/backend/internal/store/guild_list_test.go new file mode 100644 index 0000000..19c09dc --- /dev/null +++ b/backend/internal/store/guild_list_test.go @@ -0,0 +1,88 @@ +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" + 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}, + // 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"}}}, + // 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: ""}, + // 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 { + t.Fatal(err) + } + + list, err := s.GuildJSON(ctx) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("guild list = %d guilds, want 2: %#v", len(list), list) + } + 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 + // 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/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/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/migrations/012_pal_rank.sql b/backend/internal/store/migrations/012_pal_rank.sql new file mode 100644 index 0000000..1b5d4d3 --- /dev/null +++ b/backend/internal/store/migrations/012_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/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..b5f1687 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 { @@ -973,7 +984,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 } @@ -1001,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 } @@ -1084,6 +1098,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 @@ -1098,9 +1122,28 @@ 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 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) + 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 +// 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 } @@ -1133,19 +1176,28 @@ 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 } - 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} + } + 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}) @@ -1166,11 +1218,17 @@ 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). +// 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 - X, Y float64 - Level int + ID string + Name string + X, Y float64 + HasLocation bool + Level int } // Guilds returns every guild with its members and bases, typed (the integration-surface @@ -1209,18 +1267,21 @@ 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) } if e = br.Close(); e != nil { @@ -1245,6 +1306,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 +1438,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 +1447,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 +1461,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 +1472,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 +1486,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 +1542,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 +1559,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 +1572,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 +1589,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..8b11ccb 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 != "12" { + t.Fatalf("schema_version = %q, %v; want 12", 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 != "12" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 12", 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='13' 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 13 succeeded") } - for _, needle := range []string{"10", "9", "newer than this binary supports"} { + for _, needle := range []string{"13", "12", "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 != "12" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 9", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 12", 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 != "12" { 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..2956568 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 != "12" { + t.Fatalf("schema_version = %q, %v; want 12", 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 != "12" { + t.Fatalf("schema_version = %q, %v; want 12", 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 != "12" { + t.Fatalf("schema_version after upgrade = %q, %v; want 12", 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 != "12" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 12", 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..c1d0973 --- /dev/null +++ b/docs-site/src/content/docs/panel/guilds.md @@ -0,0 +1,29 @@ +--- +title: Guild details +description: Current members, bases, associated Pals, and bounded panel-observed guild activity. +sidebar: + order: 5 +--- + +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. +- 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/.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..bca8df2 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", @@ -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..22d8366 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,14 @@ { "name": "frontend", "private": true, - "version": "0.8.0", + "version": "0.9.0", "type": "module", "scripts": { "dev": "vite", "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/api/client.ts b/frontend/src/api/client.ts index c8dbb27..0a2f7ba 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -10,10 +10,12 @@ import type { BackupContentEntry, BackupDryRun, BackupSchedule, + BackupStorage, ConfigDoc, ConfigValue, ConsoleLogEntry, Guild, + GuildDetail, IntegrationKey, IntegrationKeyCreated, MapDataset, @@ -23,6 +25,7 @@ import type { PalExplorerPage, PalExplorerParams, PaldeckIconDataset, + PlayerPaldeck, PalhelmEvent, Player, PlayerDetail, @@ -31,6 +34,7 @@ import type { ServerActivity, ServerActivityWindow, ServerHealth, + ServerPaldeck, ServerInfo, SessionInfo, WhitelistEntry, @@ -153,6 +157,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 +171,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 @@ -206,6 +215,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..a446b23 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -15,13 +15,16 @@ import type { BackupContentEntry, BackupDryRun, BackupSchedule, + BackupStorage, ConfigDoc, ConfigSetting, ConfigValue, ConsoleLogEntry, Guild, + GuildDetail, IntegrationKey, IntegrationKeyCreated, + LiveWorldActor, LiveWorldSnapshot, MapDataset, MetricsCurrent, @@ -31,6 +34,7 @@ import type { PalExplorerPal, PalExplorerParams, PaldeckIconDataset, + PlayerPaldeck, PalhelmEvent, Player, PlayerDetail, @@ -40,6 +44,7 @@ import type { ServerActivity, ServerActivityWindow, ServerHealth, + ServerPaldeck, ServerInfo, SessionInfo, WhitelistEntry, @@ -107,6 +112,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 +132,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 +152,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, }, @@ -178,21 +192,57 @@ 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"]; // 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()}`; @@ -206,12 +256,27 @@ 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), })), }; }); +// 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" }, @@ -319,7 +384,9 @@ 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", + sessionDays: 7, + saveSyncMinutes: 10, }; } @@ -427,13 +494,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 +510,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 }, @@ -627,7 +694,73 @@ 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 = 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(); + 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, + rank: pal.rank ?? null, + 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 ---------- @@ -736,6 +869,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. @@ -818,6 +1034,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(); @@ -829,7 +1077,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) => ({ @@ -841,7 +1089,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() }, @@ -995,6 +1244,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..b8664dd 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"; @@ -90,6 +94,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; } @@ -115,6 +123,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; @@ -243,7 +257,12 @@ export interface WhitelistEntry { // ---------- Guilds ---------- export interface GuildBase { id: string; - location: { x: number; y: number }; + // 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; level: number; } @@ -261,6 +280,131 @@ 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; + // null when the base was never renamed; render a positional "Base N" label. + name: string | null; + location: PlayerLocation | null; + level: number; + palCount: number; +} + +export interface GuildDetailPal { + instanceId: string; + 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; + 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 { @@ -443,6 +587,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/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/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/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 new file mode 100644 index 0000000..e4b199d --- /dev/null +++ b/frontend/src/app/mapClustering.ts @@ -0,0 +1,67 @@ +export type ClusterMarkerKind = "player" | "base" | "worker"; + +/** 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/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/src/components/PalBoxDialog.tsx b/frontend/src/components/PalBoxDialog.tsx index 849f179..9a76da8 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 && } ); @@ -94,7 +96,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/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/Shell.tsx b/frontend/src/components/Shell.tsx index 95d859c..e26fce9 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"; @@ -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 }, @@ -73,6 +77,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); @@ -203,14 +210,14 @@ function HelmStrip() {
-
+
In-game Day {metrics ? metrics.day : "—"}
-
+
Uptime {metrics ? formatDuration(metrics.uptimeSec) : "—"} 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/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/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 2fcfabf..310ed0c 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"; @@ -31,7 +32,7 @@ export default function ActivityRoute() {

Player activity

- panel-observed sessions · rolling windows · no lifetime claims + observed sessions · rolling windows
{WINDOWS.map((item) => ( @@ -45,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.
- +
@@ -88,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) => )} + {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.

+

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/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/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..0f8ab32 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; @@ -61,7 +63,7 @@ export default function DiagnosticsRoute() {

Diagnostics

- read-only operator evidence · cached pollers · no raw actor data + read-only health evidence · cached pollers

- 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 new file mode 100644 index 0000000..d3bcf7c --- /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; 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); } +.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..f85661a --- /dev/null +++ b/frontend/src/routes/guilds/Guilds.tsx @@ -0,0 +1,116 @@ +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 { guildDisplayName } from "../../app/guildDisplay"; +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"; + +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 ? guildDisplayName(detailQuery.data) : "Guild detail") : "Guilds"}

+ rosters · bases · members from the latest save +
+ {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) => ( + + {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"} + + + ))} +
+ )} +
+ ); +} + +function GuildDetailView({ guild }: { guild: GuildDetail }) { + return ( + <> + 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." : ""} +
+ 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) => )} +
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} Paldeck unlocks}
+
+ )} +
+ + + {guild.bases.length === 0 ? : ( + + + {guild.bases.map((base, index) => { + const game = base.location ? worldToGame(base.location.x, base.location.y) : null; + return ; + })} +
BaseLevelPalsLocation
{base.name ?? `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.rank != null && pal.rank > 1 && <> · }{pal.association === "guild_base" ? "Guild base" : palOwnerSummary(pal)}
+ Roster +
; + })} +
+ )} +
+ + ); +} 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/map/Map.css b/frontend/src/routes/map/Map.css index 8ec2113..b620183 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; @@ -103,6 +104,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 +124,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); @@ -159,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; } @@ -195,17 +224,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 0d25b0b..cd3ce5b 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, LiveWorldActor, MapDataset, MapDatasetLayer } from "../../api/types"; import { layerMapToWorld, MAP_SIZE, @@ -15,7 +15,9 @@ 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 { addContainedMapWheelListener, buildSharedMapURL, @@ -28,7 +30,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"; @@ -141,6 +143,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); @@ -296,7 +299,13 @@ 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. 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: b.name ?? guildDisplayName(g) }))) + .filter((b): b is typeof b & { location: NonNullable } => b.location !== null); const liveMapActors = selectLiveMapActors(liveSnapshot); const workers = liveMapActors.workers; const palBoxes = liveMapActors.palBoxes; @@ -368,11 +377,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 +420,64 @@ 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]); + + // 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(() => { @@ -444,7 +512,7 @@ export default function MapRoute() {
- + {playerMarkerSelection.usedLive && liveSnapshot?.capturedAt ? ( live snapshot {formatRelativeToNow(liveSnapshot.capturedAt)} ) : healthQuery.data ? ( @@ -518,6 +586,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 -
- -
)} @@ -599,46 +663,29 @@ 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.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.Bases && baseMarkerGroups.map((group) => ( + + ))} + {layers.Players && playerMarkerGroups.map((group) => ( + + ))} + {layers.Workers && workerMarkerGroups.map((group) => ( + + ))} {layers.PalBoxes && palBoxes .filter((box) => onLayer(activeLayer, box.location.x, box.location.y)) @@ -687,12 +734,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) => ( @@ -710,6 +757,106 @@ export default function MapRoute() { ); } +function markerKind(group: ClusterMarkerGroup): "player" | "base" { + // 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({ + 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 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/src/routes/paldeck/Paldeck.css b/frontend/src/routes/paldeck/Paldeck.css new file mode 100644 index 0000000..712d1fa --- /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; 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; } +.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..f8f54bc --- /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 ( +
+
+
+

Paldeck

+ capture progress from parsed saves · 1.0 catalog +
+ +
+ + {query.isError ? ( + Couldn't load Paldeck data from 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 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."} + + +
+ + + 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" /> + +
+
+ {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 ? `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"}} + {count !== null && count > 0 && View roster} +
+
+ ); + })} +
+ )} +
+

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 ? "needs full capture data" : `${percent}% of 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..1ca7088 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"; @@ -8,29 +9,25 @@ 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 { 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 +53,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); } @@ -64,11 +61,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
- +
@@ -117,18 +114,18 @@ export default function PalsRoute() {
{rangeInvalid ? ( - Minimum level cannot be higher than maximum level. + Min level cannot be higher than max level. ) : palsQuery.isError ? ( Couldn't load the Pal roster. Save data may not have been parsed yet. ) : palsQuery.isPending ? ( ) : pals.length === 0 ? ( - + ) : ( <>
{pals.length} loaded - Results are ordered by stable save instance + ordered by save instance
{pals.map((pal) => ( @@ -143,7 +140,7 @@ export default function PalsRoute() { {palsQuery.isFetchingNextPage ? "Loading…" : `Load ${PAL_EXPLORER_PAGE_SIZE} more`} ) : ( - End of matching roster + End of results )}
@@ -176,6 +173,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/src/routes/pals/palExplorer.ts b/frontend/src/routes/pals/palExplorer.ts index dc0c369..e3c7a88 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 = {}; @@ -37,9 +97,9 @@ export function palOwnerSummary(pal: Pick("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() }); @@ -69,7 +77,7 @@ export default function PlayersRoute() {

Players

- {playersQuery.data ? `${online} online · ${players.length} known from save data` : "loading…"} + {playersQuery.data ? `${online} online · ${players.length} known` : "loading…"}
@@ -224,7 +232,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()}> @@ -235,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 @@ -341,10 +351,10 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k
Guild - {d.guildName ?? "—"} + {d.guildId && d.guildName ? {d.guildName} : "—"}
- Position + Position last save {gamePos ? `${gamePos.x}, ${gamePos.y}` : "—"}
@@ -368,13 +378,13 @@ 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`}
{pals.length === 0 && (
- No pals in the latest save parse. + No Pals in the latest save.
)} {shownPals.map((pal) => { @@ -416,7 +426,12 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k - {d.banned ? ( @@ -452,7 +467,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.
) : ( <>
@@ -465,8 +480,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." : ""}

)} @@ -636,9 +651,9 @@ function GuildsTab() {
- {initials(g.name)} + {initials(guildDisplayName(g))}
-
{g.name}
+
{guildDisplayName(g)}
{g.id}
@@ -691,7 +706,7 @@ function WhitelistTab() { return ( - + {whitelistQuery.isError ? ( Couldn't load player notes. diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index d2b3e85..4fbd765 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
@@ -199,13 +206,13 @@ function GameDataDiagnosticsCard() {
FPS{snapshot ? `${snapshot.fps.toFixed(1)} · avg ${snapshot.fpsAvg.toFixed(1)}` : "—"}
-
Exact worker links{diagnostics ? `${diagnostics.linkedBasePals}/${snapshot?.counts.basePals ?? 0}` : "—"}
+
Linked workers{diagnostics ? `${diagnostics.linkedBasePals}/${snapshot?.counts.basePals ?? 0}` : "—"}
Unresolved workers{diagnostics?.unresolvedBasePals ?? "—"}
Last poll result{diagnostics?.lastErrorCategory ?? "—"}
Next attempt{diagnostics?.nextAttemptAt ? formatRelativeToNow(diagnostics.nextAttemptAt) : "not scheduled"}
- {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 @@ -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 && (
diff --git a/frontend/src/styles/ui.css b/frontend/src/styles/ui.css index a1bcc63..79f081e 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,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, + 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 { 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/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/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/fixtures/map-transform-1.0.json b/frontend/tests/fixtures/map-transform-1.0.json new file mode 100644 index 0000000..cc5699b --- /dev/null +++ b/frontend/tests/fixtures/map-transform-1.0.json @@ -0,0 +1,73 @@ +{ + "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", + "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": "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": -375000, "y": 0 }, + "game": { "x": -344, "y": -547 }, + "nativePixel": { "x": 256, "y": 256 } + } + ] + }, + { + "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": 347352.5, "y": -818196 }, + "nativePixel": { "x": 0.0014979651666635618, "y": 511.99850203483356 } + }, + { + "id": "dataset-bounds-center", + "kind": "metadata", + "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": 689147.5, "y": -476401 }, + "nativePixel": { "x": 511.9985020348337, "y": 0.0014979651664361882 } + } + ] + } + ] +} 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); +}); diff --git a/frontend/tests/guild-paldeck-routes.test.mjs b/frontend/tests/guild-paldeck-routes.test.mjs new file mode 100644 index 0000000..3e6642a --- /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, /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/mapClustering.test.mjs b/frontend/tests/mapClustering.test.mjs new file mode 100644 index 0000000..d6f0e96 --- /dev/null +++ b/frontend/tests/mapClustering.test.mjs @@ -0,0 +1,133 @@ +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 } } }; +} + +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"]); +}); + +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/); + 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..86f9da1 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,58 @@ 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-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; + 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 [[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`); + 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`); + } +}); + +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); +}); diff --git a/frontend/tests/pal-explorer.test.mjs b/frontend/tests/pal-explorer.test.mjs index ca2d501..881829e 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({ @@ -15,11 +22,29 @@ 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"); }); +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/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 && { + 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); +}); 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/); 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); +});