Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,4 @@ oo2core*

# wrangler local cache
.wrangler/
mods/palhelm-pal-native/dist/
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons
| `PALHELM_ITEM_CATALOG_PATH` | `<data>/item-catalog.json` | operator-installed versioned item catalogue; game data/art are not distributed |
| `PALHELM_ITEM_ICON_DIR` | `<data>/item-icons` | operator-installed same-origin item icons |
| `PALHELM_ITEM_GRANT_SPOOL_DIR` | `<data>/item-grants` | local-only request/result directory shared with a compatible server bridge |
| `PALHELM_PAL_GRANTS_ENABLED` | `false` | explicitly enable the admin-only Pal creation provider; still requires an exact-build validated bridge heartbeat |
| `PALHELM_PAL_GRANT_SPOOL_DIR` | `<data>/pal-grants` | local-only Pal creation request/result directory shared with a compatible server bridge |
| `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 |

Expand All @@ -114,12 +116,12 @@ complete `/data` volume before upgrading; rollback to a 0.8.x image requires res
pre-upgrade backup, because the older binary fails closed against the newer schema. See
[the v0.9.0 release notes](docs/releases/v0.9.0.md).

The unreleased item-grant work adds migration 013 for a durable audit ledger. The
provider remains disabled by default and is not part of the read-only Integration API.
Its UE4SS bridge remains `validation_required` until an exact-build maintenance-window
test proves the inventory mutation and persistence behavior. See
The unreleased grant work adds migrations 013–014 for durable item- and Pal-grant audit
ledgers. Both providers remain independently disabled by default and are not part of the
read-only Integration API. The Pal bridge is only a reflection probe until an exact-build
maintenance-window validation proves the creation and Palbox-delivery signatures. See
[the server-mod integration plan](docs/SERVER-MOD-INTEGRATIONS-PLAN.md) before testing
the bridge; Palworld's official server mod loader does not support the native Linux
the bridges; Palworld's official server mod loader does not support the native Linux
dedicated-server binary.

## Known limits
Expand Down
118 changes: 118 additions & 0 deletions backend/cmd/pal-passive-catalog-import/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Command pal-passive-catalog-import turns PalCalc's pinned db.json into the
// compact, reviewed passive catalogue embedded by the Pal grant API.
package main

import (
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
)

const sourceCommit = "b5e13e90fedc2e95d54fa223da77be464c313001"

type sourceDB struct {
Version string `json:"Version"`
PassiveSkills []sourcePassive `json:"PassiveSkills"`
}

type sourcePassive struct {
Name string `json:"Name"`
InternalName string `json:"InternalName"`
Rank int `json:"Rank"`
Description string `json:"Description"`
IsStandardPassiveSkill bool `json:"IsStandardPassiveSkill"`
RandomInheritanceAllowed bool `json:"RandomInheritanceAllowed"`
}

type outputCatalog struct {
SchemaVersion int `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Source outputSource `json:"source"`
Passives []outputPassive `json:"passives"`
}

type outputSource struct {
Name string `json:"name"`
Version string `json:"version"`
Commit string `json:"commit"`
URL string `json:"url"`
}

type outputPassive struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Rank int `json:"rank"`
Inheritable bool `json:"inheritable"`
}

func main() {
input := flag.String("in", "", "PalCalc db.json path")
output := flag.String("out", "", "generated catalogue path")
flag.Parse()
if *input == "" || *output == "" {
fatalf("both -in and -out are required")
}
b, err := os.ReadFile(*input)
if err != nil {
fatalf("read input: %v", err)
}
var db sourceDB
if err := json.Unmarshal(b, &db); err != nil {
fatalf("decode input: %v", err)
}
if strings.TrimSpace(db.Version) == "" || len(db.PassiveSkills) < 100 {
fatalf("source database does not look complete")
}
seen := make(map[string]bool)
out := outputCatalog{
SchemaVersion: 1,
CatalogVersion: "palcalc_" + strings.ToLower(strings.TrimSpace(db.Version)) + "_" + sourceCommit[:12],
Source: outputSource{
Name: "PalCalc",
Version: strings.TrimSpace(db.Version),
Commit: sourceCommit,
URL: "https://github.com/tylercamp/palcalc/tree/" + sourceCommit,
},
}
for _, item := range db.PassiveSkills {
id, name := strings.TrimSpace(item.InternalName), strings.TrimSpace(item.Name)
key := strings.ToLower(id)
if !item.IsStandardPassiveSkill || id == "" || name == "" || seen[key] {
continue
}
seen[key] = true
out.Passives = append(out.Passives, outputPassive{
ID: id,
Name: name,
Description: strings.TrimSpace(item.Description),
Rank: item.Rank,
Inheritable: item.RandomInheritanceAllowed,
})
}
if len(out.Passives) < 100 {
fatalf("only %d standard passives found", len(out.Passives))
}
sort.Slice(out.Passives, func(i, j int) bool {
if out.Passives[i].Rank != out.Passives[j].Rank {
return out.Passives[i].Rank > out.Passives[j].Rank
}
return strings.ToLower(out.Passives[i].Name) < strings.ToLower(out.Passives[j].Name)
})
b, err = json.MarshalIndent(out, "", " ")
if err != nil {
fatalf("encode output: %v", err)
}
b = append(b, '\n')
if err := os.WriteFile(*output, b, 0o644); err != nil {
fatalf("write output: %v", err)
}
}

func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
11 changes: 11 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type Config struct {
ItemCatalogPath string
ItemIconDir string
ItemGrantSpoolDir string
// Pal grants are independently gated because their bridge creates a complex
// persistent game object. Installing the panel code never enables mutation.
PalGrantsEnabled bool
PalGrantSpoolDir string
// SessionDays is how long a login session cookie stays valid, in whole days.
SessionDays int
}
Expand Down Expand Up @@ -70,6 +74,9 @@ func Load() (Config, error) {
if c.ItemGrantsEnabled, err = optionalBool("PALHELM_ITEM_GRANTS_ENABLED", false); err != nil {
return c, err
}
if c.PalGrantsEnabled, err = optionalBool("PALHELM_PAL_GRANTS_ENABLED", false); err != nil {
return c, err
}
c.ItemCatalogPath = strings.TrimSpace(os.Getenv("PALHELM_ITEM_CATALOG_PATH"))
if c.ItemCatalogPath == "" {
c.ItemCatalogPath = filepath.Join(c.DataDir, "item-catalog.json")
Expand All @@ -82,6 +89,10 @@ func Load() (Config, error) {
if c.ItemGrantSpoolDir == "" {
c.ItemGrantSpoolDir = filepath.Join(c.DataDir, "item-grants")
}
c.PalGrantSpoolDir = strings.TrimSpace(os.Getenv("PALHELM_PAL_GRANT_SPOOL_DIR"))
if c.PalGrantSpoolDir == "" {
c.PalGrantSpoolDir = filepath.Join(c.DataDir, "pal-grants")
}
if c.MetricsInterval, err = duration("PALHELM_METRICS_INTERVAL", 5*time.Second); err != nil {
return c, err
}
Expand Down
24 changes: 24 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,30 @@ func TestLoadRejectsInvalidItemGrantBoolean(t *testing.T) {
}
}

func TestLoadPalGrantsDefaultFailClosedAndAcceptOverrides(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PALHELM_DATA_DIR", dataDir)
t.Setenv("PALHELM_PAL_GRANTS_ENABLED", "")
t.Setenv("PALHELM_PAL_GRANT_SPOOL_DIR", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.PalGrantsEnabled || cfg.PalGrantSpoolDir != filepath.Join(dataDir, "pal-grants") {
t.Fatalf("Pal grant defaults = %#v", cfg)
}
t.Setenv("PALHELM_PAL_GRANTS_ENABLED", "true")
t.Setenv("PALHELM_PAL_GRANT_SPOOL_DIR", "/bridge/pals")
cfg, err = Load()
if err != nil || !cfg.PalGrantsEnabled || cfg.PalGrantSpoolDir != "/bridge/pals" {
t.Fatalf("Pal grant overrides = %#v err=%v", cfg, err)
}
t.Setenv("PALHELM_PAL_GRANTS_ENABLED", "sometimes")
if _, err := Load(); err == nil {
t.Fatal("invalid Pal grant boolean did not fail startup")
}
}

func TestLoadGameDataOverrides(t *testing.T) {
t.Setenv("PALHELM_GAME_DATA_ENABLED", "true")
t.Setenv("PALHELM_GAME_DATA_INTERVAL", "45s")
Expand Down
150 changes: 150 additions & 0 deletions backend/internal/palgrant/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Package palgrant owns the fail-closed contract for creating one legitimate Pal
// and granting it to an online player. It intentionally does not edit save files.
package palgrant

import (
"errors"
"regexp"
"sort"
"strings"

"github.com/8tp/palhelm/internal/paldeck"
)

const CatalogVersion = "palworld_1.0_pinned"
const MaxSupportedLevel = 80

var safeToken = regexp.MustCompile(`^[A-Za-z0-9_]{1,160}$`)

type Species struct {
CharacterID string `json:"characterId"`
DisplayName string `json:"displayName"`
}

type Catalog struct {
items []Species
byID map[string]Species
}

func NewCatalog() *Catalog {
c := &Catalog{byID: make(map[string]Species)}
for _, entry := range paldeck.All() {
id := strings.TrimSpace(entry.ID)
// Capturable humans and BOSS_ save variants are intentionally not a
// creation catalogue. Alpha is represented as a validated specimen flag.
if !safeToken.MatchString(id) || strings.HasPrefix(strings.ToLower(id), "boss_") || strings.EqualFold(id, "hunter_rifle") {
continue
}
item := Species{CharacterID: id, DisplayName: strings.TrimSpace(entry.Name)}
if item.DisplayName == "" {
continue
}
c.byID[strings.ToLower(id)] = item
c.items = append(c.items, item)
}
sort.Slice(c.items, func(i, j int) bool {
return strings.ToLower(c.items[i].DisplayName) < strings.ToLower(c.items[j].DisplayName)
})
return c
}

func (c *Catalog) Count() int { return len(c.items) }

func (c *Catalog) Species(characterID string) (Species, bool) {
item, ok := c.byID[strings.ToLower(strings.TrimSpace(characterID))]
return item, ok
}

func (c *Catalog) Search(query string, limit int) []Species {
if limit < 1 || limit > 100 {
limit = 50
}
terms := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
out := make([]Species, 0, limit)
for _, item := range c.items {
haystack := strings.ToLower(item.DisplayName + " " + item.CharacterID)
matched := true
for _, term := range terms {
if !strings.Contains(haystack, term) {
matched = false
break
}
}
if matched {
out = append(out, item)
if len(out) == limit {
break
}
}
}
return out
}

type GenerationSpec struct {
Mode string `json:"mode"`
LevelMin int `json:"levelMin"`
LevelMax int `json:"levelMax"`
Gender string `json:"gender"`
Alpha bool `json:"alpha"`
Lucky bool `json:"lucky"`
IVMode string `json:"ivMode"`
TalentHP *int `json:"talentHp,omitempty"`
TalentMelee *int `json:"talentMelee,omitempty"`
TalentShot *int `json:"talentShot,omitempty"`
TalentDefense *int `json:"talentDefense,omitempty"`
PassiveSkillIDs []string `json:"passiveSkillIds"`
CondensationStars int `json:"condensationStars"`
}

func DefaultSpec() GenerationSpec {
return GenerationSpec{Mode: "natural", LevelMin: 1, LevelMax: 1, Gender: "random", IVMode: "natural", PassiveSkillIDs: []string{}}
}

func (s GenerationSpec) Validate() error {
if s.Mode != "natural" && s.Mode != "custom" {
return errors.New("mode must be natural or custom")
}
if s.LevelMin < 1 || s.LevelMax < s.LevelMin || s.LevelMax > MaxSupportedLevel {
return errors.New("level range is outside the supported game range")
}
if s.Gender != "random" && s.Gender != "male" && s.Gender != "female" {
return errors.New("gender must be random, male, or female")
}
if s.Alpha && s.Lucky {
return errors.New("alpha and lucky cannot both be forced")
}
if s.IVMode != "natural" && s.IVMode != "minimum" && s.IVMode != "exact" {
return errors.New("ivMode must be natural, minimum, or exact")
}
values := []*int{s.TalentHP, s.TalentMelee, s.TalentShot, s.TalentDefense}
if s.IVMode == "natural" {
for _, value := range values {
if value != nil {
return errors.New("natural IV mode cannot include IV overrides")
}
}
} else {
for _, value := range values {
if value == nil || *value < 0 || *value > 100 {
return errors.New("custom IV values must each be from 0 to 100")
}
}
}
if s.CondensationStars < 0 || s.CondensationStars > 4 {
return errors.New("condensationStars must be from 0 to 4")
}
if len(s.PassiveSkillIDs) > 4 {
return errors.New("a Pal cannot have more than four passive skills")
}
seen := make(map[string]bool, len(s.PassiveSkillIDs))
for _, id := range s.PassiveSkillIDs {
if !safeToken.MatchString(id) || seen[strings.ToLower(id)] {
return errors.New("passive skill IDs must be unique safe game IDs")
}
seen[strings.ToLower(id)] = true
}
if s.Mode == "natural" && (s.Gender != "random" || s.Alpha || s.Lucky || s.IVMode != "natural" || len(s.PassiveSkillIDs) > 0 || s.CondensationStars != 0) {
return errors.New("natural mode cannot include advanced overrides")
}
return nil
}
Loading