Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
10 changes: 5 additions & 5 deletions .github/workflows/ghcr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,15 +35,15 @@ 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 }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Generate image metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.8.0
0.9.0
4 changes: 4 additions & 0 deletions backend/internal/backup/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
187 changes: 187 additions & 0 deletions backend/internal/sav/base_location_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 8 additions & 0 deletions backend/internal/sav/character.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading