diff --git a/.gitignore b/.gitignore index cc98b94..d122f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ oo2core* # wrangler local cache .wrangler/ +mods/palhelm-pal-native/dist/ diff --git a/README.md b/README.md index 292c54e..6e9a051 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons | `PALHELM_ITEM_CATALOG_PATH` | `/item-catalog.json` | operator-installed versioned item catalogue; game data/art are not distributed | | `PALHELM_ITEM_ICON_DIR` | `/item-icons` | operator-installed same-origin item icons | | `PALHELM_ITEM_GRANT_SPOOL_DIR` | `/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` | `/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 | @@ -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 diff --git a/backend/cmd/pal-passive-catalog-import/main.go b/backend/cmd/pal-passive-catalog-import/main.go new file mode 100644 index 0000000..3f937f7 --- /dev/null +++ b/backend/cmd/pal-passive-catalog-import/main.go @@ -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) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a2cefc7..fbf345f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } @@ -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") @@ -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 } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index e44392b..508384b 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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") diff --git a/backend/internal/palgrant/catalog.go b/backend/internal/palgrant/catalog.go new file mode 100644 index 0000000..724f9a6 --- /dev/null +++ b/backend/internal/palgrant/catalog.go @@ -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 +} diff --git a/backend/internal/palgrant/catalog_test.go b/backend/internal/palgrant/catalog_test.go new file mode 100644 index 0000000..7010217 --- /dev/null +++ b/backend/internal/palgrant/catalog_test.go @@ -0,0 +1,50 @@ +package palgrant + +import "testing" + +func intPtr(v int) *int { return &v } + +func TestCatalogExcludesHumansAndBossSaveVariants(t *testing.T) { + c := NewCatalog() + if c.Count() < 200 { + t.Fatalf("catalogue unexpectedly small: %d", c.Count()) + } + if _, ok := c.Species("Anubis"); !ok { + t.Fatal("Anubis missing") + } + for _, id := range []string{"Hunter_Rifle", "BOSS_Hunter_Rifle"} { + if _, ok := c.Species(id); ok { + t.Fatalf("unsafe human/boss %q is grantable", id) + } + } + if got := c.Search("anub", 10); len(got) != 1 || got[0].DisplayName != "Anubis" { + t.Fatalf("search = %#v", got) + } +} + +func TestGenerationSpecValidation(t *testing.T) { + natural := DefaultSpec() + natural.LevelMin, natural.LevelMax = 20, 30 + if err := natural.Validate(); err != nil { + t.Fatal(err) + } + custom := GenerationSpec{Mode: "custom", LevelMin: 35, LevelMax: 35, Gender: "female", Alpha: true, IVMode: "minimum", TalentHP: intPtr(70), TalentMelee: intPtr(70), TalentShot: intPtr(70), TalentDefense: intPtr(70), PassiveSkillIDs: []string{"CraftSpeed_up2"}, CondensationStars: 2} + if err := custom.Validate(); err != nil { + t.Fatal(err) + } + for name, spec := range map[string]GenerationSpec{ + "alpha lucky": {Mode: "custom", LevelMin: 1, LevelMax: 1, Gender: "random", Alpha: true, Lucky: true, IVMode: "natural"}, + "level overflow": {Mode: "natural", LevelMin: 1, LevelMax: MaxSupportedLevel + 1, Gender: "random", IVMode: "natural"}, + "natural overrides": {Mode: "natural", LevelMin: 1, LevelMax: 1, Gender: "female", IVMode: "natural"}, + "missing exact IV": {Mode: "custom", LevelMin: 1, LevelMax: 1, Gender: "random", IVMode: "exact", TalentHP: intPtr(50)}, + } { + t.Run(name, func(t *testing.T) { + if spec.PassiveSkillIDs == nil { + spec.PassiveSkillIDs = []string{} + } + if err := spec.Validate(); err == nil { + t.Fatal("invalid spec accepted") + } + }) + } +} diff --git a/backend/internal/palgrant/data/passives.json b/backend/internal/palgrant/data/passives.json new file mode 100644 index 0000000..454fcd2 --- /dev/null +++ b/backend/internal/palgrant/data/passives.json @@ -0,0 +1,817 @@ +{ + "schemaVersion": 1, + "catalogVersion": "palcalc_v23_b5e13e90fedc", + "source": { + "name": "PalCalc", + "version": "v23", + "commit": "b5e13e90fedc2e95d54fa223da77be464c313001", + "url": "https://github.com/tylercamp/palcalc/tree/b5e13e90fedc2e95d54fa223da77be464c313001" + }, + "passives": [ + { + "id": "WorldTree_CraftSpeed", + "name": "Demon’s Hand", + "description": "Work Speed +90%\nSAN dreceases +15.0% faster\nWorld Tree harvestables won't vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_MoveSpeed", + "name": "Dimensional Leap", + "description": "Movement Speed +50%\nIncreases Hunger depletion rate by +15.0%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_ATK_DEF", + "name": "God of Destruction", + "description": "Attack +40%\nDefense +20%\nMax Health -50%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_Sanity", + "name": "Hermit Sage", + "description": "SAN depletion rate -50.0%\nWork Speed -20%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_DEF", + "name": "Sanctified Meat Shield", + "description": "Defense +50%\nAttack -30%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_ATK", + "name": "Twin-Edged Holy Blade", + "description": "Attack +50%\nDefense -30%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "WorldTree_FullStomach", + "name": "World Tree Seedbed", + "description": "Decrease Hunger depletion rate by +50.0%\nHP -20%\nWorld Tree resources will not vanish when approached.", + "rank": 5, + "inheritable": false + }, + { + "id": "MutationPal_Babysitter", + "name": "Babysitter", + "description": "While at a base, increases egg production speed by +30% and incubation speed by +30% for Pals assigned to a Breeding Farm.", + "rank": 4, + "inheritable": false + }, + { + "id": "PAL_ALLAttack_up3", + "name": "Demon God", + "description": "Attack +30%\nDefense +5%", + "rank": 4, + "inheritable": true + }, + { + "id": "Deffence_up3", + "name": "Diamond Body", + "description": "Defense +30%\nImmune to Flinch\nImmune to Knockback", + "rank": 4, + "inheritable": true + }, + { + "id": "Stamina_Up_3", + "name": "Eternal Engine", + "description": "Max stamina +75%\n*This effect is only valid for rideable pals.", + "rank": 4, + "inheritable": true + }, + { + "id": "EternalFlame", + "name": "Eternal Flame", + "description": "30% increase to Fire attack damage.\n30% increase to Lightning attack damage.", + "rank": 4, + "inheritable": false + }, + { + "id": "PAL_Sanity_Down_3", + "name": "Heart of the Immovable King", + "description": "SAN drops +20.0% slower.", + "rank": 4, + "inheritable": true + }, + { + "id": "MutationPal_ExplosionResist", + "name": "Heavily Armored", + "description": "Immune to Explosion Damage", + "rank": 4, + "inheritable": false + }, + { + "id": "MutationPal_Mutant", + "name": "Idiosyncratic", + "description": "Pal and Player Auto Health Regeneration Rate +50%\nDefense +25%\nImmune to Poison Damage\nImmune to Burn Damage", + "rank": 4, + "inheritable": false + }, + { + "id": "MutationPal_Immortal", + "name": "Immortality", + "description": "Life Steal +5%\nPal Auto Health Regeneration Rate +100%\nAttack +15%", + "rank": 4, + "inheritable": false + }, + { + "id": "Invader", + "name": "Invader", + "description": "30% increase in Dark attack damage.\n30% increase in Dragon attack damage.", + "rank": 4, + "inheritable": false + }, + { + "id": "SwimSpeed_up_3", + "name": "King of the Waves", + "description": "50% increase movement speed on water.", + "rank": 4, + "inheritable": true + }, + { + "id": "SelfDeathAddItemDrop_up_3", + "name": "Lavish Hospitality", + "description": "Your Dropped Items + 100%", + "rank": 4, + "inheritable": true + }, + { + "id": "Legend", + "name": "Legend", + "description": "Attack +20%\nDefense +20%\nMovement Speed increases 20%", + "rank": 4, + "inheritable": false + }, + { + "id": "RideJumpCount_Increase1", + "name": "Lightfooted", + "description": "Mounted Jump Count +1", + "rank": 4, + "inheritable": true + }, + { + "id": "Rare", + "name": "Lucky", + "description": "Attack +15%\nDefense +15%\nWork Speed +20%", + "rank": 4, + "inheritable": false + }, + { + "id": "Nushi", + "name": "Lunker", + "description": "20% increase to Water attack damage \n20% increase to Ice attack damage \n20% increase to defense.", + "rank": 4, + "inheritable": false + }, + { + "id": "PAL_FullStomach_Down_3", + "name": "Mastery of Fasting", + "description": "Hunger decreases +20.0% slower.", + "rank": 4, + "inheritable": true + }, + { + "id": "WorkSuitabilityAddRank_MonsterFarm_2", + "name": "Ranch Master", + "description": "Farming's Work Suitability +2", + "rank": 4, + "inheritable": true + }, + { + "id": "CraftSpeed_up3", + "name": "Remarkable Craftsmanship", + "description": "Work Speed +75%", + "rank": 4, + "inheritable": true + }, + { + "id": "Salvation", + "name": "Savior", + "description": "30% increase in Neutral attack damage.\n30% increase in Grass attack damage.", + "rank": 4, + "inheritable": false + }, + { + "id": "Witch", + "name": "Siren of the Void", + "description": "30% increase in Dark attack damage.\n30% increase in Ice attack damage.", + "rank": 4, + "inheritable": false + }, + { + "id": "RideJumpCount_Increase2", + "name": "Skymarcher", + "description": "Mounted Jump Count +2", + "rank": 4, + "inheritable": false + }, + { + "id": "MoveSpeed_up_3", + "name": "Swift", + "description": "30% increase to movement speed.", + "rank": 4, + "inheritable": true + }, + { + "id": "Vampire", + "name": "Vampiric", + "description": "Absorbs a portion of the damage dealt to restore Health. \nDoes not sleep at night and continues to work.", + "rank": 4, + "inheritable": true + }, + { + "id": "SwimSpeed_up_2", + "name": "Ace Swimmer", + "description": "40% increase movement speed on water.", + "rank": 3, + "inheritable": true + }, + { + "id": "CraftSpeed_up2", + "name": "Artisan", + "description": "Work Speed +50%", + "rank": 3, + "inheritable": true + }, + { + "id": "Deffence_up2", + "name": "Burly Body", + "description": "Defense +20%\nImmune to Flinch", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Normal_2_PAL", + "name": "Celestial Emperor", + "description": "30% increase in Neutral attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "PAL_FullStomach_Down_2", + "name": "Diet Lover", + "description": "Hunger decreases +15.0% slower.", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Dragon_2_PAL", + "name": "Divine Dragon", + "description": "30% increase in Dragon attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "ElementBoost_Earth_2_PAL", + "name": "Earth Emperor", + "description": "30% increase in Earth attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "WorkSuitabilityAddRank_MonsterFarm_1", + "name": "Farmhand", + "description": "Farming's Work Suitability +1", + "rank": 3, + "inheritable": true + }, + { + "id": "PAL_ALLAttack_up2", + "name": "Ferocious", + "description": "Attack +20%", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Fire_2_PAL", + "name": "Flame Emperor", + "description": "30% increase in Fire attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "AutoHPRegeneRate_Passive", + "name": "Healing Coach", + "description": "Player Auto Health Regeneration Rate +5%", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Ice_2_PAL", + "name": "Ice Emperor", + "description": "30% increase in Ice attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "Stamina_Up_1", + "name": "Infinite Stamina", + "description": "Max stamina +50%\n*This effect is only valid for rideable pals.", + "rank": 3, + "inheritable": true + }, + { + "id": "TrainerLogging_up1", + "name": "Logging Foreman", + "description": "25% increase in Player Logging Efficiency.", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Thunder_2_PAL", + "name": "Lord of Lightning", + "description": "30% increase in Lightning attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "ElementBoost_Aqua_2_PAL", + "name": "Lord of the Sea", + "description": "30% increase in Water attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "ElementBoost_Dark_2_PAL", + "name": "Lord of the Underworld", + "description": "30% increase in Dark attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "TrainerMining_up1", + "name": "Mine Foreman", + "description": "25% increase in Player Mining Efficiency.", + "rank": 3, + "inheritable": true + }, + { + "id": "TrainerWorkSpeed_UP_1", + "name": "Motivational Leader", + "description": "25% increase in Player Work Speed.", + "rank": 3, + "inheritable": true + }, + { + "id": "SalePrice_Up_1", + "name": "Noble", + "description": "Increases the value of items when sold by +5%", + "rank": 3, + "inheritable": true + }, + { + "id": "Test_PalEgg_HatchingSpeed_Up", + "name": "Philanthropist", + "description": "When assigned to a Breeding Farm, breeding speed is increased by 100%.", + "rank": 3, + "inheritable": true + }, + { + "id": "ReloadSpeedUp_Passive", + "name": "Reload Master", + "description": "Player Reload Speed +4%", + "rank": 3, + "inheritable": true + }, + { + "id": "MoveSpeed_up_2", + "name": "Runner", + "description": "20% increase to movement speed.", + "rank": 3, + "inheritable": true + }, + { + "id": "CoolTimeReduction_Up_1", + "name": "Serenity", + "description": "Active skill cooldown reduction 30%\nAttack +10%", + "rank": 3, + "inheritable": true + }, + { + "id": "SelfDeathAddItemDrop_up_2", + "name": "Service-Minded", + "description": "Your Dropped Items + 50%", + "rank": 3, + "inheritable": true + }, + { + "id": "ElementBoost_Leaf_2_PAL", + "name": "Spirit Emperor", + "description": "30% increase in Grass attack damage.", + "rank": 3, + "inheritable": false + }, + { + "id": "TrainerDEF_UP_1", + "name": "Stronghold Strategist", + "description": "10% increase in Player Defense.", + "rank": 3, + "inheritable": true + }, + { + "id": "TrainerATK_UP_1", + "name": "Vanguard", + "description": "10% increase in Player Attack.", + "rank": 3, + "inheritable": true + }, + { + "id": "PlayerSP_DecreaseRate_Passive", + "name": "Wellness Watcher", + "description": "Player Stamina Consumption -5.0%", + "rank": 3, + "inheritable": true + }, + { + "id": "MiniNushi", + "name": "Whopper", + "description": "5% increase to Water attack damage \n5% increase to Ice attack damage \n5% increase to defense.", + "rank": 3, + "inheritable": false + }, + { + "id": "PAL_Sanity_Down_2", + "name": "Workaholic", + "description": "SAN drops +15.0% slower.", + "rank": 3, + "inheritable": true + }, + { + "id": "Deffence_up2_2", + "name": "Heavyweight", + "description": "Defense +20%\nImmune to Knockback", + "rank": 2, + "inheritable": true + }, + { + "id": "Noukin", + "name": "Musclehead", + "description": "Attack +30%\nWork Speed -50%", + "rank": 2, + "inheritable": true + }, + { + "id": "ElementResist_Normal_1_PAL", + "name": "Abnormal", + "description": "10% decrease in incoming Neutral damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_oraora", + "name": "Aggressive", + "description": "Attack +10%\nDefense -10%", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Dragon_1_PAL", + "name": "Blood of the Dragon", + "description": "10% increase in Dragon attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Leaf_1_PAL", + "name": "Botanical Barrier", + "description": "10% decrease in incoming Grass damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_ALLAttack_up1", + "name": "Brave", + "description": "Attack +10%", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Thunder_1_PAL", + "name": "Capacitor", + "description": "10% increase in Lightning attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Dark_1_PAL", + "name": "Cheery", + "description": "10% decrease in incoming Dark damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Ice_1_PAL", + "name": "Coldblooded", + "description": "10% increase in Ice attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_conceited", + "name": "Conceited", + "description": "Work Speed +10%\nDefense -10%", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_FullStomach_Down_1", + "name": "Dainty Eater", + "description": "Hunger decreases +10.0% slower.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Dragon_1_PAL", + "name": "Dragonkiller", + "description": "10% decrease in incoming Dragon damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Earth_1_PAL", + "name": "Earthquake Resistant", + "description": "10% decrease in incoming Earth damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "SalePrice_Up_2", + "name": "Fine Furs", + "description": "Increases the value of items when sold by +3%", + "rank": 1, + "inheritable": true + }, + { + "id": "Stamina_Up_2", + "name": "Fit as a Fiddle", + "description": "Max stamina +25%\n*This effect is only valid for rideable pals.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Leaf_1_PAL", + "name": "Fragrant Foliage", + "description": "10% increase in Grass attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "Deffence_up1", + "name": "Hard Skin", + "description": "Defense +10%", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Ice_1_PAL", + "name": "Heated Body", + "description": "10% decrease in incoming Ice damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_rude", + "name": "Hooligan", + "description": "Attack +15%\nWork Speed -10%", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Aqua_1_PAL", + "name": "Hydromaniac", + "description": "10% increase in Water attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "CoolTimeReduction_Up_2", + "name": "Impatient", + "description": "Active skill cooldown reduction 15%", + "rank": 1, + "inheritable": true + }, + { + "id": "Nocturnal", + "name": "Insomnia", + "description": "Does not sleep and continues to work even at night.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Thunder_1_PAL", + "name": "Insulated Body", + "description": "10% decrease in incoming Lightning damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_masochist", + "name": "Masochist", + "description": "Defense +15%\nAttack -15%", + "rank": 1, + "inheritable": true + }, + { + "id": "MoveSpeed_up_1", + "name": "Nimble", + "description": "10% increase to movement speed.", + "rank": 1, + "inheritable": true + }, + { + "id": "Alien", + "name": "Otherworldly Cells", + "description": "Attack +10%\nFire damage reduction 15%\nLightning damage reduction 15%", + "rank": 1, + "inheritable": false + }, + { + "id": "PAL_Sanity_Down_1", + "name": "Positive Thinker", + "description": "SAN drops +10.0% slower.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Earth_1_PAL", + "name": "Power of Gaia", + "description": "10% increase in Earth attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Fire_1_PAL", + "name": "Pyromaniac", + "description": "10% increase in Fire attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_sadist", + "name": "Sadist", + "description": "Attack +15%\nDefense -15%", + "rank": 1, + "inheritable": true + }, + { + "id": "CraftSpeed_up1", + "name": "Serious", + "description": "Work Speed +20%", + "rank": 1, + "inheritable": true + }, + { + "id": "SwimSpeed_up_1", + "name": "Sleek Stroke", + "description": "30% increase movement speed on water.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Normal_1_PAL", + "name": "Spirit of Zen", + "description": "10% increase in Neutral attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Fire_1_PAL", + "name": "Suntan Lover", + "description": "10% decrease in incoming Fire damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementBoost_Dark_1_PAL", + "name": "Veil of Darkness", + "description": "10% increase in Dark attack damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "ElementResist_Aqua_1_PAL", + "name": "Waterproof", + "description": "10% decrease in incoming Water damage.", + "rank": 1, + "inheritable": true + }, + { + "id": "PAL_CorporateSlave", + "name": "Work Slave", + "description": "Work Speed +30%\nAttack -30%", + "rank": 1, + "inheritable": true + }, + { + "id": "CraftSpeed_down1", + "name": "Clumsy", + "description": "Work Speed -10%", + "rank": -1, + "inheritable": true + }, + { + "id": "PAL_ALLAttack_down1", + "name": "Coward", + "description": "Attack -10%", + "rank": -1, + "inheritable": true + }, + { + "id": "Deffence_down1", + "name": "Downtrodden", + "description": "Defense -10%", + "rank": -1, + "inheritable": true + }, + { + "id": "CoolTimeReduction_Down_1", + "name": "Easygoing", + "description": "Active skill cooldown extension -15%", + "rank": -1, + "inheritable": true + }, + { + "id": "PAL_FullStomach_Up_1", + "name": "Glutton", + "description": "Hunger decreases +10.0% faster.", + "rank": -1, + "inheritable": true + }, + { + "id": "NonKilling", + "name": "Mercy Hit", + "description": "Pacifist.\nWill not reduce the target's Health below 1.", + "rank": -1, + "inheritable": true + }, + { + "id": "NightOwl", + "name": "Night Owl", + "description": "Tends to nap through the day, due to being nocturnal.", + "rank": -1, + "inheritable": true + }, + { + "id": "SalePrice_Down_1", + "name": "Shabby", + "description": "Decrease the value of items when sold by -10%", + "rank": -1, + "inheritable": true + }, + { + "id": "Stamina_Down_1", + "name": "Sickly", + "description": "Max Stamina -25%\n*This effect is only valid for rideable pals.", + "rank": -1, + "inheritable": true + }, + { + "id": "PAL_Sanity_Up_1", + "name": "Unstable", + "description": "SAN drops +10.0% faster.", + "rank": -1, + "inheritable": true + }, + { + "id": "PAL_FullStomach_Up_2", + "name": "Bottomless Stomach", + "description": "Hunger decreases +15.0% faster.", + "rank": -2, + "inheritable": true + }, + { + "id": "PAL_Sanity_Up_2", + "name": "Destructive", + "description": "SAN drops +15.0% faster.", + "rank": -2, + "inheritable": true + }, + { + "id": "Deffence_down2", + "name": "Brittle", + "description": "Defense -20%", + "rank": -3, + "inheritable": true + }, + { + "id": "PAL_ALLAttack_down2", + "name": "Pacifist", + "description": "Attack -20%", + "rank": -3, + "inheritable": true + }, + { + "id": "CraftSpeed_down2", + "name": "Slacker", + "description": "Work Speed -30%", + "rank": -3, + "inheritable": true + } + ] +} diff --git a/backend/internal/palgrant/passives.go b/backend/internal/palgrant/passives.go new file mode 100644 index 0000000..9f521de --- /dev/null +++ b/backend/internal/palgrant/passives.go @@ -0,0 +1,149 @@ +package palgrant + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" +) + +//go:embed data/passives.json +var passiveCatalogJSON []byte + +type PassiveSkill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Rank int `json:"rank"` + Inheritable bool `json:"inheritable"` +} + +type PassiveCatalogSource struct { + Name string `json:"name"` + Version string `json:"version"` + Commit string `json:"commit"` + URL string `json:"url"` +} + +type passiveCatalogFile struct { + SchemaVersion int `json:"schemaVersion"` + CatalogVersion string `json:"catalogVersion"` + Source PassiveCatalogSource `json:"source"` + Passives []PassiveSkill `json:"passives"` +} + +type PassiveCatalog struct { + version string + source PassiveCatalogSource + items []PassiveSkill + byID map[string]PassiveSkill +} + +func NewPassiveCatalog() (*PassiveCatalog, error) { + var file passiveCatalogFile + if err := json.Unmarshal(passiveCatalogJSON, &file); err != nil { + return nil, fmt.Errorf("decode embedded passive catalogue: %w", err) + } + if file.SchemaVersion != 1 || strings.TrimSpace(file.CatalogVersion) == "" || len(file.Passives) < 100 { + return nil, fmt.Errorf("embedded passive catalogue is incomplete or incompatible") + } + c := &PassiveCatalog{version: file.CatalogVersion, source: file.Source, byID: make(map[string]PassiveSkill, len(file.Passives))} + for _, item := range file.Passives { + item.ID, item.Name, item.Description = strings.TrimSpace(item.ID), strings.TrimSpace(item.Name), strings.TrimSpace(item.Description) + key := strings.ToLower(item.ID) + if !safeToken.MatchString(item.ID) || item.Name == "" || c.byID[key].ID != "" { + return nil, fmt.Errorf("embedded passive catalogue contains an invalid or duplicate ID") + } + c.byID[key] = item + c.items = append(c.items, item) + } + return c, nil +} + +func MustPassiveCatalog() *PassiveCatalog { + c, err := NewPassiveCatalog() + if err != nil { + panic(err) + } + return c +} + +func (c *PassiveCatalog) Count() int { return len(c.items) } +func (c *PassiveCatalog) Version() string { return c.version } +func (c *PassiveCatalog) Source() PassiveCatalogSource { return c.source } +func (c *PassiveCatalog) Passive(id string) (PassiveSkill, bool) { + item, ok := c.byID[strings.ToLower(strings.TrimSpace(id))] + return item, ok +} + +func (c *PassiveCatalog) Search(query string, limit int) []PassiveSkill { + if limit < 1 || limit > 200 { + limit = 100 + } + terms := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + out := make([]PassiveSkill, 0, limit) + for _, item := range c.items { + haystack := strings.ToLower(item.Name + " " + item.ID + " " + item.Description) + 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 +} + +func (c *PassiveCatalog) ValidateIDs(ids []string) error { + seen := make(map[string]bool, len(ids)) + for _, id := range ids { + key := strings.ToLower(strings.TrimSpace(id)) + if _, ok := c.byID[key]; !ok { + return fmt.Errorf("passive skill %q is not in the pinned game catalogue", id) + } + if seen[key] { + return fmt.Errorf("passive skill %q is duplicated", id) + } + seen[key] = true + } + return nil +} + +func PassiveTier(rank int) string { + switch { + case rank >= 5: + return "world_tree" + case rank == 4: + return "rainbow" + case rank == 3: + return "gold" + case rank == 2: + return "silver" + case rank == 1: + return "bronze" + default: + return "negative" + } +} + +// SortedRanks is useful to clients that want deterministic filter controls. +func (c *PassiveCatalog) SortedRanks() []int { + set := make(map[int]bool) + for _, item := range c.items { + set[item.Rank] = true + } + out := make([]int, 0, len(set)) + for rank := range set { + out = append(out, rank) + } + sort.Sort(sort.Reverse(sort.IntSlice(out))) + return out +} diff --git a/backend/internal/palgrant/passives_test.go b/backend/internal/palgrant/passives_test.go new file mode 100644 index 0000000..1fb6ea4 --- /dev/null +++ b/backend/internal/palgrant/passives_test.go @@ -0,0 +1,48 @@ +package palgrant + +import "testing" + +func TestPassiveCatalogContainsVersionPinnedRainbowAndWorldTreeTraits(t *testing.T) { + c := MustPassiveCatalog() + if c.Count() != 115 { + t.Fatalf("passive catalogue count = %d, want 115", c.Count()) + } + for _, test := range []struct { + id string + name string + tier string + }{ + {"CraftSpeed_up3", "Remarkable Craftsmanship", "rainbow"}, + {"PAL_ALLAttack_up3", "Demon God", "rainbow"}, + {"WorkSuitabilityAddRank_MonsterFarm_2", "Ranch Master", "rainbow"}, + {"WorldTree_ATK", "Twin-Edged Holy Blade", "world_tree"}, + } { + item, ok := c.Passive(test.id) + if !ok || item.Name != test.name || PassiveTier(item.Rank) != test.tier { + t.Fatalf("passive %q = %#v, found=%v", test.id, item, ok) + } + } + if c.Version() != "palcalc_v23_b5e13e90fedc" { + t.Fatalf("version = %q", c.Version()) + } +} + +func TestPassiveCatalogValidationRejectsUnknownAndDuplicateIDs(t *testing.T) { + c := MustPassiveCatalog() + if err := c.ValidateIDs([]string{"CraftSpeed_up3", "PAL_ALLAttack_up3"}); err != nil { + t.Fatal(err) + } + for _, ids := range [][]string{{"NotARealPassive"}, {"CraftSpeed_up3", "craftspeed_UP3"}} { + if err := c.ValidateIDs(ids); err == nil { + t.Fatalf("invalid passive IDs accepted: %#v", ids) + } + } +} + +func TestPassiveSearchIncludesDescriptions(t *testing.T) { + c := MustPassiveCatalog() + got := c.Search("work speed +75", 10) + if len(got) != 1 || got[0].ID != "CraftSpeed_up3" { + t.Fatalf("search result = %#v", got) + } +} diff --git a/backend/internal/palgrant/spool.go b/backend/internal/palgrant/spool.go new file mode 100644 index 0000000..9e53e82 --- /dev/null +++ b/backend/internal/palgrant/spool.go @@ -0,0 +1,211 @@ +package palgrant + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" +) + +const BridgeProtocolVersion = 1 +const heartbeatMaxAge = 45 * time.Second + +var ErrBridgeBusy = errors.New("Pal bridge already has a pending request") + +type Capability struct { + Enabled bool `json:"enabled"` + Ready bool `json:"ready"` + Reason string `json:"reason"` + ProtocolVersion int `json:"protocolVersion"` + GameVersion string `json:"gameVersion,omitempty"` + CatalogVersion string `json:"catalogVersion"` + CatalogItems int `json:"catalogItems"` + LastHeartbeatAt string `json:"lastHeartbeatAt,omitempty"` +} + +type heartbeat struct { + ProtocolVersion int `json:"protocolVersion"` + State string `json:"state"` + GameVersion string `json:"gameVersion"` + CatalogVersion string `json:"catalogVersion"` + CatalogItems int `json:"catalogItems"` + At string `json:"at"` +} + +type Request struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + CreatedAt string `json:"createdAt"` + PlayerUID string `json:"playerUid"` + PlayerName string `json:"playerName"` + CharacterID string `json:"characterId"` + Spec GenerationSpec `json:"spec"` +} + +type Result struct { + ProtocolVersion int `json:"protocolVersion"` + RequestID string `json:"requestId"` + Status string `json:"status"` + InstanceID string `json:"instanceId,omitempty"` + Level int `json:"level,omitempty"` + Gender string `json:"gender,omitempty"` + Alpha bool `json:"alpha,omitempty"` + Lucky bool `json:"lucky,omitempty"` + TalentHP *int `json:"talentHp,omitempty"` + TalentMelee *int `json:"talentMelee,omitempty"` + TalentShot *int `json:"talentShot,omitempty"` + TalentDefense *int `json:"talentDefense,omitempty"` + PassiveSkillIDs []string `json:"passiveSkillIds,omitempty"` + CondensationStars int `json:"condensationStars,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` + CompletedAt string `json:"completedAt"` +} + +type Spool struct{ Dir string } + +func NewRequestID() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (s Spool) Capability(enabled bool, catalog *Catalog, now time.Time) Capability { + c := Capability{Enabled: enabled, ProtocolVersion: BridgeProtocolVersion, CatalogVersion: CatalogVersion} + if catalog != nil { + c.CatalogItems = catalog.Count() + } + if !enabled { + c.Reason = "Pal grants are disabled in Palhelm configuration." + return c + } + if catalog == nil || catalog.Count() == 0 { + c.Reason = "The safe Pal creation catalogue is unavailable." + return c + } + b, err := os.ReadFile(filepath.Join(s.Dir, "capability.json")) + if err != nil { + c.Reason = "The Pal creation bridge has not reported ready." + return c + } + var h heartbeat + if json.Unmarshal(b, &h) != nil || h.ProtocolVersion != BridgeProtocolVersion { + c.Reason = "The Pal creation bridge protocol is incompatible." + return c + } + c.GameVersion, c.LastHeartbeatAt = strings.TrimSpace(h.GameVersion), h.At + at, err := time.Parse(time.RFC3339, h.At) + if err != nil || now.Sub(at) < 0 || now.Sub(at) > heartbeatMaxAge { + c.Reason = "The Pal creation bridge heartbeat is stale." + return c + } + if h.State != "ready" { + c.Reason = "The Pal creation bridge has not passed validation for this game build." + return c + } + if h.CatalogVersion != CatalogVersion || h.CatalogItems != catalog.Count() { + c.Reason = "The Pal bridge allowlist does not match Palhelm's Pal catalogue." + return c + } + c.Ready = true + return c +} + +func (s Spool) Submit(req Request) error { + if req.ProtocolVersion != BridgeProtocolVersion || !safeToken.MatchString(req.RequestID) || !safeToken.MatchString(req.PlayerUID) || !safeToken.MatchString(req.CharacterID) || req.Spec.Validate() != nil { + return errors.New("invalid Pal grant request") + } + requests, results := filepath.Join(s.Dir, "requests"), filepath.Join(s.Dir, "results") + if err := os.MkdirAll(requests, 0o700); err != nil { + return err + } + if err := os.MkdirAll(results, 0o700); err != nil { + return err + } + b, err := json.Marshal(req) + if err != nil { + return err + } + target := filepath.Join(requests, req.RequestID+".json") + if _, err := os.Stat(target); err == nil { + return os.ErrExist + } + tmp := target + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return err + } + if err := os.Link(target, filepath.Join(s.Dir, "inbox.json")); err != nil { + if os.IsExist(err) { + return ErrBridgeBusy + } + return err + } + return nil +} + +func (s Spool) Result(requestID string) (Result, error) { + if !safeToken.MatchString(requestID) { + return Result{}, errors.New("invalid request id") + } + f, err := os.Open(filepath.Join(s.Dir, "results", requestID+".json")) + if err != nil { + return Result{}, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1)) + if err != nil { + return Result{}, err + } + if len(b) > 64<<10 { + return Result{}, errors.New("bridge result exceeds 64 KiB") + } + var result Result + dec := json.NewDecoder(strings.NewReader(string(b))) + dec.DisallowUnknownFields() + if err := dec.Decode(&result); err != nil { + return Result{}, fmt.Errorf("decode Pal bridge result: %w", err) + } + if result.ProtocolVersion != BridgeProtocolVersion || result.RequestID != requestID || (result.Status != "succeeded" && result.Status != "failed") { + return Result{}, errors.New("invalid Pal bridge result") + } + if result.Status == "succeeded" { + if !safeToken.MatchString(result.InstanceID) || result.Level < 1 || result.Level > MaxSupportedLevel || (result.Gender != "male" && result.Gender != "female") || result.CondensationStars < 0 || result.CondensationStars > 4 { + return Result{}, errors.New("invalid successful Pal bridge result") + } + for _, value := range []*int{result.TalentHP, result.TalentMelee, result.TalentShot, result.TalentDefense} { + if value == nil || *value < 0 || *value > 100 { + return Result{}, errors.New("successful Pal bridge result has invalid IVs") + } + } + if len(result.PassiveSkillIDs) > 4 { + return Result{}, errors.New("successful Pal bridge result has too many passive skills") + } + seen := make(map[string]bool, len(result.PassiveSkillIDs)) + for _, id := range result.PassiveSkillIDs { + key := strings.ToLower(id) + if !safeToken.MatchString(id) || seen[key] { + return Result{}, errors.New("successful Pal bridge result has invalid passive skills") + } + seen[key] = true + } + } + if len(result.ErrorCode) > 100 || len(result.ErrorMessage) > 500 { + return Result{}, errors.New("bridge result error fields are too long") + } + if _, err := time.Parse(time.RFC3339, result.CompletedAt); err != nil { + return Result{}, errors.New("bridge result completedAt is invalid") + } + return result, nil +} diff --git a/backend/internal/palgrant/spool_test.go b/backend/internal/palgrant/spool_test.go new file mode 100644 index 0000000..d317324 --- /dev/null +++ b/backend/internal/palgrant/spool_test.go @@ -0,0 +1,82 @@ +package palgrant + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestSpoolStaysFailClosedUntilExactBridgeIsReady(t *testing.T) { + dir := t.TempDir() + catalog := NewCatalog() + spool := Spool{Dir: dir} + now := time.Now().UTC().Truncate(time.Second) + if got := spool.Capability(false, catalog, now); got.Ready { + t.Fatal("disabled capability reported ready") + } + body := `{"protocolVersion":1,"state":"validation_required","gameVersion":"1.0.1","catalogVersion":"palworld_1.0_pinned","catalogItems":` + fmtInt(catalog.Count()) + `,"at":"` + now.Format(time.RFC3339) + `"}` + if err := os.WriteFile(filepath.Join(dir, "capability.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if got := spool.Capability(true, catalog, now); got.Ready || got.Reason == "" { + t.Fatalf("unvalidated bridge = %#v", got) + } +} + +func TestResultRequiresCompleteBoundedGeneratedSpecimen(t *testing.T) { + dir := t.TempDir() + requestID := "1234567890abcdef" + value := 72 + result := Result{ + ProtocolVersion: BridgeProtocolVersion, + RequestID: requestID, + Status: "succeeded", + InstanceID: "abcdef1234567890", + Level: MaxSupportedLevel, + Gender: "female", + TalentHP: &value, + TalentMelee: &value, + TalentShot: &value, + TalentDefense: &value, + PassiveSkillIDs: []string{"CraftSpeed_up2"}, + CondensationStars: 2, + CompletedAt: time.Now().UTC().Format(time.RFC3339), + } + body, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "results"), 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "results", requestID+".json") + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (Spool{Dir: dir}).Result(requestID); err != nil { + t.Fatalf("valid result rejected: %v", err) + } + + result.TalentDefense = nil + body, _ = json.Marshal(result) + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + if _, err := (Spool{Dir: dir}).Result(requestID); err == nil { + t.Fatal("incomplete successful result accepted") + } +} + +func fmtInt(v int) string { + if v == 0 { + return "0" + } + b := make([]byte, 0, 8) + for v > 0 { + b = append([]byte{byte('0' + v%10)}, b...) + v /= 10 + } + return string(b) +} diff --git a/backend/internal/server/openapi.json b/backend/internal/server/openapi.json index a7f6397..ce00faf 100644 --- a/backend/internal/server/openapi.json +++ b/backend/internal/server/openapi.json @@ -30,6 +30,12 @@ "/api/v1/players/{uid}/item-grants": {"post": {"summary":"Queue an allowlisted item grant for one online player (admin only)","parameters":[{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateItemGrantRequest"}}}},"responses":{"200":{"description":"Idempotent replay of an existing request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"202":{"description":"Grant durably queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"400":{"description":"Invalid or non-grantable item request"},"403":{"description":"Administrator required"},"409":{"description":"Player offline, idempotency conflict, or bridge busy"},"503":{"description":"Catalogue/bridge capability is not ready"}}}}, "/api/v1/item-grants": {"get": {"summary":"Newest durable item-grant audit records (admin only)","responses":{"200":{"description":"Up to 100 grant records","content":{"application/json":{"schema":{"type":"object","required":["grants"],"properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/ItemGrant"}}}}}}},"403":{"description":"Administrator required"}}}}, "/api/v1/item-grants/{requestId}": {"get": {"summary":"One durable item-grant audit record (admin only)","parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current reconciled grant state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ItemGrant"}}}},"403":{"description":"Administrator required"},"404":{"description":"Grant not found"}}}}, + "/api/v1/pal-grants/catalog": {"get": {"summary":"Search the viewer-safe version-pinned Pal creation catalogue","parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50}}],"responses":{"200":{"description":"Safe species catalogue and supported level cap","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalGrantCatalog"}}}},"400":{"description":"Invalid limit"}}}}, + "/api/v1/pal-grants/passives": {"get": {"summary":"Search the complete version-pinned standard passive-skill catalogue","parameters":[{"name":"q","in":"query","schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":100}}],"responses":{"200":{"description":"Passive IDs, names, effects, tiers, and inheritance flags","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalPassiveCatalog"}}}},"400":{"description":"Invalid limit"}}}}, + "/api/v1/pal-grants/capability": {"get": {"responses":{"200":{"description":"Fail-closed Pal creation bridge capability","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalGrantCapability"}}}}}}}, + "/api/v1/players/{uid}/pal-grants": {"post": {"summary":"Queue one validated Palbox grant for an online player (admin only)","parameters":[{"name":"uid","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePalGrantRequest"}}}},"responses":{"200":{"description":"Idempotent replay of an existing request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalGrant"}}}},"202":{"description":"Pal grant durably queued","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalGrant"}}}},"400":{"description":"Invalid species or generation spec"},"403":{"description":"Administrator required"},"409":{"description":"Player offline, idempotency conflict, or bridge busy"},"503":{"description":"Pal creation bridge is not ready"}}}}, + "/api/v1/pal-grants": {"get": {"summary":"Newest durable Pal-grant audit records (admin only)","responses":{"200":{"description":"Up to 100 Pal-grant records","content":{"application/json":{"schema":{"type":"object","required":["grants"],"properties":{"grants":{"type":"array","items":{"$ref":"#/components/schemas/PalGrant"}}}}}}},"403":{"description":"Administrator required"}}}}, + "/api/v1/pal-grants/{requestId}": {"get": {"summary":"One durable Pal-grant audit record (admin only)","parameters":[{"name":"requestId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current reconciled Pal-grant state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PalGrant"}}}},"403":{"description":"Administrator required"},"404":{"description":"Pal grant not found"}}}}, "/api/v1/whitelist": {"get": {"responses": {"200": {"description": "Whitelist"}}}, "put": {"responses": {"200": {"description": "Whitelist replaced"}}}}, "/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"}}}}, @@ -229,6 +235,15 @@ "ItemGrantCapability": {"type":"object","required":["enabled","ready","reason","protocolVersion","catalogItems"],"properties":{"enabled":{"type":"boolean"},"ready":{"type":"boolean"},"reason":{"type":"string"},"protocolVersion":{"type":"integer"},"gameVersion":{"type":"string"},"catalogVersion":{"type":"string"},"catalogItems":{"type":"integer","minimum":0},"lastHeartbeatAt":{"type":"string","format":"date-time"}}}, "CreateItemGrantRequest": {"type":"object","additionalProperties":false,"required":["itemId","quantity","reason","idempotencyKey"],"properties":{"itemId":{"type":"string"},"quantity":{"type":"integer","minimum":1,"maximum":9999},"reason":{"type":"string","minLength":3,"maxLength":200},"idempotencyKey":{"type":"string","minLength":8,"maxLength":128,"pattern":"^[A-Za-z0-9._:-]+$"}}}, "ItemGrant": {"type":"object","required":["requestId","createdAt","updatedAt","actor","playerUid","playerName","itemId","itemName","quantity","reason","status"],"properties":{"requestId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"actor":{"type":"string"},"playerUid":{"type":"string"},"playerName":{"type":"string"},"itemId":{"type":"string"},"itemName":{"type":"string"},"quantity":{"type":"integer","minimum":1},"grantedQuantity":{"type":["integer","null"],"minimum":0},"reason":{"type":"string"},"status":{"type":"string","enum":["queued","succeeded","failed"]},"errorCode":{"type":"string"},"errorMessage":{"type":"string"}}}, + "GrantablePalSpecies": {"type":"object","required":["characterId","displayName"],"properties":{"characterId":{"type":"string","pattern":"^[A-Za-z0-9_]{1,160}$"},"displayName":{"type":"string"}}}, + "PalGrantCatalog": {"type":"object","required":["species","catalogVersion","catalogItems","maxLevel","passiveCatalogVersion","passiveCatalogItems"],"properties":{"species":{"type":"array","maxItems":100,"items":{"$ref":"#/components/schemas/GrantablePalSpecies"}},"catalogVersion":{"type":"string","const":"palworld_1.0_pinned"},"catalogItems":{"type":"integer","minimum":1},"maxLevel":{"type":"integer","const":80},"passiveCatalogVersion":{"type":"string"},"passiveCatalogItems":{"type":"integer","minimum":100}}}, + "GrantablePassiveSkill": {"type":"object","required":["id","name","description","rank","inheritable"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9_]{1,160}$"},"name":{"type":"string"},"description":{"type":"string"},"rank":{"type":"integer","minimum":-10,"maximum":10},"inheritable":{"type":"boolean"}}}, + "PalPassiveCatalog": {"type":"object","required":["passives","catalogVersion","catalogItems","ranks","source"],"properties":{"passives":{"type":"array","maxItems":200,"items":{"$ref":"#/components/schemas/GrantablePassiveSkill"}},"catalogVersion":{"type":"string"},"catalogItems":{"type":"integer","minimum":100},"ranks":{"type":"array","items":{"type":"integer"}},"source":{"type":"object","required":["name","version","commit","url"],"properties":{"name":{"type":"string"},"version":{"type":"string"},"commit":{"type":"string"},"url":{"type":"string"}}}}}, + "PalGrantCapability": {"type":"object","required":["enabled","ready","reason","protocolVersion","catalogVersion","catalogItems"],"properties":{"enabled":{"type":"boolean"},"ready":{"type":"boolean"},"reason":{"type":"string"},"protocolVersion":{"type":"integer"},"gameVersion":{"type":"string"},"catalogVersion":{"type":"string"},"catalogItems":{"type":"integer","minimum":0},"lastHeartbeatAt":{"type":"string","format":"date-time"}}}, + "PalGenerationSpec": {"type":"object","additionalProperties":false,"required":["mode","levelMin","levelMax","gender","alpha","lucky","ivMode","passiveSkillIds","condensationStars"],"properties":{"mode":{"type":"string","enum":["natural","custom"]},"levelMin":{"type":"integer","minimum":1,"maximum":80},"levelMax":{"type":"integer","minimum":1,"maximum":80},"gender":{"type":"string","enum":["random","male","female"]},"alpha":{"type":"boolean"},"lucky":{"type":"boolean"},"ivMode":{"type":"string","enum":["natural","minimum","exact"]},"talentHp":{"type":"integer","minimum":0,"maximum":100},"talentMelee":{"type":"integer","minimum":0,"maximum":100},"talentShot":{"type":"integer","minimum":0,"maximum":100},"talentDefense":{"type":"integer","minimum":0,"maximum":100},"passiveSkillIds":{"type":"array","maxItems":4,"uniqueItems":true,"items":{"type":"string","pattern":"^[A-Za-z0-9_]{1,160}$"}},"condensationStars":{"type":"integer","minimum":0,"maximum":4}}}, + "CreatePalGrantRequest": {"type":"object","additionalProperties":false,"required":["characterId","spec","reason","idempotencyKey"],"properties":{"characterId":{"type":"string"},"spec":{"$ref":"#/components/schemas/PalGenerationSpec"},"reason":{"type":"string","minLength":3,"maxLength":200},"idempotencyKey":{"type":"string","minLength":8,"maxLength":128,"pattern":"^[A-Za-z0-9._:-]+$"}}}, + "PalGrantResult": {"type":"object","required":["protocolVersion","requestId","status","completedAt"],"properties":{"protocolVersion":{"type":"integer"},"requestId":{"type":"string"},"status":{"type":"string","enum":["succeeded","failed"]},"instanceId":{"type":"string"},"level":{"type":"integer","minimum":1,"maximum":80},"gender":{"type":"string","enum":["male","female"]},"alpha":{"type":"boolean"},"lucky":{"type":"boolean"},"talentHp":{"type":"integer","minimum":0,"maximum":100},"talentMelee":{"type":"integer","minimum":0,"maximum":100},"talentShot":{"type":"integer","minimum":0,"maximum":100},"talentDefense":{"type":"integer","minimum":0,"maximum":100},"passiveSkillIds":{"type":"array","maxItems":4,"uniqueItems":true,"items":{"type":"string"}},"condensationStars":{"type":"integer","minimum":0,"maximum":4},"errorCode":{"type":"string"},"errorMessage":{"type":"string"},"completedAt":{"type":"string","format":"date-time"}}}, + "PalGrant": {"type":"object","required":["requestId","createdAt","updatedAt","actor","playerUid","playerName","characterId","displayName","spec","reason","status"],"properties":{"requestId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"actor":{"type":"string"},"playerUid":{"type":"string"},"playerName":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"spec":{"$ref":"#/components/schemas/PalGenerationSpec"},"reason":{"type":"string"},"status":{"type":"string","enum":["queued","succeeded","failed"]},"result":{"$ref":"#/components/schemas/PalGrantResult"},"errorCode":{"type":"string"},"errorMessage":{"type":"string"}}}, "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"}}}, diff --git a/backend/internal/server/pal_grants.go b/backend/internal/server/pal_grants.go new file mode 100644 index 0000000..87ac1ac --- /dev/null +++ b/backend/internal/server/pal_grants.go @@ -0,0 +1,190 @@ +package server + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "reflect" + "strconv" + "strings" + "time" + + "github.com/8tp/palhelm/internal/palgrant" + "github.com/8tp/palhelm/internal/store" + "github.com/go-chi/chi/v5" +) + +func (s *Server) palGrantCapability(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.palSpool.Capability(s.cfg.PalGrantsEnabled, s.palCatalog, time.Now().UTC())) +} + +func (s *Server) palGrantCatalog(w http.ResponseWriter, r *http.Request) { + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 100 { + writeError(w, 400, "invalid_limit", "limit must be from 1 to 100.") + return + } + limit = parsed + } + items := s.palCatalog.Search(r.URL.Query().Get("q"), limit) + writeJSON(w, http.StatusOK, map[string]any{"species": items, "catalogVersion": palgrant.CatalogVersion, "catalogItems": s.palCatalog.Count(), "maxLevel": palgrant.MaxSupportedLevel, "passiveCatalogVersion": s.palPassiveCatalog.Version(), "passiveCatalogItems": s.palPassiveCatalog.Count()}) +} + +func (s *Server) palGrantPassives(w http.ResponseWriter, r *http.Request) { + limit := 100 + if raw := r.URL.Query().Get("limit"); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 200 { + writeError(w, 400, "invalid_limit", "limit must be from 1 to 200.") + return + } + limit = parsed + } + items := s.palPassiveCatalog.Search(r.URL.Query().Get("q"), limit) + writeJSON(w, http.StatusOK, map[string]any{ + "passives": items, + "catalogVersion": s.palPassiveCatalog.Version(), + "catalogItems": s.palPassiveCatalog.Count(), + "ranks": s.palPassiveCatalog.SortedRanks(), + "source": s.palPassiveCatalog.Source(), + }) +} + +type createPalGrantRequest struct { + CharacterID string `json:"characterId"` + Spec palgrant.GenerationSpec `json:"spec"` + Reason string `json:"reason"` + IdempotencyKey string `json:"idempotencyKey"` +} + +func (s *Server) createPalGrant(w http.ResponseWriter, r *http.Request) { + capability := s.palSpool.Capability(s.cfg.PalGrantsEnabled, s.palCatalog, time.Now().UTC()) + if !capability.Ready { + writeError(w, http.StatusServiceUnavailable, "pal_grants_unavailable", capability.Reason) + return + } + var req createPalGrantRequest + if !decode(w, r, &req) { + return + } + req.CharacterID, req.Reason, req.IdempotencyKey = strings.TrimSpace(req.CharacterID), strings.TrimSpace(req.Reason), strings.TrimSpace(req.IdempotencyKey) + if !idempotencyKeyPattern.MatchString(req.IdempotencyKey) { + writeError(w, 400, "invalid_idempotency_key", "idempotencyKey must be 8 to 128 safe characters.") + return + } + if len(req.Reason) < 3 || len(req.Reason) > 200 { + writeError(w, 400, "invalid_reason", "reason must be from 3 to 200 characters.") + return + } + species, ok := s.palCatalog.Species(req.CharacterID) + if !ok { + writeError(w, 400, "pal_not_grantable", "That species or form is not in the safe Pal creation catalogue.") + return + } + if err := req.Spec.Validate(); err != nil { + writeError(w, 400, "invalid_pal_spec", err.Error()+".") + return + } + if err := s.palPassiveCatalog.ValidateIDs(req.Spec.PassiveSkillIDs); err != nil { + writeError(w, 400, "invalid_pal_spec", err.Error()+".") + return + } + uid := s.store.ResolveUID(r.Context(), chi.URLParam(r, "uid")) + player, err := s.store.PlayerByUID(r.Context(), uid) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, 404, "player_not_found", "Player not found.") + return + } + if err != nil { + internal(w, err) + return + } + if !s.poll.Online()[player.UID] { + writeError(w, 409, "player_offline", "The player must be online to receive a Pal safely.") + return + } + now := time.Now().UTC() + requestID, err := palgrant.NewRequestID() + if err != nil { + internal(w, err) + return + } + grant := store.PalGrant{RequestID: requestID, IdempotencyKey: req.IdempotencyKey, CreatedAt: now, UpdatedAt: now, Actor: principalFrom(r).Username, PlayerUID: player.UID, PlayerName: player.Name, CharacterID: species.CharacterID, DisplayName: species.DisplayName, Spec: req.Spec, Reason: req.Reason, Status: "queued"} + grant, created, err := s.store.CreatePalGrant(r.Context(), grant) + if err != nil { + internal(w, err) + return + } + if !created { + if grant.PlayerUID != player.UID || grant.CharacterID != species.CharacterID || grant.Reason != req.Reason || !reflect.DeepEqual(grant.Spec, req.Spec) { + writeError(w, 409, "idempotency_conflict", "That idempotency key was already used for a different Pal grant.") + return + } + writeJSON(w, 200, s.reconcilePalGrant(r, grant)) + return + } + bridgeReq := palgrant.Request{ProtocolVersion: palgrant.BridgeProtocolVersion, RequestID: requestID, CreatedAt: now.Format(time.RFC3339), PlayerUID: player.UID, PlayerName: player.Name, CharacterID: species.CharacterID, Spec: req.Spec} + if err := s.palSpool.Submit(bridgeReq); err != nil { + _ = s.store.CompletePalGrant(r.Context(), requestID, "failed", nil, "bridge_submit_failed", "The Pal bridge could not accept the request.", now) + if errors.Is(err, palgrant.ErrBridgeBusy) { + writeError(w, http.StatusConflict, "pal_grant_busy", "Another Pal grant is already being processed; wait for it to finish.") + return + } + internal(w, err) + return + } + s.audit(r, "pal_grant", fmt.Sprintf("Queued Lv. %d-%d %s for %s", req.Spec.LevelMin, req.Spec.LevelMax, species.DisplayName, player.Name), map[string]any{"requestId": requestID, "playerUid": player.UID, "characterId": species.CharacterID, "mode": req.Spec.Mode}) + writeJSON(w, http.StatusAccepted, grant) +} + +func (s *Server) getPalGrant(w http.ResponseWriter, r *http.Request) { + grant, err := s.store.PalGrant(r.Context(), chi.URLParam(r, "requestId")) + if errors.Is(err, sql.ErrNoRows) { + writeError(w, 404, "grant_not_found", "Pal grant not found.") + return + } + if err != nil { + internal(w, err) + return + } + writeJSON(w, 200, s.reconcilePalGrant(r, grant)) +} + +func (s *Server) listPalGrants(w http.ResponseWriter, r *http.Request) { + grants, err := s.store.PalGrants(r.Context(), 100) + if err != nil { + internal(w, err) + return + } + for index := range grants { + grants[index] = s.reconcilePalGrant(r, grants[index]) + } + writeJSON(w, 200, map[string]any{"grants": grants}) +} + +func (s *Server) reconcilePalGrant(r *http.Request, grant store.PalGrant) store.PalGrant { + if grant.Status != "queued" { + return grant + } + result, err := s.palSpool.Result(grant.RequestID) + if err != nil { + return grant + } + completedAt := time.Now().UTC() + if parsed, parseErr := time.Parse(time.RFC3339, result.CompletedAt); parseErr == nil && parsed.After(completedAt.Add(-5*time.Minute)) && parsed.Before(completedAt.Add(5*time.Minute)) { + completedAt = parsed + } + var storedResult *palgrant.Result + if result.Status == "succeeded" { + storedResult = &result + } + _ = s.store.CompletePalGrant(r.Context(), grant.RequestID, result.Status, storedResult, result.ErrorCode, result.ErrorMessage, completedAt) + updated, err := s.store.PalGrant(r.Context(), grant.RequestID) + if err == nil { + return updated + } + return grant +} diff --git a/backend/internal/server/pal_grants_test.go b/backend/internal/server/pal_grants_test.go new file mode 100644 index 0000000..c71db03 --- /dev/null +++ b/backend/internal/server/pal_grants_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "github.com/8tp/palhelm/internal/store" +) + +func TestPalGrantCatalogIsSafeAndMutationDefaultsFailClosed(t *testing.T) { + h, st := newItemGrantTestServer(t) + uid := "abcdef12000000000000000000000000" + if err := st.UpsertLivePlayer(context.Background(), store.Player{UID: uid, Name: "Player"}, time.Now().UTC()); err != nil { + t.Fatal(err) + } + viewer := loginAs(t, h, "viewerpass") + admin := loginAs(t, h, "panelpass") + catalog := sessionRequest(h, http.MethodGet, "/api/v1/pal-grants/catalog?q=anub", "", viewer) + if catalog.Code != 200 || !strings.Contains(catalog.Body.String(), `"displayName":"Anubis"`) || strings.Contains(catalog.Body.String(), "Hunter_Rifle") { + t.Fatalf("catalog status=%d body=%s", catalog.Code, catalog.Body.String()) + } + passives := sessionRequest(h, http.MethodGet, "/api/v1/pal-grants/passives?q=remarkable", "", viewer) + if passives.Code != 200 || !strings.Contains(passives.Body.String(), `"id":"CraftSpeed_up3"`) || !strings.Contains(passives.Body.String(), `"rank":4`) { + t.Fatalf("passive catalog status=%d body=%s", passives.Code, passives.Body.String()) + } + capability := sessionRequest(h, http.MethodGet, "/api/v1/pal-grants/capability", "", viewer) + if capability.Code != 200 || !strings.Contains(capability.Body.String(), `"ready":false`) { + t.Fatalf("capability status=%d body=%s", capability.Code, capability.Body.String()) + } + body := `{"characterId":"anubis","spec":{"mode":"natural","levelMin":20,"levelMax":25,"gender":"random","alpha":false,"lucky":false,"ivMode":"natural","passiveSkillIds":[],"condensationStars":0},"reason":"event reward","idempotencyKey":"pal-test-123"}` + if got := sessionRequest(h, http.MethodPost, "/api/v1/players/"+uid+"/pal-grants", body, viewer); got.Code != http.StatusForbidden { + t.Fatalf("viewer mutation status=%d body=%s", got.Code, got.Body.String()) + } + if got := sessionRequest(h, http.MethodPost, "/api/v1/players/"+uid+"/pal-grants", body, admin); got.Code != http.StatusServiceUnavailable || !strings.Contains(got.Body.String(), "pal_grants_unavailable") { + t.Fatalf("disabled mutation status=%d body=%s", got.Code, got.Body.String()) + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index f09b24e..86f7d76 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/8tp/palhelm/internal/config" "github.com/8tp/palhelm/internal/gameconfig" "github.com/8tp/palhelm/internal/itemgrant" + "github.com/8tp/palhelm/internal/palgrant" "github.com/8tp/palhelm/internal/palworld" "github.com/8tp/palhelm/internal/poller" "github.com/8tp/palhelm/internal/steamavatar" @@ -33,24 +34,27 @@ var openapi []byte // Server owns routing and application services. type Server struct { - cfg config.Config - store *store.Store - pal *palworld.Client - rcon *palworld.RCONClient - poll *poller.Service - health *poller.Health - hub *Hub - auth *auth - shutdown *orchestrator - backups *backup.Engine - gamecfg *gameconfig.Editor - integration *integrationAuth - avatars *steamavatar.Resolver - itemCatalog *itemgrant.Catalog - itemSpool itemgrant.Spool - diskStat diskStatFunc - started time.Time - log *slog.Logger + cfg config.Config + store *store.Store + pal *palworld.Client + rcon *palworld.RCONClient + poll *poller.Service + health *poller.Health + hub *Hub + auth *auth + shutdown *orchestrator + backups *backup.Engine + gamecfg *gameconfig.Editor + integration *integrationAuth + avatars *steamavatar.Resolver + itemCatalog *itemgrant.Catalog + itemSpool itemgrant.Spool + palCatalog *palgrant.Catalog + palPassiveCatalog *palgrant.PassiveCatalog + palSpool palgrant.Spool + diskStat diskStatFunc + started time.Time + log *slog.Logger } // New creates a fully wired HTTP server and poller service. @@ -71,7 +75,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), itemSpool: itemgrant.Spool{Dir: cfg.ItemGrantSpoolDir}, diskStat: statfsDiskUsage, 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), itemSpool: itemgrant.Spool{Dir: cfg.ItemGrantSpoolDir}, palCatalog: palgrant.NewCatalog(), palPassiveCatalog: palgrant.MustPassiveCatalog(), palSpool: palgrant.Spool{Dir: cfg.PalGrantSpoolDir}, diskStat: statfsDiskUsage, started: time.Now(), log: log} if catalog, catalogErr := itemgrant.LoadCatalog(cfg.ItemCatalogPath); catalogErr != nil { if cfg.ItemGrantsEnabled { log.Error("item grants unavailable: catalogue failed validation", "error", catalogErr, "path", cfg.ItemCatalogPath) @@ -207,6 +211,9 @@ func (s *Server) routes() http.Handler { api.Get("/items", s.items) api.Get("/items/capability", s.itemGrantCapability) api.Get("/items/{itemId}/icon", s.itemIcon) + api.Get("/pal-grants/catalog", s.palGrantCatalog) + api.Get("/pal-grants/passives", s.palGrantPassives) + api.Get("/pal-grants/capability", s.palGrantCapability) api.Get("/console/log", s.consoleLog) api.Get("/console/saved", s.savedCommands) api.Get("/events", s.events) @@ -231,6 +238,9 @@ func (s *Server) routes() http.Handler { m.Post("/players/{uid}/item-grants", s.createItemGrant) m.Get("/item-grants", s.listItemGrants) m.Get("/item-grants/{requestId}", s.getItemGrant) + m.Post("/players/{uid}/pal-grants", s.createPalGrant) + m.Get("/pal-grants", s.listPalGrants) + m.Get("/pal-grants/{requestId}", s.getPalGrant) m.Put("/whitelist", s.putWhitelist) m.Post("/world/parse", s.parseWorld) m.Post("/console/exec", s.consoleExec) diff --git a/backend/internal/store/migrations/014_pal_grants.sql b/backend/internal/store/migrations/014_pal_grants.sql new file mode 100644 index 0000000..7c8fc92 --- /dev/null +++ b/backend/internal/store/migrations/014_pal_grants.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS pal_grants ( + request_id TEXT PRIMARY KEY, + idempotency_key TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + actor TEXT NOT NULL, + player_uid TEXT NOT NULL, + player_name TEXT NOT NULL, + character_id TEXT NOT NULL, + display_name TEXT NOT NULL, + spec_json TEXT NOT NULL, + result_json TEXT, + reason TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('queued','succeeded','failed')), + error_code TEXT NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS pal_grants_created_at ON pal_grants(created_at DESC); +CREATE INDEX IF NOT EXISTS pal_grants_player_uid ON pal_grants(player_uid, created_at DESC); diff --git a/backend/internal/store/pal_grants.go b/backend/internal/store/pal_grants.go new file mode 100644 index 0000000..7e29d36 --- /dev/null +++ b/backend/internal/store/pal_grants.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/8tp/palhelm/internal/palgrant" +) + +type PalGrant struct { + RequestID string `json:"requestId"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Actor string `json:"actor"` + PlayerUID string `json:"playerUid"` + PlayerName string `json:"playerName"` + CharacterID string `json:"characterId"` + DisplayName string `json:"displayName"` + Spec palgrant.GenerationSpec `json:"spec"` + Result *palgrant.Result `json:"result,omitempty"` + Reason string `json:"reason"` + Status string `json:"status"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + +func (s *Store) CreatePalGrant(ctx context.Context, g PalGrant) (PalGrant, bool, error) { + g.PlayerUID = NormalizeUID(g.PlayerUID) + spec, err := json.Marshal(g.Spec) + if err != nil { + return PalGrant{}, false, err + } + _, err = s.db.ExecContext(ctx, `INSERT INTO pal_grants(request_id,idempotency_key,created_at,updated_at,actor,player_uid,player_name,character_id,display_name,spec_json,reason,status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, + g.RequestID, g.IdempotencyKey, g.CreatedAt.Unix(), g.UpdatedAt.Unix(), g.Actor, g.PlayerUID, g.PlayerName, g.CharacterID, g.DisplayName, string(spec), g.Reason, g.Status) + if err == nil { + return g, true, nil + } + existing, getErr := s.PalGrantByIdempotencyKey(ctx, g.IdempotencyKey) + if getErr == nil { + return existing, false, nil + } + return PalGrant{}, false, err +} + +func (s *Store) PalGrantByIdempotencyKey(ctx context.Context, key string) (PalGrant, error) { + return scanPalGrant(s.db.QueryRowContext(ctx, palGrantSelect+" WHERE idempotency_key=?", key)) +} + +func (s *Store) PalGrant(ctx context.Context, requestID string) (PalGrant, error) { + return scanPalGrant(s.db.QueryRowContext(ctx, palGrantSelect+" WHERE request_id=?", requestID)) +} + +func (s *Store) PalGrants(ctx context.Context, limit int) ([]PalGrant, error) { + if limit < 1 || limit > 200 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, palGrantSelect+" ORDER BY created_at DESC LIMIT ?", limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]PalGrant, 0) + for rows.Next() { + grant, err := scanPalGrant(rows) + if err != nil { + return nil, err + } + out = append(out, grant) + } + return out, rows.Err() +} + +func (s *Store) CompletePalGrant(ctx context.Context, requestID, status string, result *palgrant.Result, code, message string, now time.Time) error { + if status != "succeeded" && status != "failed" { + return errors.New("invalid Pal grant status") + } + var resultJSON any + if result != nil { + body, err := json.Marshal(result) + if err != nil { + return err + } + resultJSON = string(body) + } + updated, err := s.db.ExecContext(ctx, `UPDATE pal_grants SET updated_at=?,status=?,result_json=?,error_code=?,error_message=? WHERE request_id=? AND status='queued'`, now.Unix(), status, resultJSON, code, message, requestID) + if err != nil { + return err + } + n, err := updated.RowsAffected() + if err != nil { + return err + } + if n == 0 { + _, lookupErr := s.PalGrant(ctx, requestID) + return lookupErr + } + return nil +} + +const palGrantSelect = `SELECT request_id,idempotency_key,created_at,updated_at,actor,player_uid,player_name,character_id,display_name,spec_json,result_json,reason,status,error_code,error_message FROM pal_grants` + +func scanPalGrant(row itemGrantScanner) (PalGrant, error) { + var grant PalGrant + var created, updated int64 + var specJSON string + var resultJSON sql.NullString + if err := row.Scan(&grant.RequestID, &grant.IdempotencyKey, &created, &updated, &grant.Actor, &grant.PlayerUID, &grant.PlayerName, &grant.CharacterID, &grant.DisplayName, &specJSON, &resultJSON, &grant.Reason, &grant.Status, &grant.ErrorCode, &grant.ErrorMessage); err != nil { + return PalGrant{}, err + } + if err := json.Unmarshal([]byte(specJSON), &grant.Spec); err != nil { + return PalGrant{}, fmt.Errorf("decode stored Pal grant spec: %w", err) + } + if resultJSON.Valid { + var result palgrant.Result + if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil { + return PalGrant{}, fmt.Errorf("decode stored Pal grant result: %w", err) + } + grant.Result = &result + } + grant.CreatedAt, grant.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() + return grant, nil +} diff --git a/backend/internal/store/pal_grants_test.go b/backend/internal/store/pal_grants_test.go new file mode 100644 index 0000000..73d86f2 --- /dev/null +++ b/backend/internal/store/pal_grants_test.go @@ -0,0 +1,38 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/8tp/palhelm/internal/palgrant" +) + +func TestPalGrantPersistenceAndIdempotency(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "pal-grants.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + now := time.Now().UTC().Truncate(time.Second) + spec := palgrant.DefaultSpec() + spec.LevelMin, spec.LevelMax = 20, 25 + grant := PalGrant{RequestID: "request123", IdempotencyKey: "key-12345", CreatedAt: now, UpdatedAt: now, Actor: "admin", PlayerUID: "ABCDEF12000000000000000000000000", PlayerName: "Player", CharacterID: "anubis", DisplayName: "Anubis", Spec: spec, Reason: "event reward", Status: "queued"} + created, fresh, err := st.CreatePalGrant(context.Background(), grant) + if err != nil || !fresh || created.PlayerUID != "abcdef12000000000000000000000000" { + t.Fatalf("create = %#v fresh=%v err=%v", created, fresh, err) + } + replayed, fresh, err := st.CreatePalGrant(context.Background(), grant) + if err != nil || fresh || replayed.RequestID != grant.RequestID { + t.Fatalf("replay = %#v fresh=%v err=%v", replayed, fresh, err) + } + result := &palgrant.Result{ProtocolVersion: 1, RequestID: grant.RequestID, Status: "succeeded", InstanceID: "12345678abcdef00", Level: 23, Gender: "female", CompletedAt: now.Format(time.RFC3339)} + if err := st.CompletePalGrant(context.Background(), grant.RequestID, "succeeded", result, "", "", now.Add(time.Second)); err != nil { + t.Fatal(err) + } + got, err := st.PalGrant(context.Background(), grant.RequestID) + if err != nil || got.Result == nil || got.Result.Level != 23 || got.Status != "succeeded" { + t.Fatalf("stored = %#v err=%v", got, err) + } +} diff --git a/backend/internal/store/store_migration_audit_test.go b/backend/internal/store/store_migration_audit_test.go index e34e4b7..de671b8 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 != "13" { - t.Fatalf("schema_version = %q, %v; want 13", v, err) + if v, err := st.GetKV(ctx, "schema_version"); err != nil || v != "14" { + t.Fatalf("schema_version = %q, %v; want 14", 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 != "13" { - t.Fatalf("schema_version after replay = %q, %v; want repaired to 13", v, err) + if v, err := reopened.GetKV(ctx, "schema_version"); err != nil || v != "14" { + t.Fatalf("schema_version after replay = %q, %v; want repaired to 14", 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='14' WHERE key='schema_version'`); err != nil { + if _, err := legacy.Exec(`UPDATE kv SET value='15' 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 14 succeeded") + t.Fatal("Open on schema_version 15 succeeded") } - for _, needle := range []string{"14", "13", "newer than this binary supports"} { + for _, needle := range []string{"15", "14", "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 != "13" { + if v, err := st.GetKV(context.Background(), "schema_version"); err != nil || v != "14" { st.Close() - t.Fatalf("round %d: schema_version = %q, %v; want 13", round, v, err) + t.Fatalf("round %d: schema_version = %q, %v; want 14", 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 != "13" { + if err = v03.QueryRow(`SELECT value FROM kv WHERE key='schema_version'`).Scan(&v); err != nil || v != "14" { 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 b89d84d..99bec35 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 != "13" { - t.Fatalf("schema_version = %q, %v; want 13", v, getErr) + if v, getErr := st.GetKV(context.Background(), "schema_version"); getErr != nil || v != "14" { + t.Fatalf("schema_version = %q, %v; want 14", 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 != "13" { - t.Fatalf("schema_version = %q, %v; want 13", v, err) + if err != nil || v != "14" { + t.Fatalf("schema_version = %q, %v; want 14", 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 != "13" { - t.Fatalf("schema_version after upgrade = %q, %v; want 13", v, err) + if err != nil || v != "14" { + t.Fatalf("schema_version after upgrade = %q, %v; want 14", 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 != "13" { - t.Fatalf("schema_version after no-op reopen = %q, %v; want 13", v, err) + if v, err = reopened.GetKV(ctx, "schema_version"); err != nil || v != "14" { + t.Fatalf("schema_version after no-op reopen = %q, %v; want 14", v, err) } } diff --git a/docs/API.md b/docs/API.md index 4d3748f..9da9c8d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -41,6 +41,12 @@ Operation-specific recovery details, such as Config's `manualCommand`, stay insi | POST | `/players/{uid}/item-grants` | **Admin only.** `{itemId, quantity, reason, idempotencyKey}` → `202` durable queued grant. Online players and allowlisted items only. Never accepts a display-name target or arbitrary command. | | GET | `/item-grants` | **Admin only.** Newest 100 durable audit records, reconciling any bridge result files. | | GET | `/item-grants/{requestId}` | **Admin only.** One audited grant and its current `queued`, `succeeded`, or `failed` state. A missing/late result is never retried automatically. | +| GET | `/pal-grants/catalog?q=&limit=50` | Viewer-safe Pal creation allowlist from the version-pinned Paldeck. Excludes humans and `BOSS_` save variants; Alpha is a specimen flag. Returns the current supported level cap and passive-catalogue version/count. | +| GET | `/pal-grants/passives?q=&limit=100` | Viewer-safe pinned standard-passive catalogue. Returns up to 200 searchable IDs with display name, description, numeric rank, normal inheritance flag, source provenance, and catalogue version. Includes negative through rainbow and World Tree traits; it does not itself authorize mutation. | +| GET | `/pal-grants/capability` | Fail-closed Pal creation provider state. `ready` requires explicit enablement, a matching safe catalogue, a fresh heartbeat, and exact-build bridge validation. | +| POST | `/players/{uid}/pal-grants` | **Admin only.** `{characterId, spec, reason, idempotencyKey}` → `202` durable queued Palbox grant. Online players and bounded version-pinned specs only. `passiveSkillIds` accepts at most four unique IDs and each must exist in the pinned standard-passive catalogue. | +| GET | `/pal-grants` | **Admin only.** Newest 100 durable Pal-grant audit records, reconciling result files without retrying a creation. | +| GET | `/pal-grants/{requestId}` | **Admin only.** One audited Pal grant and its current `queued`, `succeeded`, or `failed` state. | | GET/PUT | `/whitelist` | Legacy path for the local player-annotation ledger: `[{steamId, name?}]`; PUT replaces. It does **not** enforce who may join. | ## Guilds diff --git a/docs/GIVE-PAL-PLAN.md b/docs/GIVE-PAL-PLAN.md new file mode 100644 index 0000000..b40f8e2 --- /dev/null +++ b/docs/GIVE-PAL-PLAN.md @@ -0,0 +1,255 @@ +# Give Pal — implementation scope + +Status: panel/backend contract and fail-closed runtime bridge are installed; +the runtime bridge remains `validation_required` after rejected reflected-RPC +and Lua-native creation paths and is unavailable for ordinary panel requests. + +## User experience + +From an online player's detail panel, an administrator selects **Give Pal…**, +searches the safe Pal catalogue, chooses a level or level range, supplies a reason, +reviews the generated specimen, and confirms once. + +The default **Natural roll** delegates gender, IVs, passives, active skills, and +other specimen details to the validated game-side generator. **Custom** exposes +only fields Palhelm can validate honestly: + +- gender: random, female, or male; +- normal, Alpha, or Lucky (Alpha and Lucky cannot both be forced); +- natural, minimum, or exact HP/Melee Attack/Ranged Attack/Defense IVs from 0–100; +- condensation from 0–4 displayed stars, kept distinct from the save's 1–5 rank; +- zero through four unique standard passive skills from a pinned PalCalc v23 + catalogue. The editor includes negative, bronze, silver, gold, rainbow, and + World Tree traits, shows their effect and normal inheritance status, and treats + rare/fixed selections as an explicit audited admin override; +- level range from 1 through the pinned 1.0 cap of 80. + +Unknown or duplicate raw passive IDs are rejected by the backend even when a +request bypasses the panel. Active skills remain out of v1 until their exact write +and read-back fields are validated. The capture-native candidate can deliver to a +free party slot before the Palbox, so v1 must report and verify the actual owned +destination rather than claiming Palbox-only delivery. + +## Implemented offline + +- Independent `PALHELM_PAL_GRANTS_ENABLED` flag, default false. +- Independent Pal grant spool and capability heartbeat. +- Safe catalogue built from Palhelm's pinned 1.0 Paldeck; humans and BOSS_ save + variants are excluded. +- Complete embedded standard-passive catalogue generated from pinned PalCalc + commit `b5e13e90fedc2e95d54fa223da77be464c313001`, plus searchable viewer API + and a four-slot expert selector. +- Bounded generation-spec validation. +- Admin-only API contract, online-player check, idempotency, audit event, durable + request/result history, and no automatic retry. +- Panel search, Pal icons, natural/custom controls, review screen, status polling, + and recent history. +- Fail-closed UE4SS bridge with exact one-request validation gates, authoritative + owner/container read-back, and no retry after an uncertain mutation. + +## Runtime reverse-engineering checkpoint + +The locally retained `v1.0.1.100619` Linux UHT dump and installed symbol bundle +expose these relevant names. They are historical discovery inputs, not current- +build validation artifacts: + +```text +APalPlayerState::RequestSpawnMonsterForPlayer(FName, int, int) +APalPlayerState::Debug_CaptureNewMonster_ToServer(FName) +APalPlayerState::Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer( + FPalDebugOtomoPalInfo, bool bRandomPassiveSkill) +UPalUtility::PalCaptureSuccessFromHandle( + APalPlayerCharacter*, UPalIndividualCharacterHandle*) +``` + +The first live path constructed a synthetic `UPalCheatManager`; the reflected call +returned but created nothing. The second used the real player's +`RequestSpawnMonsterForPlayer` server RPC with Palworld's Palbox-transport debug +flag enabled. A player-bound preflight passed, but the call again produced no new +party or Palbox specimen. Both outcomes were verified against owned instance IDs +and were never retried. + +The next staged path uses the real player's reflected +`Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer` RPC. Its +`FPalDebugOtomoPalInfo` parameter contains species, level, rank, talent preset, +active-skill, passive-skill, status-rank, friendship, and awakening fields. The +installed UE4SS build contains Lua-table-to-`UScriptStruct` conversion support, +so the bridge can populate the parameter without raw memory writes. The bridge +supplies only the allowlisted species, requested level, uncondensed rank, and +natural-generation defaults, then asks Palworld to randomize passives. It +snapshots and verifies both party and Palbox because normal capture semantics can +use either. + +The same loaded bridge includes bounded custom level/range, gender, generic +Alpha (`BOSS_` form), Lucky, exact/minimum IV, passive, and condensation +handling. `AdvancedEnabled` loads those paths without bypassing the independent +production-validation gate. Runtime validation policy is re-read from +`config.lua`, allowing promotion after the natural specimen survives +save/reconnect testing without another game restart. + +### Lua-native capture route rejected (2026-07-19) + +After the player-bound debug capture RPC returned without creating a specimen, +the exact-build UHT dump exposed a theoretically cleaner normal-capture route: + +```text +UPalUtility::GetInitializedCharacterSaveParemter(..., out SaveParameter) +UPalCharacterManager::SpawnNewCharacter(SaveParameter, SpawnParameter, Callback) +UPalUtility::PalCaptureSuccess(PlayerCharacter, SpawnedPalActor) +``` + +The first read-only live probe stopped before `SpawnNewCharacter`, but the +installed experimental Linux UE4SS beta corrupted the allocator while marshalling +the large `FPalIndividualCharacterSaveParameter&` out struct. Palworld raised an +`FMallocBinned2 Attempt to realloc an unrecognized block` fatal error and SIGSEGV; +Docker then restarted it under the existing `unless-stopped` policy. No Pal was +created and this Lua route must not be retried on the live server. + +The next implementation candidate is therefore a version-pinned UE4SS C++ bridge. +It calls reflected Palworld functions with native parameter storage, retaining the +existing Palhelm spool, idempotency, capacity, read-back, cleanup, and no-retry-on- +uncertainty gates. The installed `.sym` file cannot provide the native handle +helpers: it predates the current executable, and offline decoding proved its +records do not land on the named functions in the current ELF. Those addresses +are prohibited. The first non-mutating C++ capability artifact now lives in +`mods/palhelm-pal-native`; it must not be installed or loaded without a fresh +backup and explicit restart approval. + +## Existing-mod audit (2026-07-18) + +### Admin Commands (Server Side) 3.0.2 + +This current UE4SS mod advertises server-only Linux, Proton/Wine, and Windows +support, with `/spawn` and `/capture` commands. That is useful feasibility +evidence for native Linux UE4SS. It is not a safe implementation dependency for +Palhelm: + +- `/capture`/the newer spawn workflow creates or hijacks a world actor and then + auto-captures it rather than documenting a direct Pal-storage call; +- the maintainer currently documents a 1.0-era bug where a captured Pal may not be + summonable or movable until the player rejoins or the server restarts; +- command targeting and success text are not equivalent to verifying the final + owner, container, slot, and persistent individual; +- the distributed implementation is not licensed for reuse or modification. + +Palhelm should therefore not copy the spawn-then-capture route. It may be useful +only as an isolated behavioral comparison during the same future maintenance +window. + +### PalDefender 1.8.x + +PalDefender exposes direct `/givepal` and bearer-authenticated +`POST /v1/pdapi/give/pals/{player_identifier}` operations. Its documented design +provides several patterns worth matching: + +- stable platform ID or PlayerUID targeting, never a display name; +- Pal-storage capacity validation before mutation; +- execution on the game thread with a bounded five-second request deadline; +- a simple natural path where only Pal ID and level are supplied; +- filename-selected templates for advanced specimens instead of arbitrary JSON + embedded in a command; +- server-wide and per-species import policy that can block or clamp levels, ranks, + IVs, souls, passives, and forbidden species; +- separate validation, timeout, storage-full, and request-failure outcomes. + +PalDefender itself cannot be our native-Linux implementation: it is currently +Windows-only and closed source. A future provider adapter could use its documented +REST API on Windows/Proton deployments, while the native Linux provider retains +the same Palhelm-side contract. + +### PalSchema + +PalSchema is valuable for versioned data-table and asset changes, but it is not a +player-targeted runtime Pal-delivery API. It should not sit in the critical grant +path. + +## Contract changes from the audit + +Before the bridge can report `ready`, it must additionally prove: + +1. Party and Palbox free-space are checked before individual creation. +2. Delivery uses Palworld's authoritative capture/grant logic; Palhelm itself + never writes a live save or raw container memory. +3. Success is returned only after owner, container, slot, instance ID, and generated + fields can be read back from the authoritative handle/container. +4. Game-thread work has a bounded deadline, but a timeout after dispatch is + `outcome_unknown` and is never retried. +5. The exact-build UE4SS `MemberVariableLayout.ini` is present and validated. +6. Advanced templates eventually pass through a separate operator policy layer; + the current hard validator remains the upper safety boundary. + +The first production tranche remains natural generation plus bounded +gender/Alpha/Lucky/IV/rank/passive overrides. Active skills, learned skills, +souls, work-suitability overrides, skins, and nicknames remain disabled until +their exact 1.0 fields, versioned ID catalogues, and policy rules are validated. + +## Upstream and live-target revalidation (2026-08-02) + +The live server was safely updated, after a verified save-aware backup and with +zero connected players, from `v1.0.2.100933` to Pocketpair's current +`v1.0.2.101103` dedicated-server build. Its exact Linux target is now: + +```text +Steam depot build ID: 24466863 +PalServer GNU build ID: 787f7f8c15edb8fb +PalServer SHA-256: c508a28b06cebf0752296b38da5244c08a5688da44dad8f816eb2d726d82699e +``` + +The server container remains on `thijsvanloef/palworld-server-docker:2.7.1`, +which is the latest published container release. The update completed through +that image's supported updater, the container returned healthy, UE4SS loaded, +the item bridge returned `ready`, and the Pal bridge correctly remained +`validation_required`. + +The native capability probe is pinned to an older Palworld executable and must +continue failing closed. Updating its expected build ID or SHA alone is +prohibited: the July 18 UHT dump and `MemberVariableLayout.ini` predate this +target, so doing so would turn a version guard into a false compatibility claim. +A fresh dump/layout plus reflected metadata comparison is required before a new +non-mutating probe can be built. + +Current upstream evidence also narrows the Linux path: + +- Pocketpair's first-party dedicated-server mod loader currently supports only + the Windows server build. +- The `Yangff/RE-UE4SS` `linux-port-rebase2` branch still ends at commit + `9b4552068804a5f5ec2309ca378aeb65af40ee1a` from November 2024. +- Admin Commands (Server Side) `3.0.4` explicitly reports Pal spawning as broken + on native Linux after 1.0 and recommends disabling its spawn/catch commands; + it also reports autosave-crash risk around the 1.0 spawn path. + +PR completion therefore requires development on a disposable clone of the +world, not another mutation probe on the live server. The shortest responsible +path is: + +1. Generate current-build UHT headers and `MemberVariableLayout.ini` on the + disposable server and archive their Palworld/UE4SS fingerprints. +2. Rebuild the non-mutating native probe for that exact target and compare the + full reflected function/property metadata, not only object names. +3. Implement capacity lookup, native struct construction, authoritative + creation/delivery, read-back, and exact-once result handling. +4. Prove an authoritative cleanup/rollback path before the first mutation. +5. Run the maintenance-window acceptance matrix below on the disposable world. +6. Pin the validated artifacts and only then promote the live bridge from + `validation_required`. + +## Maintenance-window acceptance test + +1. Back up the world and verify the archive. +2. Ensure zero players online; use a disposable player/Palbox if possible. +3. Deploy only the non-mutating `palhelm-pal-native` capability probe and restart + once. +4. Confirm the exact-build `MemberVariableLayout.ini`, build-ID gate, native mod + lifecycle, and reflected function metadata without submitting a mutation. +5. Prove the target party and Palbox lookup plus at least one free owned slot + before creation. +6. Only after a current-build authoritative rollback route is proven, test one + ordinary natural Lamball at level 1 through the native reflected capture path. +7. Read the resulting handle/container back, then save normally, reconnect, parse + the save, and confirm owner/container/instance + fields and all generated stats. +8. Exercise full-box, disconnect, duplicate request, timeout-after-dispatch, and + process-crash cases. +9. Keep `ProductionValidated=false` unless every check passes for the exact build. + +No runtime deployment or Palworld restart is authorized by this document. diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5c664c5..5a02ae6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -21,6 +21,11 @@ import type { ItemCatalogPage, ItemGrant, ItemGrantCapability, + PalGrant, + PalGrantCapability, + PalGrantCatalog, + PalPassiveCatalog, + PalGenerationSpec, MapDataset, MetricsCurrent, MetricsHistory, @@ -155,6 +160,28 @@ export const api = { grants: (): Promise => USE_MOCK ? mock.listItemGrants() : request<{ grants: ItemGrant[] }>("GET", "/item-grants").then((result) => result.grants), }, + palGrants: { + capability: (): Promise => + USE_MOCK ? mock.palGrantCapability() : request("GET", "/pal-grants/capability"), + catalog: (q = "", limit = 50): Promise => { + if (USE_MOCK) return mock.searchGrantablePals(q, limit); + const query = new URLSearchParams({ q, limit: String(limit) }); + return request("GET", `/pal-grants/catalog?${query}`); + }, + passives: (q = "", limit = 200): Promise => { + if (USE_MOCK) return mock.searchGrantablePassives(q, limit); + const query = new URLSearchParams({ q, limit: String(limit) }); + return request("GET", `/pal-grants/passives?${query}`); + }, + grant: (uid: string, characterId: string, spec: PalGenerationSpec, reason: string, idempotencyKey: string): Promise => + USE_MOCK + ? mock.grantPal(uid, characterId, spec, reason, idempotencyKey) + : request("POST", `/players/${encodeURIComponent(uid)}/pal-grants`, { characterId, spec, reason, idempotencyKey }), + grantStatus: (requestId: string): Promise => + USE_MOCK ? mock.palGrantStatus(requestId) : request("GET", `/pal-grants/${encodeURIComponent(requestId)}`), + grants: (): Promise => + USE_MOCK ? mock.listPalGrants() : request<{ grants: PalGrant[] }>("GET", "/pal-grants").then((result) => result.grants), + }, pals: { list: (params: PalExplorerParams = {}): Promise => { if (USE_MOCK) return mock.listPals(params); diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index ebfdf3b..bef5571 100644 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -27,6 +27,11 @@ import type { ItemCatalogPage, ItemGrant, ItemGrantCapability, + PalGrant, + PalGrantCapability, + PalGrantCatalog, + PalPassiveCatalog, + PalGenerationSpec, LiveWorldActor, LiveWorldSnapshot, MapDataset, @@ -1382,6 +1387,72 @@ export async function listItemGrants(): Promise { return [...mockGrants.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); } +const mockGrantablePals = [ + { characterId: "anubis", displayName: "Anubis" }, + { characterId: "sheepball", displayName: "Lamball" }, + { characterId: "grassmammoth", displayName: "Mammorest" }, + { characterId: "penguin", displayName: "Pengullet" }, +]; +const mockPalGrants = new Map(); + +export async function palGrantCapability(): Promise { + requireSession(); + await latency(); + return { enabled: true, ready: true, reason: "", protocolVersion: 1, gameVersion: "1.0.1", catalogVersion: "palworld_1.0_pinned", catalogItems: 249, lastHeartbeatAt: new Date().toISOString() }; +} + +export async function searchGrantablePals(q: string, limit: number): Promise { + requireSession(); + await latency(); + const needle = q.trim().toLowerCase(); + const species = mockGrantablePals.filter((pal) => !needle || `${pal.displayName} ${pal.characterId}`.toLowerCase().includes(needle)).slice(0, limit); + return { species, catalogVersion: "palworld_1.0_pinned", catalogItems: 249, maxLevel: 80, passiveCatalogVersion: "palcalc_v23_b5e13e90fedc", passiveCatalogItems: 115 }; +} + +const mockPassives = [ + { id: "CraftSpeed_up3", name: "Remarkable Craftsmanship", description: "Work Speed +75%", rank: 4, inheritable: true }, + { id: "PAL_ALLAttack_up3", name: "Demon God", description: "Attack +30%\nDefense +5%", rank: 4, inheritable: true }, + { id: "WorkSuitabilityAddRank_MonsterFarm_2", name: "Ranch Master", description: "Farming's Work Suitability +2", rank: 4, inheritable: true }, + { id: "WorldTree_ATK", name: "Twin-Edged Holy Blade", description: "Attack +50%\nDefense -30%", rank: 5, inheritable: false }, + { id: "PAL_ALLAttack_up2", name: "Musclehead", description: "Attack +30%\nWork Speed -50%", rank: 3, inheritable: true }, +]; + +export async function searchGrantablePassives(q: string, limit: number): Promise { + requireSession(); + await latency(); + const needle = q.trim().toLowerCase(); + const passives = mockPassives.filter((passive) => !needle || `${passive.name} ${passive.id} ${passive.description}`.toLowerCase().includes(needle)).slice(0, limit); + return { passives, catalogVersion: "palcalc_v23_b5e13e90fedc", catalogItems: 115, ranks: [5, 4, 3], source: { name: "PalCalc", version: "v23", commit: "b5e13e90fedc2e95d54fa223da77be464c313001", url: "https://github.com/tylercamp/palcalc" } }; +} + +export async function grantPal(uid: string, characterId: string, spec: PalGenerationSpec, reason: string, idempotencyKey: string): Promise { + requireAdmin(); + await latency(150, 300); + const existing = mockPalGrants.get(idempotencyKey); + if (existing) return existing; + const species = mockGrantablePals.find((pal) => pal.characterId === characterId); + const player = players.find((candidate) => candidate.uid === uid); + if (!species || !player) throw new ApiRequestError(400, "invalid_grant", "The mock Pal grant is invalid."); + const now = new Date().toISOString(); + const grant: PalGrant = { requestId: crypto.randomUUID().replaceAll("-", ""), createdAt: now, updatedAt: now, actor: "admin", playerUid: uid, playerName: player.name, characterId, displayName: species.displayName, spec, reason, status: "queued" }; + mockPalGrants.set(idempotencyKey, grant); + return grant; +} + +export async function palGrantStatus(requestId: string): Promise { + requireAdmin(); + await latency(); + const grant = [...mockPalGrants.values()].find((candidate) => candidate.requestId === requestId); + if (!grant) throw new ApiRequestError(404, "grant_not_found", "Pal grant not found."); + return grant; +} + +export async function listPalGrants(): Promise { + requireAdmin(); + await latency(); + return [...mockPalGrants.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + // ---------- events ---------- export async function listEvents(limit: number, kind?: string): Promise { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 866876a..96e2317 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -218,6 +218,99 @@ export interface ItemGrant { errorMessage?: string; } +// ---------- Pal grants (admin-only, server-authoritative creation boundary) ---------- +export interface PalGrantCapability { + enabled: boolean; + ready: boolean; + reason: string; + protocolVersion: number; + gameVersion?: string; + catalogVersion: string; + catalogItems: number; + lastHeartbeatAt?: string; +} + +export interface GrantablePalSpecies { + characterId: string; + displayName: string; +} + +export interface PalGrantCatalog { + species: GrantablePalSpecies[]; + catalogVersion: string; + catalogItems: number; + maxLevel: number; + passiveCatalogVersion: string; + passiveCatalogItems: number; +} + +export interface GrantablePassiveSkill { + id: string; + name: string; + description: string; + rank: number; + inheritable: boolean; +} + +export interface PalPassiveCatalog { + passives: GrantablePassiveSkill[]; + catalogVersion: string; + catalogItems: number; + ranks: number[]; + source: { name: string; version: string; commit: string; url: string }; +} + +export interface PalGenerationSpec { + mode: "natural" | "custom"; + levelMin: number; + levelMax: number; + gender: "random" | "male" | "female"; + alpha: boolean; + lucky: boolean; + ivMode: "natural" | "minimum" | "exact"; + talentHp?: number; + talentMelee?: number; + talentShot?: number; + talentDefense?: number; + passiveSkillIds: string[]; + condensationStars: number; +} + +export interface PalGrantResult { + protocolVersion: number; + requestId: string; + status: "succeeded"; + instanceId: string; + level: number; + gender?: string; + alpha?: boolean; + lucky?: boolean; + talentHp?: number; + talentMelee?: number; + talentShot?: number; + talentDefense?: number; + passiveSkillIds?: string[]; + condensationStars?: number; + completedAt: string; +} + +export interface PalGrant { + requestId: string; + createdAt: string; + updatedAt: string; + actor: string; + playerUid: string; + playerName: string; + characterId: string; + displayName: string; + spec: PalGenerationSpec; + result?: PalGrantResult; + reason: string; + status: "queued" | "succeeded" | "failed"; + errorCode?: string; + errorMessage?: string; +} + export type ServerActivityWindow = "24h" | "7d" | "30d"; export interface ActivityConcurrencyBucket { diff --git a/frontend/src/routes/players/Players.css b/frontend/src/routes/players/Players.css index f1ca419..5b7e2f1 100644 --- a/frontend/src/routes/players/Players.css +++ b/frontend/src/routes/players/Players.css @@ -160,8 +160,69 @@ dialog.dialog.item-grant-dialog .dialog-body { overflow-y: auto; overscroll-beha .item-grant-history span { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 2px; } .item-grant-history strong { font-size: var(--text-xs); } .item-grant-history small { overflow: hidden; color: var(--ink-3); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.pal-grant-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-content: start; } +.pal-grant-item { + display: grid; grid-template-columns: 48px minmax(0, 1fr); gap: 10px; align-items: center; + padding: 9px 10px; border: 0; border-bottom: 1px solid var(--line); color: var(--ink-1); + background: var(--surface); text-align: left; cursor: pointer; +} +.pal-grant-item:nth-child(odd) { border-right: 1px solid var(--line); } +.pal-grant-item:hover { background: var(--surface-2); } +.pal-grant-item.is-selected { background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); } +.pal-grant-item .pal-chip { width: 46px; height: 46px; } +.pal-grant-item > span { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.pal-grant-item strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); } +.pal-grant-item small { overflow: hidden; color: var(--ink-3); font: 10px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.pal-grant-fields { display: grid; grid-template-columns: 130px 130px minmax(180px, 1fr); gap: var(--space-3); } +.pal-grant-mode { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border: var(--border-ctl) solid var(--line); border-radius: var(--radius-ctl); background: var(--surface-2); } +.pal-grant-mode > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.pal-grant-mode strong, .pal-specimen-kind > strong { font-size: var(--text-sm); font-weight: 600; color: var(--ink-2); } +.pal-grant-mode small { color: var(--ink-3); font-size: var(--text-xs); } +.pal-grant-mode > div:last-child, .pal-specimen-kind > div { display: flex; flex-wrap: wrap; gap: 6px; } +.pal-grant-advanced { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); padding: var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-ctl); background: var(--surface-2); } +.pal-grant-advanced > .item-grant-note { grid-column: 1 / -1; } +.pal-specimen-kind { display: flex; grid-column: 1 / -1; flex-direction: column; gap: 6px; } +.pal-grant-ivs { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); } +.pal-passive-editor { display: flex; grid-column: 1 / -1; min-width: 0; flex-direction: column; gap: 10px; padding-top: var(--space-2); border-top: 1px solid var(--line); } +.pal-passive-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.pal-passive-editor-head > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; } +.pal-passive-editor-head small, .pal-passive-source { color: var(--ink-3); font-size: 10px; } +.pal-passive-selected { display: flex; flex-wrap: wrap; gap: 7px; } +.pal-passive-chip { display: flex; flex-direction: column; gap: 1px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 999px; color: var(--ink-1); background: var(--surface); text-align: left; cursor: pointer; } +.pal-passive-chip span { font-size: var(--text-xs); font-weight: 650; } +.pal-passive-chip small { color: var(--ink-3); font-size: 9px; } +.pal-passive-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); max-height: 280px; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius-ctl); background: var(--surface); } +.pal-passive-option { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 10px; border: 0; border-bottom: 1px solid var(--line); color: var(--ink-1); background: transparent; text-align: left; cursor: pointer; } +.pal-passive-option:nth-child(odd) { border-right: 1px solid var(--line); } +.pal-passive-option:hover:not(:disabled) { background: var(--surface-2); } +.pal-passive-option:disabled { opacity: .45; cursor: not-allowed; } +.pal-passive-option.is-selected { background: var(--accent-soft); box-shadow: inset 3px 0 var(--accent); } +.pal-passive-option-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.pal-passive-option-title strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-xs); } +.pal-passive-option-title em { flex: none; font-size: 9px; font-style: normal; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; } +.pal-passive-option > span:not(.pal-passive-option-title) { white-space: pre-line; color: var(--ink-2); font-size: 11px; line-height: 1.35; } +.pal-passive-option > small { overflow: hidden; color: var(--ink-3); font: 9px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } +.tier-rainbow .pal-passive-option-title em, .pal-passive-chip.tier-rainbow span { color: var(--accent-ink); } +.tier-world-tree .pal-passive-option-title em, .pal-passive-chip.tier-world-tree span { color: var(--ok-ink); } +.tier-gold .pal-passive-option-title em, .pal-passive-chip.tier-gold span { color: var(--warn-ink); } +.tier-silver .pal-passive-option-title em, .pal-passive-chip.tier-silver span { color: var(--ink-3); } +.tier-bronze .pal-passive-option-title em, .pal-passive-chip.tier-bronze span { color: var(--warn-ink); } +.tier-negative .pal-passive-option-title em, .pal-passive-chip.tier-negative span { color: var(--danger-ink); } +.pal-passive-chip.tier-rainbow { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); background: linear-gradient(110deg, var(--accent-soft), var(--warn-soft), var(--ok-soft)); } +.pal-passive-chip.tier-world-tree { border-color: color-mix(in srgb, var(--ok) 60%, var(--line)); } +.pal-grant-review { display: grid; grid-template-columns: 76px 1fr; gap: var(--space-3); align-items: center; } +.pal-grant-review .pal-chip { width: 72px; height: 72px; } +.pal-grant-review > div { display: flex; flex-direction: column; gap: 5px; } +.pal-grant-review span { color: var(--ink-2); font-size: var(--text-sm); } +.pal-grant-review .banner { grid-column: 1 / -1; } @media (max-width: 560px) { .item-grant-item { grid-template-columns: 38px minmax(0, 1fr); } .item-grant-item code { display: none; } .item-grant-fields { grid-template-columns: 1fr; } + .pal-grant-mode { align-items: stretch; flex-direction: column; } + .pal-grant-results { grid-template-columns: 1fr; } + .pal-grant-item:nth-child(odd) { border-right: 0; } + .pal-grant-fields, .pal-grant-advanced, .pal-grant-ivs { grid-template-columns: 1fr; } + .pal-passive-results { grid-template-columns: 1fr; } + .pal-passive-option:nth-child(odd) { border-right: 0; } } diff --git a/frontend/src/routes/players/Players.tsx b/frontend/src/routes/players/Players.tsx index 35a71e7..a356932 100644 --- a/frontend/src/routes/players/Players.tsx +++ b/frontend/src/routes/players/Players.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "../../api/client"; -import type { GrantableItem, Player, PlayerActivity, PlayerActivityWindow, WhitelistEntry } from "../../api/types"; +import type { GrantableItem, GrantablePalSpecies, GrantablePassiveSkill, PalGenerationSpec, Player, PlayerActivity, PlayerActivityWindow, WhitelistEntry } from "../../api/types"; import { useIsAdmin } from "../../app/AuthProvider"; import { usePaletteBridge } from "../../app/paletteBridge"; import { formatDuration, formatRelativeToNow, truncateMiddle } from "../../app/format"; @@ -11,11 +11,12 @@ import { guildDisplayName } from "../../app/guildDisplay"; import { Card, CardBody, CardHead } from "../../components/Card"; import { Tabs } from "../../components/Tabs"; import { Pill } from "../../components/Pill"; +import { ToggleChip } from "../../components/ToggleChip"; import { Banner } from "../../components/Banner"; import { EmptyState } from "../../components/EmptyState"; import { Dialog, ConfirmDialog } from "../../components/ConfirmDialog"; import { DropdownMenu, DropdownMenuItem } from "../../components/DropdownMenu"; -import { Field, SearchField } from "../../components/Field"; +import { Field, SearchField, SelectField } from "../../components/Field"; import { useToast } from "../../components/Toast"; import { IconPlayers } from "../../components/icons"; import { PlayerAvatar } from "../../components/PlayerAvatar"; @@ -49,6 +50,15 @@ function ItemRarityBadge({ rarity }: { rarity?: string }) { return {label}; } +function passiveTier(passive: Pick): { label: string; className: string } { + if (passive.rank >= 5) return { label: "World Tree", className: "world-tree" }; + if (passive.rank === 4) return { label: "Rainbow", className: "rainbow" }; + if (passive.rank === 3) return { label: "Gold", className: "gold" }; + if (passive.rank === 2) return { label: "Silver", className: "silver" }; + if (passive.rank === 1) return { label: "Bronze", className: "bronze" }; + return { label: "Negative", className: "negative" }; +} + function lastSeenLabel(p: Player): string { if (p.online) return "now"; const d = new Date(p.lastSeenAt); @@ -306,6 +316,7 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k const [showAllPals, setShowAllPals] = useState(false); const [messageOpen, setMessageOpen] = useState(false); const [itemGrantOpen, setItemGrantOpen] = useState(false); + const [palGrantOpen, setPalGrantOpen] = useState(false); const [expandedPalId, setExpandedPalId] = useState(null); useEffect(() => setExpandedPalId(null), [uid]); @@ -322,6 +333,12 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k enabled: isAdmin && uid !== null, refetchInterval: 30_000, }); + const palGrantsQuery = useQuery({ + queryKey: ["pal-grants"], + queryFn: () => api.palGrants.grants(), + enabled: isAdmin && uid !== null, + refetchInterval: 30_000, + }); if (!uid) { return ( @@ -452,6 +469,9 @@ function PlayerDetailPanel({ uid, onAction }: { uid: string | null; onAction: (k + + {reviewing ? ( + + ) : ( + + )} + + } + > + {capability.isLoading &&

Checking the Pal creation bridge…

} + {!capability.isLoading && capability.data?.ready !== true && ( + Preview only: {capability.data?.reason ?? "The Pal creation bridge is unavailable."} Review and confirmation remain disabled. + )} + {reviewing && selected ? ( +
+ +
+ {selected.displayName} + Level: {levelMin === levelMax ? levelMin : `${levelMin}–${levelMax}`} + {advanced ? `${gender} · ${alpha ? "Alpha" : lucky ? "Lucky" : "normal"} · ${ivMode} IVs · ${condensationStars}★` : "Natural gender, IVs, passives, and skills"} + {advanced && Passives: {selectedPassives.length > 0 ? selectedPassives.map((passive) => passive.name).join(" · ") : "natural roll"}} + Destination: first safe Palbox slot + Recipient: {player.name} + Reason: {reason.trim()} +
+ This creates a persistent Pal. Palhelm submits once, never retries an uncertain result, and keeps an audit record. +
+ ) : ( +
+

Online players only · {(catalog.data?.catalogItems ?? capability.data?.catalogItems ?? 0).toLocaleString()} allowlisted species/forms · Palbox delivery

+ setSearch(event.target.value)} placeholder="Search Pal name or Character ID…" autoFocus /> +
+ {catalog.isLoading && Searching Pal catalogue…} + {catalog.isError && The Pal catalogue could not be loaded. Try the search again.} + {catalog.data?.species.map((pal) => ( + + ))} + {catalog.data && catalog.data.species.length === 0 && No safe Pal species match that search.} +
+ {selected && ( + <> +
+ setLevelMin(Number(event.target.value))} /> + setLevelMax(Number(event.target.value))} /> + setReason(event.target.value)} placeholder="Why is this Pal being granted?" /> +
+
+
+ Generation mode + Natural uses the game's ordinary randomized specimen generation. +
+
+ setAdvanced(false)}>Natural roll + setAdvanced(true)}>Customize specimen +
+
+ {advanced && ( +
+ setGender(event.target.value as PalGenerationSpec["gender"])}> + setIVMode(event.target.value as PalGenerationSpec["ivMode"])}> + setCondensationStars(Number(event.target.value))}>{[0, 1, 2, 3, 4].map((value) => )} +
+ Specimen +
+ { setAlpha(false); setLucky(false); }}>Normal + { setAlpha(!alpha); setLucky(false); }}>Alpha + { setLucky(!lucky); setAlpha(false); }}>Lucky +
+
+ {ivMode !== "natural" && ( +
+ setTalentHp(Number(event.target.value))} /> + setTalentMelee(Number(event.target.value))} /> + setTalentShot(Number(event.target.value))} /> + setTalentDefense(Number(event.target.value))} /> +
+ )} +
+
+
+ Passive skills + {selectedPassives.length}/4 selected · all standard 1.0 traits +
+ {passives.data?.catalogItems ?? catalog.data?.passiveCatalogItems ?? 0} catalogued +
+ {selectedPassives.length > 0 && ( +
+ {selectedPassives.map((passive) => { + const tier = passiveTier(passive); + return ( + + ); + })} +
+ )} + setPassiveSearch(event.target.value)} placeholder="Search passives, effects, or internal ID…" aria-label="Search passive skills" /> +
+ {passives.isLoading && Searching passive catalogue…} + {passives.isError && The passive catalogue could not be loaded. Try the search again.} + {passives.data?.passives.map((passive) => { + const tier = passiveTier(passive); + const selectedPassive = selectedPassives.some((item) => item.id === passive.id); + const disabled = !selectedPassive && selectedPassives.length >= 4; + return ( + + ); + })} + {passives.data && passives.data.passives.length === 0 && No passive traits match that search.} +
+

Rainbow and World Tree traits are allowed as explicit admin overrides. The bridge must read back the same four IDs before reporting success.

+
+

Active-skill, nickname, soul, and work-suitability editors remain fail-closed until their exact 1.0 write fields are validated in the maintenance probe.

+
+ )} + + )} +
+ )} + + ); +} + function PlayerActivitySummary({ activity }: { activity: PlayerActivity }) { return (
diff --git a/frontend/tests/pal-grants.test.mjs b/frontend/tests/pal-grants.test.mjs new file mode 100644 index 0000000..14bfb12 --- /dev/null +++ b/frontend/tests/pal-grants.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +const players = await readFile(new URL("../src/routes/players/Players.tsx", import.meta.url), "utf8"); +const client = await readFile(new URL("../src/api/client.ts", import.meta.url), "utf8"); +const styles = await readFile(new URL("../src/routes/players/Players.css", import.meta.url), "utf8"); + +test("player detail scopes a reviewed Palbox-first Give Pal workflow", () => { + assert.match(players, /Give Pal…/); + assert.match(players, /Natural roll|natural roll/i); + assert.match(players, /Customize specimen/); + assert.match(players, /first safe Palbox slot/); + assert.match(players, /Confirm Pal grant/); + assert.match(players, /Alpha/); + assert.match(players, /Lucky/); + assert.match(players, /HP IV/); + assert.match(players, /Melee attack IV/); + assert.match(players, /Ranged attack IV/); + assert.match(players, /Defense IV/); + assert.match(players, /Passive skills/); + assert.match(players, /Rainbow and World Tree traits/); + assert.match(players, /selectedPassives\.map\(\(passive\) => passive\.id\)/); + assert.match(players, /api\.palGrants\.passives\(passiveSearch, 200\)/); + assert.match(players, / { + assert.doesNotMatch(styles, /#[0-9a-f]{3,8}\b/i); + assert.match(styles, /item-grant-dialog \{ width: min\(900px, 94vw\); max-height: 92vh;/); + assert.match(styles, /pal-grant-ivs[^}]*repeat\(4,/); + assert.match(styles, /pal-passive-results[^}]*repeat\(2,/); + assert.match(styles, /@media \(max-width: 560px\)/); +}); + +test("Pal creation reuses one idempotency key and polls the audit record", () => { + assert.match(players, /api\.palGrants\.grant\(player\.uid, selected\.characterId, spec, reason\.trim\(\), idempotencyKey\)/); + assert.match(players, /api\.palGrants\.grantStatus\(grant\.requestId\)/); + assert.match(players, /Never resubmit a creation request/); +}); + +test("Give Pal remains a session-admin mutation outside the Integration API", () => { + assert.match(client, /request\("POST", `\/players\/\$\{encodeURIComponent\(uid\)\}\/pal-grants`/); + assert.doesNotMatch(client, /integration.*pal-grants/i); +}); diff --git a/mods/palhelm-pal-bridge/README.md b/mods/palhelm-pal-bridge/README.md new file mode 100644 index 0000000..0e6a30b --- /dev/null +++ b/mods/palhelm-pal-bridge/README.md @@ -0,0 +1,99 @@ +# Palhelm Pal Grant Bridge + +Server-only UE4SS bridge for Palhelm's audited **Give Pal** workflow. + +## Current status + +`validation_required` by design. Validation attempts proved that Palworld's +reflected `APalPlayerState::RequestSpawnMonsterForPlayer` RPC returns without an +error in a server-side UE4SS context but does not create an owned specimen. The +bridge therefore uses the target player's reflected, reliable server RPC +`APalPlayerState::Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer` for the next +isolated validation. Its reflected `FPalDebugOtomoPalInfo` parameter carries the +allowlisted species and exact level while Palworld generates the remaining +natural specimen data. This is Palworld's capture path rather than its +grant/spawn path. +The bridge snapshots both the player's party and Palbox and reports success +only after exactly one matching new specimen can be read back from either owned +container. This avoids constructing or attaching a synthetic cheat manager and +does not mutate global debug settings. Any uncertain call remains +non-retryable. Player-controller identity resolution and all reflected +Palworld calls execute on the game thread; the background loop only performs +spool-file coordination. + +Before production validation, only the exact request configured by +`ValidationPreflightRequestID`, `ValidationRequestID`, `ValidationCharacterID`, +and `ValidationLevel` can run, and only when `ValidationMode=true`. A matching +`preflightOnly=true` request must first prove, on the game thread, that the +target controller, Palbox, empty slot, and transport setting are readable. The +actual validation request remains blocked until that read-only preflight result +exists. The capability heartbeat remains +`validation_required`, so Palhelm cannot submit ordinary requests. +`AdvancedEnabled=true` loads bounded custom level/range, gender, generic Alpha, +Lucky, IV, passive, and condensation handling during the same startup, but does +not bypass validation. The validation IDs and `ProductionValidated` are re-read +from `config.lua`, so the exact build can be promoted after persistence testing +without another Palworld restart. + +Read-only inspection of the installed `v1.0.1.100619` Linux symbol bundle found: + +```text +UPalCharacterManager::CreateIndividual( + FPalIndividualCharacterSaveParameter, + UPalCharacterManager::FIndividualIDCallback) + +UPalNetworkIndividualComponent::CreateIndividualID_ServerInternal( + FPalIndividualCharacterSaveParameter, + FGuid, + int) + +UPalCharacterManager::GrantWorldCharacterToPlayerServerInternal( + UPalIndividualCharacterHandle*, + FGuid const&) + +UPalCharacterManager::GrantWorldCharacterToPlayerFixedSlotIdServerInternal( + UPalIndividualCharacterHandle*, + FGuid const&, + FPalCharacterSlotId const&) +``` + +`CreateIndividualID_ServerInternal` is reflected as a `UFunction`, but it only +proves character creation, not Palbox delivery. The bridge therefore does not +use it. The two direct Grant helpers appear native-only. + +The reflected Lua alternative that initializes a natural save parameter, spawns +a temporary Pal actor, and feeds that actor to `PalCaptureSuccess` is also disabled. +On the exact 1.0 Linux build, a read-only call to +`GetInitializedCharacterSaveParemter` crashed the experimental UE4SS beta while +marshalling its large struct out parameter, before any actor or individual was +created. Do not retry that route in Lua. A future version-pinned C++ bridge is the +only remaining planned creation provider; it is intentionally not loaded by this +mod. + +The probe also checks the reflected read-side container functions `GetContainer`, +`FindEmptySlot`, and `FindByHandle`, plus the presence of +`MemberVariableLayout.ini`. These are the minimum pieces needed to preflight +Palbox capacity and verify a completed delivery; they do not make mutation safe +on their own. + +## Required validation gates + +1. Take a fresh world backup and use a maintenance window with no players. +2. Confirm exactly one authoritative `PalNetworkIndividualComponent` context. +3. Confirm the version-pinned UHT dump still exposes + `Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer`, + `FPalDebugOtomoPalInfo`, player party and Pal storage, the character-container + manager, and owned-container slot enumeration. +4. Grant a natural, ordinary Lamball through the one-request validation gate to + a test player with an empty Palbox slot. +5. Verify identity, owner UID, container/slot, level, gender, IVs, passives, + active skills, rank, Alpha/Lucky flags, Paldeck behavior, replication, + reconnect, normal world save, and subsequent save parse. +6. Test full Palbox, disconnect during creation, duplicate request ID, process + death after the game call, and outcome-unknown recovery. +7. Only then implement request consumption and set `ProductionValidated=true` + for that exact game version. + +The bridge must never retry a request whose mutation outcome is uncertain. +Humans, tower/raid actors, BOSS_ identifiers, placeholders, and unallowlisted +forms remain rejected by the panel contract. diff --git a/mods/palhelm-pal-bridge/Scripts/main.lua b/mods/palhelm-pal-bridge/Scripts/main.lua new file mode 100644 index 0000000..6d86d94 --- /dev/null +++ b/mods/palhelm-pal-bridge/Scripts/main.lua @@ -0,0 +1,628 @@ +-- Palhelm Pal Grant Bridge protocol v1. Server-only; vanilla clients. +-- The bridge stays fail-closed until one exact game build passes validation. +local MOD = "PalhelmPalBridge" +local PROTOCOL = 1 + +local function log(message) + print(string.format("[%s] %s\n", MOD, tostring(message))) +end + +local source = debug.getinfo(1, "S").source:gsub("^@", ""):gsub("\\", "/") +local separator = package.config and package.config:sub(1, 1) or "/" +local function joinPath(base, child) + if base:sub(-1) == "/" or base:sub(-1) == "\\" then return base .. child end + return base .. separator .. child +end +local scriptDir = source:match("^(.+)/[^/]+$") +local modDir = scriptDir and scriptDir:match("^(.+)/Scripts$") or nil +if not modDir then + log("ERROR: cannot resolve mod directory") + return +end + +local configPath = joinPath(modDir, "config.lua") +local okConfig, config = pcall(dofile, configPath) +if not okConfig or type(config) ~= "table" then + log("ERROR: config.lua is missing or invalid") + return +end + +-- Runtime policy is deliberately reloadable. UE4SS loads this script only at game +-- startup, but operators must be able to promote a successfully validated exact +-- build without restarting Palworld a second time. Immutable paths/catalogue +-- identity remain pinned to the startup configuration. +local immutableConfig = { + SpoolDir = config.SpoolDir, + GameVersion = config.GameVersion, + CatalogVersion = config.CatalogVersion, + CatalogItems = config.CatalogItems, + MemberVariableLayoutPath = config.MemberVariableLayoutPath, +} + +local function reloadRuntimeConfig() + local ok, candidate = pcall(dofile, configPath) + if not ok or type(candidate) ~= "table" then return false end + for key, value in pairs(immutableConfig) do + if candidate[key] ~= value then return false end + end + for _, key in ipairs({ + "GenerateUHTHeaders", "ValidationMode", "ValidationPreflightRequestID", + "ValidationRequestID", "ValidationCharacterID", "ValidationLevel", + "ValidationBoxOnly", "ProductionValidated", "AdvancedEnabled", + }) do + config[key] = candidate[key] + end + return true +end + +local function safeToken(value) + return type(value) == "string" and value:match("^[A-Za-z0-9_]+$") ~= nil and #value <= 160 +end + +local function safeVersion(value) + return type(value) == "string" and value:match("^[A-Za-z0-9_.-]+$") ~= nil and #value <= 80 +end + +local function jsonEscape(value) + return tostring(value or ""):gsub("\\", "\\\\"):gsub('"', '\\"'):gsub("\r", "\\r"):gsub("\n", "\\n") +end + +local function readFile(path) + local file = io.open(path, "rb") + if not file then return nil end + local body = file:read("*a") + file:close() + if not body or body == "" or #body > 65536 then return nil end + return body +end + +local function writeAtomic(path, body) + local temporary = path .. ".tmp" + local file = io.open(temporary, "wb") + if not file then return false end + file:write(body) + file:close() + os.remove(path) + if not os.rename(temporary, path) then + os.remove(temporary) + return false + end + return true +end + +local function validObject(object) + if not object then return false end + local ok, valid = pcall(function() return object:IsValid() end) + return ok and valid == true +end + +local function probeFunction(path) + if type(StaticFindObject) ~= "function" then return false end + local ok, object = pcall(StaticFindObject, path) + return ok and validObject(object) +end + +local function fileExists(path) + local file = io.open(path, "rb") + if not file then return false end + file:close() + return true +end + +local spool = config.SpoolDir or "" +local inbox = joinPath(spool, "inbox.json") +local processing = joinPath(spool, "processing.json") +local capability = joinPath(spool, "capability.json") +local defaultLayoutPath = joinPath(joinPath(joinPath(modDir, ".."), ".."), "MemberVariableLayout.ini") + +local checks = { + executeInGameThread = type(ExecuteInGameThread) == "function", + findAllOf = type(FindAllOf) == "function", + memberVariableLayout = fileExists(config.MemberVariableLayoutPath or defaultLayoutPath), + debugCaptureNewMonster = probeFunction("/Script/Pal.PalPlayerState:Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer"), + palStorage = probeFunction("/Script/Pal.PalPlayerState:GetPalStorage"), + palParty = probeFunction("/Script/Pal.PalPlayerState:GetPalPlayerOtomoData"), + palStorageCapacity = probeFunction("/Script/Pal.PalPlayerDataPalStorage:GetPageIndexExistEmptySlot"), + palStorageSlots = probeFunction("/Script/Pal.PalIndividualCharacterContainer:GetSlots"), + palContainerEmptySlot = probeFunction("/Script/Pal.PalIndividualCharacterContainer:FindEmptySlot"), + palContainerManagerUtility = probeFunction("/Script/Pal.PalUtility:GetCharacterContainerManager"), + palContainerManagerGet = probeFunction("/Script/Pal.PalCharacterContainerManager:GetContainer"), +} + +local configured = type(spool) == "string" and spool ~= "" + and safeVersion(config.GameVersion or "") + and safeVersion(config.CatalogVersion or "") + and tonumber(config.CatalogItems) ~= nil and tonumber(config.CatalogItems) > 0 + and checks.executeInGameThread and checks.findAllOf + and checks.memberVariableLayout + and checks.debugCaptureNewMonster and checks.palStorage and checks.palParty + and checks.palStorageCapacity and checks.palStorageSlots + and checks.palContainerEmptySlot and checks.palContainerManagerUtility + and checks.palContainerManagerGet + +local function utcNow() + return os.date("!%Y-%m-%dT%H:%M:%SZ") +end + +local function writeCapability() + reloadRuntimeConfig() + local state = "invalid_configuration" + if configured then + state = config.ProductionValidated == true and "ready" or "validation_required" + end + local body = string.format( + '{"protocolVersion":%d,"state":"%s","gameVersion":"%s","catalogVersion":"%s","catalogItems":%d,"at":"%s"}\n', + PROTOCOL, state, jsonEscape(config.GameVersion), jsonEscape(config.CatalogVersion), + tonumber(config.CatalogItems) or 0, utcNow()) + writeAtomic(capability, body) +end + +local function parseBoolean(body, name) + return body:match('"' .. name .. '"%s*:%s*(true)') == "true" +end + +local function parseOptionalNumber(body, name) + local value = body:match('"' .. name .. '"%s*:%s*(%d+)') + return value and tonumber(value) or nil +end + +local function parseTokenArray(body, name, maximum) + local encoded = body:match('"' .. name .. '"%s*:%s*%[([^%]]*)%]') + if encoded == nil then return nil end + if not encoded:match("%S") then return {} end + local values = {} + for value in encoded:gmatch('"([A-Za-z0-9_]+)"') do + if not safeToken(value) or #values >= maximum then return nil end + for _, existing in ipairs(values) do + if string.lower(existing) == string.lower(value) then return nil end + end + values[#values + 1] = value + end + local remainder = encoded:gsub('"[A-Za-z0-9_]+"', ""):gsub("[%s,]", "") + if remainder ~= "" or #values == 0 then return nil end + return values +end + +local function parseRequest(body) + if not body then return nil, "request_unreadable" end + local request = { + protocolVersion = tonumber(body:match('"protocolVersion"%s*:%s*(%d+)')), + requestId = body:match('"requestId"%s*:%s*"([A-Za-z0-9_]+)"'), + playerUid = body:match('"playerUid"%s*:%s*"([A-Za-z0-9_]+)"'), + characterId = body:match('"characterId"%s*:%s*"([A-Za-z0-9_]+)"'), + mode = body:match('"mode"%s*:%s*"([A-Za-z0-9_]+)"'), + levelMin = tonumber(body:match('"levelMin"%s*:%s*(%d+)')), + levelMax = tonumber(body:match('"levelMax"%s*:%s*(%d+)')), + gender = body:match('"gender"%s*:%s*"([A-Za-z0-9_]+)"'), + alpha = parseBoolean(body, "alpha"), + lucky = parseBoolean(body, "lucky"), + preflightOnly = parseBoolean(body, "preflightOnly"), + ivMode = body:match('"ivMode"%s*:%s*"([A-Za-z0-9_]+)"'), + talentHp = parseOptionalNumber(body, "talentHp"), + talentMelee = parseOptionalNumber(body, "talentMelee"), + talentShot = parseOptionalNumber(body, "talentShot"), + talentDefense = parseOptionalNumber(body, "talentDefense"), + condensationStars = tonumber(body:match('"condensationStars"%s*:%s*(%d+)')), + passiveSkillIds = parseTokenArray(body, "passiveSkillIds", 4), + } + if request.playerUid then request.playerUid = string.lower(request.playerUid) end + if request.protocolVersion ~= PROTOCOL or not safeToken(request.requestId) + or not request.playerUid or #request.playerUid ~= 32 or not request.playerUid:match("^%x+$") + or not safeToken(request.characterId) + or (request.mode ~= "natural" and request.mode ~= "custom") + or not request.levelMin or not request.levelMax or request.levelMax < request.levelMin + or request.levelMin < 1 or request.levelMax > 80 + or (request.gender ~= "random" and request.gender ~= "male" and request.gender ~= "female") + or (request.alpha and request.lucky) + or (request.ivMode ~= "natural" and request.ivMode ~= "minimum" and request.ivMode ~= "exact") + or request.condensationStars == nil or request.condensationStars < 0 or request.condensationStars > 4 + or request.passiveSkillIds == nil then + return request, "unsupported_or_invalid_request" + end + if request.ivMode == "natural" then + if request.talentHp ~= nil or request.talentMelee ~= nil or request.talentShot ~= nil + or request.talentDefense ~= nil then return request, "unsupported_or_invalid_request" end + else + if request.talentHp == nil or request.talentMelee == nil or request.talentShot == nil + or request.talentDefense == nil then return request, "unsupported_or_invalid_request" end + for _, value in ipairs({ request.talentHp, request.talentMelee, request.talentShot, request.talentDefense }) do + if value < 0 or value > 100 then return request, "unsupported_or_invalid_request" end + end + end + if request.mode == "natural" and (request.gender ~= "random" or request.alpha or request.lucky + or request.ivMode ~= "natural" or #request.passiveSkillIds > 0 or request.condensationStars ~= 0) then + return request, "unsupported_or_invalid_request" + end + if request.mode == "custom" and config.AdvancedEnabled ~= true then + return request, "unsupported_or_invalid_request" + end + return request, nil +end + +local function resultPath(requestId) + return joinPath(joinPath(spool, "results"), requestId .. ".json") +end + +local function writeFailure(request, code, message) + local body = string.format( + '{"protocolVersion":%d,"requestId":"%s","status":"failed","errorCode":"%s","errorMessage":"%s","completedAt":"%s"}\n', + PROTOCOL, jsonEscape(request.requestId), jsonEscape(code), jsonEscape(message), utcNow()) + if not writeAtomic(resultPath(request.requestId), body) then + log("ERROR: could not write failure result for " .. tostring(request.requestId)) + return false + end + os.remove(processing) + return true +end + +local function writePreflightSuccess(request) + local body = string.format( + '{"protocolVersion":%d,"requestId":"%s","status":"succeeded","preflightOnly":true,"playerUid":"%s","completedAt":"%s"}\n', + PROTOCOL, jsonEscape(request.requestId), jsonEscape(request.playerUid), utcNow()) + if not writeAtomic(resultPath(request.requestId), body) then + log("ERROR: could not write preflight result for " .. tostring(request.requestId)) + return false + end + os.remove(processing) + return true +end + +local function hex32(value) + local formatted = string.format("%016x", tonumber(value) or 0) + return formatted:sub(-8) +end + +local function guidHex(value) + local ok, result = pcall(function() + return string.lower(hex32(value.A) .. hex32(value.B) .. hex32(value.C) .. hex32(value.D)) + end) + return ok and result or "" +end + +local function fNameString(value) + local ok, result = pcall(function() return value:ToString() end) + return ok and tostring(result) or tostring(value or "") +end + +local function findController(uid) + local controllers = FindAllOf("PalPlayerController") + if not controllers then return nil end + local match = nil + for _, controller in ipairs(controllers) do + if validObject(controller) then + local ok, controllerUid = pcall(function() return guidHex(controller:GetPlayerUId()) end) + if ok and controllerUid == uid then + if match then return nil end + match = controller + end + end + end + return match +end + +local function addContainerHandles(handles, container) + if not validObject(container) then error("character_container_unavailable") end + local slots = container:GetSlots() + if not slots then error("character_container_slots_unavailable") end + for _, slot in ipairs(slots) do + if validObject(slot) then + local handle = slot:GetHandle() + if validObject(handle) then + local id = handle:GetIndividualID() + local instanceId = guidHex(id.InstanceId) + if instanceId ~= "" then handles[instanceId] = handle end + end + end + end +end + +local function ownedHandles(controller, requireEmptySlot, boxOnly) + local state = controller:GetPalPlayerState() + if not validObject(state) then error("player_state_unavailable") end + local storage = state:GetPalStorage() + if not validObject(storage) then error("pal_storage_unavailable") end + local boxContainer = storage.TargetContainer + if not validObject(boxContainer) then error("pal_storage_container_unavailable") end + + local handles = {} + local partyContainer = nil + local partyEmpty = false + if not boxOnly then + local partyData = state:GetPalPlayerOtomoData() + if not validObject(partyData) then error("pal_party_data_unavailable") end + local rawContainerId = partyData.OtomoCharacterContainerId + local rawGuid = rawContainerId.ID + local containerId = { ID = { + A = tonumber(rawGuid.A), B = tonumber(rawGuid.B), + C = tonumber(rawGuid.C), D = tonumber(rawGuid.D), + } } + local pawn = controller:GetDefaultPlayerCharacter() + if not validObject(pawn) then error("player_character_unavailable") end + local utility = StaticFindObject("/Script/Pal.Default__PalUtility") + if not validObject(utility) then error("pal_utility_unavailable") end + local manager = utility:GetCharacterContainerManager(pawn) + if not validObject(manager) then error("character_container_manager_unavailable") end + partyContainer = manager:GetContainer(containerId) + if not validObject(partyContainer) then error("pal_party_container_unavailable") end + partyEmpty = validObject(partyContainer:FindEmptySlot()) + addContainerHandles(handles, partyContainer) + end + + if requireEmptySlot then + local emptyPage = tonumber(storage:GetPageIndexExistEmptySlot(0)) + local boxEmpty = emptyPage ~= nil and emptyPage >= 0 + if not partyEmpty and not boxEmpty then error("owned_pal_containers_full") end + end + + addContainerHandles(handles, boxContainer) + return handles +end + +local function passiveIDs(parameter) + local out = {} + local ok, values = pcall(function() return parameter:GetPassiveSkillList() end) + if ok and values then + for _, value in ipairs(values) do out[#out + 1] = fNameString(value) end + end + return out +end + +local function parameterResult(request, instanceId, parameter) + local characterId = fNameString(parameter:GetCharacterID()) + local normalizedCharacterId = characterId:gsub("^[Bb][Oo][Ss][Ss]_", "") + local isAlpha = normalizedCharacterId ~= characterId + if string.lower(normalizedCharacterId) ~= string.lower(request.characterId) then + error("created_character_mismatch") + end + local genderCode = tonumber(parameter:GetGenderType()) + if genderCode == nil then genderCode = tonumber(tostring(parameter:GetGenderType()):match("(%d+)$")) end + local gender = genderCode == 1 and "male" or genderCode == 2 and "female" or "" + if gender == "" then error("created_gender_unknown") end + local save = parameter.SaveParameter + local talents = { + hp = tonumber(save.Talent_HP), melee = tonumber(save.Talent_Melee), + shot = tonumber(save.Talent_Shot), defense = tonumber(save.Talent_Defense), + } + for _, value in pairs(talents) do + if value == nil or value < 0 or value > 100 then error("created_talent_unknown") end + end + local level = tonumber(parameter:GetLevel()) + local rank = tonumber(parameter:GetRank()) + if not level or not rank then error("created_level_or_rank_unknown") end + if level < request.levelMin or level > request.levelMax then error("created_level_mismatch") end + local passives = passiveIDs(parameter) + if #passives > 4 then error("created_passives_invalid") end + if request.mode == "custom" then + if request.gender ~= "random" and gender ~= request.gender then error("created_gender_mismatch") end + if isAlpha ~= request.alpha then error("created_alpha_mismatch") end + if (parameter:IsRarePal() == true) ~= request.lucky then error("created_lucky_mismatch") end + if rank ~= request.condensationStars + 1 then error("created_rank_mismatch") end + if request.ivMode == "exact" and (talents.hp ~= request.talentHp + or talents.melee ~= request.talentMelee or talents.shot ~= request.talentShot + or talents.defense ~= request.talentDefense) then error("created_exact_talents_mismatch") end + if request.ivMode == "minimum" and (talents.hp < request.talentHp + or talents.melee < request.talentMelee or talents.shot < request.talentShot + or talents.defense < request.talentDefense) then error("created_minimum_talents_mismatch") end + if #passives ~= #request.passiveSkillIds then error("created_passives_mismatch") end + local expected = {} + for _, passive in ipairs(request.passiveSkillIds) do expected[string.lower(passive)] = true end + for _, passive in ipairs(passives) do + if not expected[string.lower(passive)] then error("created_passives_mismatch") end + end + end + local encodedPassives = {} + for _, passive in ipairs(passives) do encodedPassives[#encodedPassives + 1] = '"' .. jsonEscape(passive) .. '"' end + return string.format( + '{"protocolVersion":%d,"requestId":"%s","status":"succeeded","instanceId":"%s","level":%d,"gender":"%s","alpha":%s,"lucky":%s,"talentHp":%d,"talentMelee":%d,"talentShot":%d,"talentDefense":%d,"passiveSkillIds":[%s],"condensationStars":%d,"completedAt":"%s"}\n', + PROTOCOL, jsonEscape(request.requestId), jsonEscape(instanceId), level, gender, + tostring(isAlpha), tostring(parameter:IsRarePal() == true), talents.hp, talents.melee, talents.shot, + talents.defense, table.concat(encodedPassives, ","), math.max(0, rank - 1), utcNow()) +end + +local function applyCustomOverrides(request, parameter) + if request.mode ~= "custom" then return end + local save = parameter.SaveParameter + if request.gender == "male" then save.Gender = 1 end + if request.gender == "female" then save.Gender = 2 end + save.IsRarePal = request.lucky == true + save.Rank = request.condensationStars + 1 + if request.ivMode == "exact" then + save.Talent_HP = request.talentHp + save.Talent_Melee = request.talentMelee + save.Talent_Shot = request.talentShot + save.Talent_Defense = request.talentDefense + elseif request.ivMode == "minimum" then + save.Talent_HP = math.max(tonumber(save.Talent_HP) or 0, request.talentHp) + save.Talent_Melee = math.max(tonumber(save.Talent_Melee) or 0, request.talentMelee) + save.Talent_Shot = math.max(tonumber(save.Talent_Shot) or 0, request.talentShot) + save.Talent_Defense = math.max(tonumber(save.Talent_Defense) or 0, request.talentDefense) + end +end + +local busy = false +local function verifyGrant(request, controller, before) + local attempts = 0 + LoopAsync(500, function() + attempts = attempts + 1 + ExecuteInGameThread(function() + local ok, outcome = pcall(function() + local boxOnly = config.ProductionValidated ~= true and config.ValidationBoxOnly == true + local after = ownedHandles(controller, false, boxOnly) + local candidates = {} + for id, handle in pairs(after) do + if not before[id] then candidates[#candidates + 1] = { id = id, handle = handle } end + end + if #candidates == 0 then return nil end + if #candidates ~= 1 then error("multiple_new_pals_detected") end + local parameter = candidates[1].handle:TryGetIndividualParameter() + if not validObject(parameter) then return nil end + applyCustomOverrides(request, parameter) + return parameterResult(request, candidates[1].id, parameter) + end) + if ok and outcome then + if writeAtomic(resultPath(request.requestId), outcome) then os.remove(processing) end + busy = false + elseif not ok then + writeFailure(request, "verification_failed", tostring(outcome)) + busy = false + elseif attempts >= 30 then + writeFailure(request, "outcome_unknown", "Palworld did not expose one verified new Palbox specimen; this request will not be retried.") + busy = false + end + end) + return not busy or attempts >= 30 + end) +end + +local function validationRequestAllowed(request) + if config.ValidationMode ~= true then return false end + local naturalProbe = request.mode == "natural" and request.levelMin == request.levelMax + and request.gender == "random" and request.alpha == false and request.lucky == false + and request.ivMode == "natural" and #request.passiveSkillIds == 0 + and request.condensationStars == 0 + if not naturalProbe then return false end + if request.preflightOnly then + return request.requestId == tostring(config.ValidationPreflightRequestID or "") + and request.characterId == tostring(config.ValidationCharacterID or "") + and request.levelMin == tonumber(config.ValidationLevel) + end + local preflight = readFile(resultPath(tostring(config.ValidationPreflightRequestID or ""))) or "" + return request.requestId == tostring(config.ValidationRequestID or "") + and request.characterId == tostring(config.ValidationCharacterID or "") + and request.levelMin == tonumber(config.ValidationLevel) + and preflight:match('"status"%s*:%s*"succeeded"') ~= nil + and preflight:match('"preflightOnly"%s*:%s*true') ~= nil + and preflight:match('"playerUid"%s*:%s*"' .. request.playerUid .. '"') ~= nil +end + +local function captureMonsterForPlayer(playerState, request) + local captureOk, captureError = pcall(function() + local level = request.levelMin + if request.levelMax > request.levelMin then + local seed = tonumber(request.requestId:sub(1, 8), 16) or 0 + level = request.levelMin + (seed % (request.levelMax - request.levelMin + 1)) + end + local characterId = request.alpha and ("BOSS_" .. request.characterId) or request.characterId + local passiveRows = {} + for _, passive in ipairs(request.passiveSkillIds) do + passiveRows[#passiveRows + 1] = { Key = FName(passive) } + end + local info = { + PalName = { Key = FName(characterId) }, + Level = level, + Rank = request.condensationStars + 1, + TalentLevel = 0, + WazaList = {}, + PassiveSkill = passiveRows, + StatusRank = {}, + FriendshipRank = 0, + bIsAwakening = false, + } + playerState:Debug_CaptureNewMonsterByDebugOtomoInfo_ToServer(info, request.mode == "natural") + end) + if not captureOk then + return false, "outcome_unknown", "The Palworld capture call returned an error after it began; it will not be retried: " .. tostring(captureError) + end + return true, nil, nil +end + +local function processInbox() + reloadRuntimeConfig() + if busy or not configured or not readFile(inbox) then return end + if not os.rename(inbox, processing) then return end + local request, parseError = parseRequest(readFile(processing)) + if parseError then + writeFailure(request or { requestId = "invalid" }, parseError, "The bridge rejected an invalid or unsupported Pal request.") + return + end + if readFile(resultPath(request.requestId)) then + os.remove(processing) + return + end + if config.ProductionValidated ~= true and not validationRequestAllowed(request) then + writeFailure(request, "validation_required", "This exact game build has not passed isolated Pal grant validation.") + return + end + busy = true + local scheduled, scheduleError = pcall(function() + ExecuteInGameThread(function() + local ok, outcome, controller, callErrorCode, callErrorMessage, preflightOnly = pcall(function() + local resolvedController = findController(request.playerUid) + if not resolvedController then + return nil, nil, "player_not_found", "No unique online player matched the stable UID on the game thread." + end + local boxOnly = config.ProductionValidated ~= true and config.ValidationBoxOnly == true + local before = ownedHandles(resolvedController, true, boxOnly) + if request.preflightOnly then + return before, resolvedController, nil, nil, true + end + local playerState = resolvedController:GetPalPlayerState() + if not validObject(playerState) then error("player_state_unavailable") end + local callOk, errorCode, errorMessage = captureMonsterForPlayer(playerState, request) + if not callOk then return nil, resolvedController, errorCode, errorMessage end + return before, resolvedController, nil, nil + end) + if not ok then + writeFailure(request, "grant_call_failed", tostring(outcome)) + busy = false + elseif not outcome then + writeFailure(request, callErrorCode or "outcome_unknown", callErrorMessage or "The Palworld grant outcome could not be established.") + busy = false + elseif preflightOnly then + writePreflightSuccess(request) + busy = false + else + verifyGrant(request, controller, outcome) + end + end) + end) + if not scheduled then + writeFailure(request, "outcome_unknown", "The game-thread call could not be scheduled; this request was not retried: " .. tostring(scheduleError)) + busy = false + end +end + +local orphan = parseRequest(readFile(processing)) +if orphan then + writeFailure(orphan, "outcome_unknown", "The game stopped while this grant was processing; it was not retried.") +end + +local function writeDumpStatus(state, detail) + local body = string.format('{"state":"%s","detail":"%s","at":"%s"}\n', jsonEscape(state), jsonEscape(detail), utcNow()) + writeAtomic(joinPath(spool, "uht-dump-status.json"), body) +end + +local function generateUHTHeadersOnce() + if config.GenerateUHTHeaders ~= true then return end + if type(GenerateUHTCompatibleHeaders) ~= "function" then + writeDumpStatus("unavailable", "GenerateUHTCompatibleHeaders is unavailable") + return + end + writeDumpStatus("running", "read-only UHT header generation started") + ExecuteInGameThread(function() + local ok, err = pcall(GenerateUHTCompatibleHeaders) + if ok then + writeDumpStatus("complete", "inspect UE4SS/UHTHeaderDump") + else + writeDumpStatus("failed", tostring(err)) + end + end) +end + +writeCapability() +LoopAsync(1000, function() + processInbox() + return false +end) +LoopAsync(10000, function() + writeCapability() + return false +end) +LoopAsync(15000, function() + generateUHTHeadersOnce() + return true +end) + +local loadedState = configured and (config.ProductionValidated == true and "ready" or "validation_required") or "invalid_configuration" +log(string.format("loaded: state=%s, capture-native grant=%s, advanced=%s", loadedState, + tostring(checks.debugCaptureNewMonster), tostring(config.AdvancedEnabled == true))) diff --git a/mods/palhelm-pal-bridge/config.example.lua b/mods/palhelm-pal-bridge/config.example.lua new file mode 100644 index 0000000..9f269a4 --- /dev/null +++ b/mods/palhelm-pal-bridge/config.example.lua @@ -0,0 +1,22 @@ +return { + SpoolDir = "/palhelm-data/pal-grants", + GameVersion = "v1.0.1.100619", + CatalogVersion = "palworld_1.0_pinned", + CatalogItems = 249, + -- Maintenance-only, read-only reflection dump. Set true for one startup, + -- inspect UHTHeaderDump, then set false again. + GenerateUHTHeaders = false, + -- Isolated one-request validation gate. This does not make capability ready. + ValidationMode = false, + ValidationPreflightRequestID = "", + ValidationRequestID = "", + ValidationCharacterID = "SheepBall", + ValidationLevel = 1, + ValidationBoxOnly = false, + -- Load custom generation support during the same game startup. The + -- independent ProductionValidated gate still blocks ordinary requests. + AdvancedEnabled = true, + -- Optional override; normally auto-detected beside the ue4ss Mods directory. + -- MemberVariableLayoutPath = "/absolute/path/to/ue4ss/MemberVariableLayout.ini", + ProductionValidated = false, +} diff --git a/mods/palhelm-pal-bridge/config.validation.lua b/mods/palhelm-pal-bridge/config.validation.lua new file mode 100644 index 0000000..6ec8948 --- /dev/null +++ b/mods/palhelm-pal-bridge/config.validation.lua @@ -0,0 +1,99 @@ +-- One-shot in-memory adapter for the currently loaded script. Future starts use +-- the matching implementation already installed in main.lua. +local hotfixMarker = [[/palhelm-pal-grants/hotfix-owned-handles-once]] +local marker = io.open(hotfixMarker, "rb") +if marker then + marker:close() + os.remove(hotfixMarker) + + local function validObject(object) + if not object then return false end + local ok, valid = pcall(function() return object:IsValid() end) + return ok and valid == true + end + + local function hex32(value) + local formatted = string.format("%016x", tonumber(value) or 0) + return formatted:sub(-8) + end + + local function instanceId(handle) + local id = handle:GetIndividualID().InstanceId + return string.lower(hex32(id.A) .. hex32(id.B) .. hex32(id.C) .. hex32(id.D)) + end + + local function addHandle(handles, handle) + if not validObject(handle) then return false end + local id = instanceId(handle) + if #id ~= 32 then return false end + handles[id] = handle + return true + end + + local function addContainer(handles, container) + local slots = container:GetSlots() + if not slots then error("character_container_slots_unavailable") end + for _, slot in ipairs(slots) do + if validObject(slot) then addHandle(handles, slot:GetHandle()) end + end + end + + local function ownedHandles(controller, requireEmptySlot) + local state = controller:GetPalPlayerState() + if not validObject(state) then error("player_state_unavailable") end + local storage = state:GetPalStorage() + if not validObject(storage) then error("pal_storage_unavailable") end + local boxContainer = storage.TargetContainer + if not validObject(boxContainer) then error("pal_storage_container_unavailable") end + + local handles = {} + addContainer(handles, boxContainer) + if requireEmptySlot then + local emptyPage = tonumber(storage:GetPageIndexExistEmptySlot(0)) + local boxEmpty = emptyPage ~= nil and emptyPage >= 0 + if not boxEmpty then error("pal_storage_full") end + end + return handles + end + + local patchedOwnedHandles = false + local clearedBusy = false + for stackLevel = 2, 12 do + local info = debug.getinfo(stackLevel, "f") + if info and type(info.func) == "function" then + for index = 1, 128 do + local name, value = debug.getupvalue(info.func, index) + if not name then break end + if name == "busy" then + debug.setupvalue(info.func, index, false) + clearedBusy = true + elseif name == "ownedHandles" and type(value) == "function" then + debug.setupvalue(info.func, index, ownedHandles) + patchedOwnedHandles = true + end + end + end + end + + local diagnostic = io.open([[/palhelm-pal-grants/hotfix-owned-handles-result.txt]], "wb") + if diagnostic then + diagnostic:write("patchedOwnedHandles=", tostring(patchedOwnedHandles), " clearedBusy=", tostring(clearedBusy), "\n") + diagnostic:close() + end +end + +return { + SpoolDir = [[/palhelm-pal-grants]], + GameVersion = "v1.0.1.100619", + CatalogVersion = "palworld_1.0_pinned", + CatalogItems = 249, + GenerateUHTHeaders = false, + ValidationMode = true, + ValidationPreflightRequestID = "palhelm_validation_20260719_12_preflight", + ValidationRequestID = "palhelm_validation_20260719_12", + ValidationCharacterID = "SheepBall", + ValidationLevel = 1, + ValidationBoxOnly = true, + AdvancedEnabled = true, + ProductionValidated = false, +} diff --git a/mods/palhelm-pal-bridge/enabled.txt b/mods/palhelm-pal-bridge/enabled.txt new file mode 100644 index 0000000..0a3011a --- /dev/null +++ b/mods/palhelm-pal-bridge/enabled.txt @@ -0,0 +1 @@ +PalhelmPalBridge : 1 diff --git a/mods/palhelm-pal-native/Dockerfile.builder b/mods/palhelm-pal-native/Dockerfile.builder new file mode 100644 index 0000000..dd16bbd --- /dev/null +++ b/mods/palhelm-pal-native/Dockerfile.builder @@ -0,0 +1,9 @@ +FROM gcc:13 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends clang libc++-dev libc++abi-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /source + +ENTRYPOINT ["clang++"] diff --git a/mods/palhelm-pal-native/README.md b/mods/palhelm-pal-native/README.md new file mode 100644 index 0000000..6560bb6 --- /dev/null +++ b/mods/palhelm-pal-native/README.md @@ -0,0 +1,86 @@ +# Palhelm native Pal bridge + +Experimental, server-only Linux UE4SS C++ provider for Palhelm's Give Pal +workflow. It is not installed or production-ready. + +## Retired exact target + +- Palworld executable build ID: `7f7e167407984ec3` +- Palworld executable SHA-256: + `788649fa1592160faa7bcf07ccd16d474ebeaae954717bc32284b5a43028d8e7` +- UE4SS fork: `Yangff/RE-UE4SS`, `linux-port-rebase2` +- UE4SS commit: `9b4552068804a5f5ec2309ca378aeb65af40ee1a` +- Installed `libUE4SS.so` SHA-256: + `b614f4f0555319be60a45fd5c47e7ca8089a3222babf49a42d4bdbd187855fb1` + +This Palworld target is no longer installed. As of 2026-08-02, the live server +runs `v1.0.2.101103` / Steam build `24466863`, with GNU build ID +`787f7f8c15edb8fb` and executable SHA-256 +`c508a28b06cebf0752296b38da5244c08a5688da44dad8f816eb2d726d82699e`. +The probe must remain disabled against it. Do not update the constants below +until a fresh UHT dump, `MemberVariableLayout.ini`, and reflected metadata audit +have been completed on a disposable server for the exact new target. + +The installed fork loads `Mods//dlls/main.so` with `dlopen` and resolves +`start_mod` plus `uninstall_mod`. This is not upstream's stable Linux C++ ABI. +The minimal ABI mirror is therefore pinned to the exact fork commit and includes +the `HAS_UI` base-class member and virtual slots used by that build. + +## Current capability probe + +The first artifact is deliberately non-mutating. On Unreal initialization it: + +1. reads the GNU build ID from the running main executable; +2. refuses to continue on any build other than the retired pinned Palworld build; +3. checks that the four reflected functions required by the proposed native + spawn-and-capture path still exist; +4. logs readiness, but does not read a request or create a Pal. + +The bridge links to the already-loaded `libUE4SS.so` without linking a second +C++ standard library. That avoids the static-runtime symbol interposition called +out by current upstream Linux experiments. + +## Build + +Run `./mods/palhelm-pal-native/build.sh`. The disposable builder uses Clang and +libc++ because the installed UE4SS exports the `std::__1` ABI. The output is +`dist/main.so`. + +Before any live install, inspect the artifact with `readelf`, verify its only +UE4SS dependency is `libUE4SS.so`, hash both live target binaries again, take and +verify a world backup, and obtain explicit restart approval. + +`./mods/palhelm-pal-native/verify-target.sh` performs the target hash, layout, +and reflected-header preflight. A pass authorizes staging only; it does not +authorize installing, loading, restarting, or mutating the world. + +## Planned mutation provider + +The first grant implementation will remain behind Palhelm's existing exact-one- +request validation gate: + +1. stage one request off-thread without touching Unreal objects; +2. execute state transitions from a native ProcessEvent pre-hook on the game + thread; +3. resolve the target by stable player UID and preflight party/Palbox capacity; +4. allocate and initialize reflected parameter buffers through `FProperty`; +5. call `GetInitializedCharacterSaveParemter` natively so the large out struct is + never marshalled through Lua; +6. create/spawn one individual, then deliver through Palworld's authoritative + capture/grant path; +7. verify owner UID, container, slot, instance ID, and generated fields; +8. refuse the mutation unless a current-build, authoritative cleanup route is + proven; the stale native symbols cannot be used as rollback; +9. report any timeout after dispatch as `outcome_unknown`, with no retry. + +The native-only cleanup and direct-handle grant helper names exist in the +installed Palworld `.sym` bundle, but that bundle predates the current executable +and its records do not land on the named functions in the current ELF. It is +therefore rejected as stale. No address from it may be called. Until Pocketpair +ships matching symbols or the helpers can be reached through a validated reflected +API, the capability state must remain `validation_required`. + +`tools/resolve_sym.cpp` implements Unreal 5.6's compact Breakpad symbol-record +format for offline, exact-name inspection. Its output demonstrated the mismatch: +the July 9 symbol records do not correspond to the July 15 executable's function +boundaries. It is a reverse-engineering aid only, never a runtime resolver. diff --git a/mods/palhelm-pal-native/build.sh b/mods/palhelm-pal-native/build.sh new file mode 100755 index 0000000..ac518ac --- /dev/null +++ b/mods/palhelm-pal-native/build.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source_dir="${repo_root}/mods/palhelm-pal-native" +ue4ss_dir="${UE4SS_DIR:-/home/hunter/palworld/data/Pal/Binaries/Linux/ue4ss}" +builder="${PALHELM_NATIVE_BUILDER:-palhelm/ue4ss-native-builder:bookworm}" + +mkdir -p "${source_dir}/dist" + +docker build --tag "${builder}" --file "${source_dir}/Dockerfile.builder" "${source_dir}" +docker run --rm \ + --volume "${source_dir}:/source:ro" \ + --volume "${source_dir}/dist:/output" \ + --volume "${ue4ss_dir}:/ue4ss:ro" \ + "${builder}" \ + -std=c++20 -O2 -fPIC -fvisibility=hidden -fno-exceptions -fno-rtti \ + -stdlib=libc++ -nostdlib++ \ + -I/source/include /source/src/main.cpp \ + -shared -L/ue4ss -lUE4SS \ + -Wl,--no-undefined -Wl,-z,defs -Wl,-z,relro -Wl,-z,now \ + -o /output/main.so diff --git a/mods/palhelm-pal-native/include/ue4ss_abi.hpp b/mods/palhelm-pal-native/include/ue4ss_abi.hpp new file mode 100644 index 0000000..15be772 --- /dev/null +++ b/mods/palhelm-pal-native/include/ue4ss_abi.hpp @@ -0,0 +1,90 @@ +#pragma once + +// Minimal ABI mirror for the exact Linux UE4SS fork installed on BES Pals. +// +// This is intentionally not a general UE4SS SDK. The fork's UEPseudo dependency +// is private, so the bridge declares only the stable loader surface it needs for +// a non-mutating capability probe. Keep the vtable order and HAS_UI data member +// in sync with Yangff/RE-UE4SS commit 9b4552068804a5f5ec2309ca378aeb65af40ee1a. + +#include +#include +#include +#include + +namespace RC +{ + using UEStringType = std::u16string; + using SystemStringViewType = std::string_view; + + namespace GUI + { + class GUITab; + } + + namespace LuaMadeSimple + { + class Lua; + } + + class CppUserModBase + { + protected: + // The installed library was built with HAS_UI. Omitting this member + // changes every following offset and makes construction unsafe. + std::vector> GUITabs{}; + + public: + UEStringType ModName{}; + UEStringType ModVersion{}; + UEStringType ModDescription{}; + UEStringType ModAuthors{}; + UEStringType ModIntendedSDKVersion{}; + + CppUserModBase(); + virtual ~CppUserModBase(); + + virtual auto on_update() -> void; + virtual auto on_unreal_init() -> void; + virtual auto on_ui_init() -> void; + virtual auto on_program_start() -> void; + + virtual auto on_lua_start(SystemStringViewType, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + std::vector&) -> void; + virtual auto on_lua_start(LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + std::vector&) -> void; + virtual auto on_lua_stop(SystemStringViewType, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + std::vector&) -> void; + virtual auto on_lua_stop(LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + LuaMadeSimple::Lua&, + std::vector&) -> void; + virtual auto on_dll_load(SystemStringViewType) -> void; + virtual auto render_tab() -> void; + }; + + static_assert(sizeof(CppUserModBase) == 0x98, + "UE4SS base layout drifted; do not load this bridge"); + + namespace Unreal + { + class UObject; + class UClass; + + namespace UObjectGlobals + { + auto StaticFindObject_InternalSlow(UClass* object_class, + UObject* object_package, + const char16_t* object_name, + bool exact_class) -> UObject*; + } + } +} diff --git a/mods/palhelm-pal-native/src/main.cpp b/mods/palhelm-pal-native/src/main.cpp new file mode 100644 index 0000000..a0794e0 --- /dev/null +++ b/mods/palhelm-pal-native/src/main.cpp @@ -0,0 +1,144 @@ +#include "ue4ss_abi.hpp" + +#include +#include +#include +#include +#include +#include + +namespace +{ + constexpr std::array kExpectedGameBuildId{ + 0x7f, 0x7e, 0x16, 0x74, 0x07, 0x98, 0x4e, 0xc3, + }; + + auto write_log(const char* message) -> void + { + const auto length = std::strlen(message); + static_cast(::write(STDERR_FILENO, message, length)); + } + + auto align4(std::size_t value) -> std::size_t + { + return (value + 3U) & ~std::size_t{3U}; + } + + struct BuildIdSearch + { + bool found{}; + bool matches{}; + }; + + auto inspect_main_executable(dl_phdr_info* info, std::size_t, void* opaque) -> int + { + auto& search = *static_cast(opaque); + if (info->dlpi_name != nullptr && info->dlpi_name[0] != '\0') + { + return 0; + } + + for (std::uint16_t index = 0; index < info->dlpi_phnum; ++index) + { + const auto& header = info->dlpi_phdr[index]; + if (header.p_type != PT_NOTE) + { + continue; + } + + const auto* cursor = reinterpret_cast(info->dlpi_addr + header.p_vaddr); + const auto* end = cursor + header.p_memsz; + while (cursor + sizeof(ElfW(Nhdr)) <= end) + { + const auto* note = reinterpret_cast(cursor); + cursor += sizeof(ElfW(Nhdr)); + const auto* name = cursor; + cursor += align4(note->n_namesz); + const auto* description = cursor; + cursor += align4(note->n_descsz); + if (cursor > end) + { + break; + } + + if (note->n_type == NT_GNU_BUILD_ID && note->n_namesz >= 3 && + std::memcmp(name, "GNU", 3) == 0) + { + search.found = true; + search.matches = note->n_descsz == kExpectedGameBuildId.size() && + std::memcmp(description, + kExpectedGameBuildId.data(), + kExpectedGameBuildId.size()) == 0; + return 1; + } + } + } + return 1; + } + + auto game_build_matches() -> bool + { + BuildIdSearch search{}; + static_cast(::dl_iterate_phdr(inspect_main_executable, &search)); + return search.found && search.matches; + } + + auto reflection_surface_present() -> bool + { + using RC::Unreal::UObjectGlobals::StaticFindObject_InternalSlow; + constexpr std::array required_objects{ + u"/Script/Pal.PalUtility:GetInitializedCharacterSaveParemter", + u"/Script/Pal.PalUtility:PalCaptureSuccess", + u"/Script/Pal.PalUtility:GetCharacterManager", + u"/Script/Pal.PalCharacterManager:SpawnNewCharacter", + }; + + for (const auto* object_name : required_objects) + { + if (StaticFindObject_InternalSlow(nullptr, nullptr, object_name, false) == nullptr) + { + return false; + } + } + return true; + } + + class PalhelmPalNative final : public RC::CppUserModBase + { + public: + PalhelmPalNative() + { + // These stay inside libc++'s short-string representation. The mod is + // deliberately linked without a second C++ runtime. + ModName = u"Palhelm"; + ModVersion = u"0.1.0"; + ModAuthors = u"BES Pals"; + } + + auto on_unreal_init() -> void override + { + if (!game_build_matches()) + { + write_log("[PalhelmPalNative] disabled: Palworld build ID mismatch\n"); + return; + } + if (!reflection_surface_present()) + { + write_log("[PalhelmPalNative] disabled: required reflected API missing\n"); + return; + } + + write_log("[PalhelmPalNative] capability probe ready; mutation is not implemented\n"); + } + }; +} + +extern "C" __attribute__((visibility("default"))) auto start_mod() -> RC::CppUserModBase* +{ + return new PalhelmPalNative(); +} + +extern "C" __attribute__((visibility("default"))) auto uninstall_mod(RC::CppUserModBase* mod) -> void +{ + delete mod; +} diff --git a/mods/palhelm-pal-native/tools/resolve_sym.cpp b/mods/palhelm-pal-native/tools/resolve_sym.cpp new file mode 100644 index 0000000..863ab0d --- /dev/null +++ b/mods/palhelm-pal-native/tools/resolve_sym.cpp @@ -0,0 +1,108 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma pack(push, 1) +struct Record +{ + std::uint64_t address; + std::uint32_t line_number; + std::uint32_t file_relative_offset; + std::uint32_t symbol_relative_offset; +}; +#pragma pack(pop) + +static_assert(sizeof(Record) == 20); + +auto main(int argc, char** argv) -> int +{ + if (argc < 3) + { + std::cerr << "usage: resolve_sym FILE.sym SYMBOL [SYMBOL...]\n"; + return 2; + } + + const int fd = ::open(argv[1], O_RDONLY); + if (fd < 0) + { + std::cerr << "unable to open symbol file\n"; + return 2; + } + + struct stat status{}; + if (::fstat(fd, &status) != 0 || status.st_size < 4) + { + std::cerr << "invalid symbol file\n"; + ::close(fd); + return 2; + } + + const auto size = static_cast(status.st_size); + const auto* bytes = static_cast( + ::mmap(nullptr, size, PROT_READ, MAP_PRIVATE, fd, 0)); + ::close(fd); + if (bytes == MAP_FAILED) + { + std::cerr << "unable to map symbol file\n"; + return 2; + } + + std::uint32_t count{}; + std::memcpy(&count, bytes, sizeof(count)); + const std::size_t strings_offset = sizeof(count) + + static_cast(count) * sizeof(Record); + if (strings_offset >= size) + { + std::cerr << "invalid record count\n"; + ::munmap(const_cast(bytes), size); + return 2; + } + + const auto* records = reinterpret_cast(bytes + sizeof(count)); + std::vector found(static_cast(argc), false); + for (std::uint32_t index = 0; index < count; ++index) + { + const auto relative = records[index].symbol_relative_offset; + if (relative == UINT32_MAX || relative >= size - strings_offset) + { + continue; + } + + const char* symbol = reinterpret_cast(bytes + strings_offset + relative); + const auto remaining = size - strings_offset - relative; + const void* newline = std::memchr(symbol, '\n', remaining); + if (newline == nullptr) + { + continue; + } + const std::string_view candidate(symbol, + static_cast(newline) - symbol); + + for (int argument = 2; argument < argc; ++argument) + { + if (candidate == argv[argument]) + { + std::cout << "0x" << std::hex << records[index].address << std::dec + << '\t' << candidate << '\n'; + found[static_cast(argument)] = true; + break; + } + } + } + + ::munmap(const_cast(bytes), size); + for (int argument = 2; argument < argc; ++argument) + { + if (!found[static_cast(argument)]) + { + return 1; + } + } + return 0; +} diff --git a/mods/palhelm-pal-native/verify-target.sh b/mods/palhelm-pal-native/verify-target.sh new file mode 100755 index 0000000..7b885e2 --- /dev/null +++ b/mods/palhelm-pal-native/verify-target.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +linux_dir="${PALWORLD_LINUX_DIR:-/home/hunter/palworld/data/Pal/Binaries/Linux}" +game="${linux_dir}/PalServer-Linux-Shipping" +ue4ss="${linux_dir}/ue4ss/libUE4SS.so" +layout="${linux_dir}/ue4ss/MemberVariableLayout.ini" +headers="${linux_dir}/ue4ss/UHTHeaderDump/Pal/Public" + +expected_game_sha="788649fa1592160faa7bcf07ccd16d474ebeaae954717bc32284b5a43028d8e7" +expected_ue4ss_sha="b614f4f0555319be60a45fd5c47e7ca8089a3222babf49a42d4bdbd187855fb1" + +actual_game_sha="$(sha256sum "${game}" | awk '{print $1}')" +actual_ue4ss_sha="$(sha256sum "${ue4ss}" | awk '{print $1}')" + +if [[ "${actual_game_sha}" != "${expected_game_sha}" ]]; then + echo "Palworld executable mismatch; bridge must remain disabled" >&2 + exit 1 +fi +if [[ "${actual_ue4ss_sha}" != "${expected_ue4ss_sha}" ]]; then + echo "UE4SS library mismatch; bridge must remain disabled" >&2 + exit 1 +fi +if [[ ! -s "${layout}" ]]; then + echo "MemberVariableLayout.ini missing; bridge must remain disabled" >&2 + exit 1 +fi + +utility_header="$(rg -l 'GetInitializedCharacterSaveParemter\(' "${headers}" -g 'PalUtility*.h' | head -n 1)" +manager_header="$(rg -l 'SpawnNewCharacter\(' "${headers}" -g 'PalCharacterManager*.h' | head -n 1)" +if [[ -z "${utility_header}" || -z "${manager_header}" ]]; then + echo "required reflected Palworld API missing; bridge must remain disabled" >&2 + exit 1 +fi + +echo "Pinned Palworld and UE4SS target verified; capability probe may be staged" +echo "Native addresses from PalServer-Linux-Shipping.sym remain prohibited"