Save endgame progress, team presets, technique support and camera controls
This commit is contained in:
1 parent
a681affdb3
commit
639cef7d19
52 files changed
+11940
-766
No files matched your search
+44
-10
@@ -331,17 +331,41 @@ func DefaultScene() Scene {
|
||||
return Scene{PlaneID: 20411, FloorID: 20411001, EntryID: 2041101}
|
||||
}
|
||||
|
||||
// Tierce teams and best measured node results survive leaving a scene/login.
|
||||
type TierceNodeProgress struct {
|
||||
Score uint32 `json:"score"`
|
||||
Won bool `json:"won"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
}
|
||||
type TierceProgress struct {
|
||||
Teams [][]uint32 `json:"teams"`
|
||||
Buffs []uint32 `json:"buffs"`
|
||||
Nodes map[uint32]TierceNodeProgress `json:"nodes"`
|
||||
}
|
||||
|
||||
type TeamPreset struct {
|
||||
Name string `json:"name"`
|
||||
Avatars map[uint32]uint32 `json:"avatars"`
|
||||
LeaderSlot uint32 `json:"leader_slot"`
|
||||
Favourite bool `json:"favourite"`
|
||||
}
|
||||
|
||||
type Persistent struct {
|
||||
Lineups map[uint32]uint32 `json:"lineups"`
|
||||
Position Position `json:"position"`
|
||||
Scene Scene `json:"scene"`
|
||||
MainCharacter MultiPathAvatar `json:"main_character"`
|
||||
MarchType MultiPathAvatar `json:"march_type"`
|
||||
EnableSWGlobal *bool `json:"enable_sw_global"`
|
||||
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
|
||||
EnableAhaGlobal *bool `json:"enable_aha_global"`
|
||||
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
|
||||
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
|
||||
TeamPresets map[uint32]*TeamPreset `json:"team_presets"`
|
||||
ActiveTeamPreset uint32 `json:"active_team_preset"`
|
||||
TierceProgress map[uint32]*TierceProgress `json:"tierce_progress"`
|
||||
TechniqueFoodStock map[uint32]uint32 `json:"technique_food_stock"`
|
||||
AutoTechniqueFood *bool `json:"auto_technique_food"`
|
||||
Lineups map[uint32]uint32 `json:"lineups"`
|
||||
Position Position `json:"position"`
|
||||
Scene Scene `json:"scene"`
|
||||
MainCharacter MultiPathAvatar `json:"main_character"`
|
||||
MarchType MultiPathAvatar `json:"march_type"`
|
||||
EnableSWGlobal *bool `json:"enable_sw_global"`
|
||||
EnableCastoriceGlobal *bool `json:"enable_castorice_global"`
|
||||
EnableAhaGlobal *bool `json:"enable_aha_global"`
|
||||
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
|
||||
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
|
||||
}
|
||||
|
||||
// DefaultPersistent mirrors the Rust Default impl.
|
||||
@@ -368,6 +392,11 @@ func DefaultPersistent() Persistent {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FreesrData struct {
|
||||
TeamPresets map[uint32]*TeamPreset `json:"-"`
|
||||
ActiveTeamPreset uint32 `json:"-"`
|
||||
TierceProgress map[uint32]*TierceProgress `json:"-"`
|
||||
TechniqueFoodStock map[uint32]uint32 `json:"-"`
|
||||
AutoTechniqueFood *bool `json:"-"`
|
||||
persistentBaseline map[string]any
|
||||
|
||||
Lightcones []Lightcone `json:"lightcones"`
|
||||
@@ -413,6 +442,11 @@ func LoadFreesrData() (*FreesrData, error) {
|
||||
var p Persistent
|
||||
if err := json.Unmarshal(pers, &p); err == nil {
|
||||
data.Lineups = p.Lineups
|
||||
data.TeamPresets = p.TeamPresets
|
||||
data.ActiveTeamPreset = p.ActiveTeamPreset
|
||||
data.TierceProgress = p.TierceProgress
|
||||
data.TechniqueFoodStock = p.TechniqueFoodStock
|
||||
data.AutoTechniqueFood = p.AutoTechniqueFood
|
||||
data.Position = p.Position
|
||||
data.Scene = p.Scene
|
||||
data.MainCharacter = p.MainCharacter
|
||||
|
||||
@@ -39,7 +39,7 @@ func (f *FreesrData) persistentSnapshot() Persistent {
|
||||
ex[id] = avatar.ExRankUnlocked
|
||||
}
|
||||
}
|
||||
return Persistent{Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
|
||||
return Persistent{TeamPresets: f.TeamPresets, ActiveTeamPreset: f.ActiveTeamPreset, TierceProgress: f.TierceProgress, TechniqueFoodStock: f.TechniqueFoodStock, AutoTechniqueFood: f.AutoTechniqueFood, Lineups: f.Lineups, Position: f.Position, Scene: f.Scene,
|
||||
MainCharacter: f.MainCharacter, MarchType: f.MarchType,
|
||||
EnableSWGlobal: f.EnableSWGlobal, EnableCastoriceGlobal: f.EnableCastoriceGlobal,
|
||||
EnableAhaGlobal: f.EnableAhaGlobal, DressedSkins: f.DressedSkins, ExRanksUnlocked: ex}
|
||||
|
||||
@@ -162,34 +162,24 @@ func (a *AvatarJson) ToAvatarPathDataProto(lightcone *Lightcone, relics []*Relic
|
||||
out.EquipRelicList = append(out.EquipRelicList, r.ToEquipRelic())
|
||||
}
|
||||
|
||||
if len(a.Data.Skills) > 0 {
|
||||
for pointID, level := range a.Data.Skills {
|
||||
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
|
||||
PointId: pointID,
|
||||
Level: level,
|
||||
})
|
||||
}
|
||||
} else if len(a.Data.SkillsByAnchorType) > 0 {
|
||||
res, _ := GameRes()
|
||||
for anchorType, level := range a.Data.SkillsByAnchorType {
|
||||
pointID := anchorType
|
||||
if res != nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
|
||||
if pid, ok := res.AvatarSkillTreePoints[a.AvatarID][anchorType]; ok && pid != 0 {
|
||||
pointID = pid
|
||||
}
|
||||
// AvatarPathData uses AnchorType numbers, whereas BattleAvatar uses full
|
||||
// PointIDs. PearlSR avatar_mgr.createSkillTree and battle_mgr intentionally
|
||||
// serialize different IDs for these two protocol messages.
|
||||
if res, err := GameRes(); err == nil {
|
||||
points := res.AvatarSkillTreePoints[a.AvatarID]
|
||||
for anchor, pointID := range points {
|
||||
var level uint32
|
||||
if len(a.Data.Skills) > 0 {
|
||||
level = a.Data.Skills[pointID]
|
||||
} else if len(a.Data.SkillsByAnchorType) > 0 {
|
||||
level = a.Data.SkillsByAnchorType[anchor]
|
||||
} else {
|
||||
// Keep the existing no-import level behavior, using valid anchors.
|
||||
level = 1
|
||||
}
|
||||
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
|
||||
PointId: pointID,
|
||||
Level: level,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if res, err := GameRes(); err == nil && res.AvatarSkillTreePoints != nil && res.AvatarSkillTreePoints[a.AvatarID] != nil {
|
||||
for _, pid := range res.AvatarSkillTreePoints[a.AvatarID] {
|
||||
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree, &goproto.AvatarPathSkillTree{
|
||||
PointId: pid,
|
||||
Level: 1,
|
||||
})
|
||||
if level > 0 {
|
||||
out.AvatarPathSkillTree = append(out.AvatarPathSkillTree,
|
||||
&goproto.AvatarPathSkillTree{PointId: anchor, Level: level})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,15 +304,16 @@ func ToLineupAvatars(f *FreesrData) []*goproto.LineupAvatar {
|
||||
|
||||
// ToLineupInfo builds LineupInfo from a lineup map (order: ascending slot).
|
||||
func ToLineupInfo(lineups map[uint32]uint32) *goproto.LineupInfo {
|
||||
maxMp := uint32(5)
|
||||
if _, ok := lineups[1408]; ok {
|
||||
maxMp = 8
|
||||
var maxMp, currentMP uint32
|
||||
if rules, err := LoadTechniqueRules(); err == nil {
|
||||
maxMp = rules.MaxMP(lineups)
|
||||
currentMP = rules.InitialMP
|
||||
}
|
||||
|
||||
lineup := &goproto.LineupInfo{
|
||||
ExtraLineupType: goproto.ExtraLineupType_ExtraLineupType_LineupNone,
|
||||
Name: "Squad 1",
|
||||
Mp: maxMp,
|
||||
Mp: currentMP,
|
||||
MaxMp: maxMp,
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestStandaloneBattleRelicWire(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAvatarPathSkillTreePointIds(t *testing.T) {
|
||||
// 1. avatar with explicit Skills map uses real PointIDs directly
|
||||
// AvatarPathData uses resource AnchorType numbers; BattleAvatar uses PointIDs.
|
||||
av := AvatarJson{
|
||||
AvatarID: 1213,
|
||||
Data: AvatarData{
|
||||
@@ -55,8 +55,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
|
||||
if len(proto.AvatarPathSkillTree) != 2 {
|
||||
t.Fatalf("expected 2 skill tree nodes, got %d", len(proto.AvatarPathSkillTree))
|
||||
}
|
||||
if proto.AvatarPathSkillTree[0].PointId != 1213001 || proto.AvatarPathSkillTree[1].PointId != 1213002 {
|
||||
t.Fatalf("point ids must be real config point IDs, got %v", proto.AvatarPathSkillTree)
|
||||
if proto.AvatarPathSkillTree[0].PointId != 1 || proto.AvatarPathSkillTree[1].PointId != 2 {
|
||||
t.Fatalf("path point ids must be resource anchor numbers, got %v", proto.AvatarPathSkillTree)
|
||||
}
|
||||
|
||||
// 2. avatar with only SkillsByAnchorType resolves from AvatarSkillTreeConfig.json
|
||||
@@ -73,8 +73,8 @@ func TestAvatarPathSkillTreePointIds(t *testing.T) {
|
||||
if len(protoAnchor.AvatarPathSkillTree) != 1 {
|
||||
t.Fatalf("expected 1 skill tree node, got %d", len(protoAnchor.AvatarPathSkillTree))
|
||||
}
|
||||
if protoAnchor.AvatarPathSkillTree[0].PointId != 1001001 {
|
||||
t.Fatalf("expected resolved point ID 1001001 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
|
||||
if protoAnchor.AvatarPathSkillTree[0].PointId != 1 {
|
||||
t.Fatalf("expected anchor 1 for March Point01, got %d", protoAnchor.AvatarPathSkillTree[0].PointId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@ type AvatarConfig struct {
|
||||
}
|
||||
|
||||
type JsonConfig struct {
|
||||
MapEntrances map[uint32]MapEntranceEntry `json:"-"`
|
||||
SceneAnchors map[uint32][]AnchorItem `json:"-"`
|
||||
// entryId -> "P<plane>_F<floor>" -> config
|
||||
LevelOutputConfigs map[uint32]map[string]*LevelOutputConfig `json:"levelOutputConfigs"`
|
||||
AvatarConfigs map[uint32]AvatarConfig `json:"avatarConfigs"`
|
||||
@@ -192,6 +194,7 @@ func GameRes() (*JsonConfig, error) {
|
||||
_ = Teleports() // ensure teleportDB is initialized
|
||||
|
||||
loadSupplementaryResources(gameRes)
|
||||
applySceneMetadata(gameRes)
|
||||
})
|
||||
return gameRes, gameResErr
|
||||
}
|
||||
@@ -266,6 +269,8 @@ func loadSupplementaryResources(res *JsonConfig) {
|
||||
}
|
||||
|
||||
planesByID := map[uint32]MazePlaneEntry{}
|
||||
res.MapEntrances = entrancesByEntry
|
||||
res.SceneAnchors = map[uint32][]AnchorItem{}
|
||||
if raw, err := readResourceFile("resources/MazePlane.json", "MazePlane.json"); err == nil {
|
||||
var list []MazePlaneEntry
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
@@ -366,6 +371,7 @@ func loadSupplementaryResources(res *JsonConfig) {
|
||||
name: {
|
||||
IsEnteredSceneInfo: true,
|
||||
Scenes: groups,
|
||||
WorldID: planesByID[sc.PlaneID].WorldID,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -377,6 +383,7 @@ func loadSupplementaryResources(res *JsonConfig) {
|
||||
var aFile AnchorConfigFile
|
||||
if json.Unmarshal(raw, &aFile) == nil && len(aFile.AnchorConfig) > 0 {
|
||||
for _, item := range aFile.AnchorConfig {
|
||||
res.SceneAnchors[item.EntryID] = item.Anchor
|
||||
floorID := uint32(0)
|
||||
if ent, ok := entrancesByEntry[item.EntryID]; ok && ent.FloorID != 0 {
|
||||
floorID = ent.FloorID
|
||||
@@ -415,6 +422,72 @@ func loadSupplementaryResources(res *JsonConfig) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Supplementary scenes do not carry world metadata. Resolve it from the
|
||||
// installed entrance/plane tables, including entries supplied by Anchor.json.
|
||||
floorSavedValues := map[uint32]map[string]int32{}
|
||||
if raw, err := readResourceFile("resources/FloorSavedValuesConfig.json"); err == nil {
|
||||
var table struct {
|
||||
Floors []struct {
|
||||
FloorID uint32 `json:"FloorID"`
|
||||
Values []struct {
|
||||
Name string `json:"Name"`
|
||||
MaxValue int32 `json:"MaxValue"`
|
||||
} `json:"SavedValues"`
|
||||
} `json:"saved_values"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &table); err == nil {
|
||||
// Same unlocked scene defaults as PearlSR 005d2c12 scene_mgr.zig.
|
||||
for _, floor := range table.Floors {
|
||||
values := map[string]int32{}
|
||||
for _, value := range floor.Values {
|
||||
if value.Name != "" {
|
||||
values[value.Name] = value.MaxValue
|
||||
}
|
||||
}
|
||||
floorSavedValues[floor.FloorID] = values
|
||||
}
|
||||
} else {
|
||||
LogInfo("[SCENE] Invalid FloorSavedValuesConfig: %v", err)
|
||||
}
|
||||
}
|
||||
for entryID, configs := range res.LevelOutputConfigs {
|
||||
entrance, ok := entrancesByEntry[entryID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Legacy dumps label every entrance in a plane with its first floor.
|
||||
// An entrance has an explicit floor in MapEntrance; do not derive it.
|
||||
if len(configs) == 1 {
|
||||
canonical := fmt.Sprintf("P%d_F%d", entrance.PlaneID, entrance.FloorID)
|
||||
for name, config := range configs {
|
||||
if name != canonical {
|
||||
delete(configs, name)
|
||||
configs[canonical] = config
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
plane, ok := planesByID[entrance.PlaneID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for name, config := range configs {
|
||||
config.WorldID = plane.WorldID
|
||||
var configPlane, configFloor uint32
|
||||
if n, _ := fmt.Sscanf(name, "P%d_F%d", &configPlane, &configFloor); n != 2 {
|
||||
continue
|
||||
}
|
||||
if config.SavedValues == nil {
|
||||
config.SavedValues = map[string]int32{}
|
||||
}
|
||||
for name, value := range floorSavedValues[configFloor] {
|
||||
if _, exists := config.SavedValues[name]; !exists {
|
||||
config.SavedValues[name] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teleports lazily parses teleports.json ({floor_id: {teleport_id: info}}).
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
// RuntimeConfig is shared by both server processes. Deployment values belong
|
||||
// in configuration rather than in handlers or executable entry points.
|
||||
type RuntimeConfig struct {
|
||||
PlayerUID uint32 `json:"player_uid"`
|
||||
BattleDiagnostics BattleDiagnosticsConfig `json:"battle_diagnostics"`
|
||||
ClientVersion string `json:"client_version"`
|
||||
GameHost string `json:"game_host"`
|
||||
@@ -67,6 +68,16 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
*dest = uint16(n)
|
||||
}
|
||||
}
|
||||
if value, ok := os.LookupEnv("GAC_PLAYER_UID"); ok {
|
||||
n, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil || n == 0 {
|
||||
return cfg, fmt.Errorf("GAC_PLAYER_UID must be an integer between 1 and 4294967295")
|
||||
}
|
||||
cfg.PlayerUID = uint32(n)
|
||||
}
|
||||
if cfg.PlayerUID == 0 {
|
||||
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
|
||||
}
|
||||
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
|
||||
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Scene metadata is exported from the installed Client's RuntimeFloor tables.
|
||||
// A complete group list allows us to exclude deleted/client-owned groups
|
||||
// without maintaining special cases for individual maps or group IDs.
|
||||
type sceneMetadata struct {
|
||||
ClientVersion string `json:"client_version"`
|
||||
Floors map[uint32]struct {
|
||||
StartAnchor *AnchorItem `json:"start_anchor"`
|
||||
Monsters map[uint32]map[uint32]TeleportInfo `json:"monsters"`
|
||||
GroupCount int `json:"group_count"`
|
||||
Groups map[uint32]struct {
|
||||
LoadSide string `json:"load_side"`
|
||||
Deleted bool `json:"is_deleted"`
|
||||
} `json:"groups"`
|
||||
} `json:"floors"`
|
||||
}
|
||||
|
||||
func applySceneMetadata(res *JsonConfig) {
|
||||
raw, err := ReadResourceFile("resources/SceneMetadata.json")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var metadata sceneMetadata
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil {
|
||||
LogInfo("[SCENE] Invalid SceneMetadata: %v", err)
|
||||
return
|
||||
}
|
||||
config, err := LoadRuntimeConfig()
|
||||
if err != nil || metadata.ClientVersion != config.ClientVersion {
|
||||
LogInfo("[SCENE] SceneMetadata Client revision does not match runtime config; not applied")
|
||||
return
|
||||
}
|
||||
for entryID, configs := range res.LevelOutputConfigs {
|
||||
for name, scene := range configs {
|
||||
var planeID, floorID uint32
|
||||
if n, _ := fmt.Sscanf(name, "P%d_F%d", &planeID, &floorID); n != 2 {
|
||||
continue
|
||||
}
|
||||
floor, ok := metadata.Floors[floorID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if floor.GroupCount == 0 || len(floor.Groups) != floor.GroupCount {
|
||||
LogInfo("[SCENE] Incomplete group metadata for floor %d; not applied", floorID)
|
||||
continue
|
||||
}
|
||||
if floor.StartAnchor != nil {
|
||||
res.SceneAnchors[entryID] = []AnchorItem{*floor.StartAnchor}
|
||||
}
|
||||
for groupID := range scene.Scenes {
|
||||
group, exists := floor.Groups[groupID]
|
||||
if !exists || group.Deleted || group.LoadSide == "Client" {
|
||||
delete(scene.Scenes, groupID)
|
||||
LogInfo("[SCENE] Excluded group entry=%d floor=%d group=%d present=%t deleted=%t side=%s", entryID, floorID, groupID, exists, group.Deleted, group.LoadSide)
|
||||
continue
|
||||
}
|
||||
data := scene.Scenes[groupID]
|
||||
for index := range data.Monsters {
|
||||
monster := &data.Monsters[index]
|
||||
if placement, ok := floor.Monsters[groupID][monster.InstID]; ok {
|
||||
monster.Pos, monster.Rot = placement.Pos, placement.Rot
|
||||
}
|
||||
}
|
||||
scene.Scenes[groupID] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type TeamPresetRules struct {
|
||||
Count uint32 `json:"preset_count"`
|
||||
MaxMembers uint32 `json:"max_members"`
|
||||
MaxNameLength int `json:"max_name_length"`
|
||||
}
|
||||
|
||||
var teamPresetOnce sync.Once
|
||||
var teamPresetRules TeamPresetRules
|
||||
var teamPresetError error
|
||||
|
||||
func LoadTeamPresetRules() (*TeamPresetRules, error) {
|
||||
teamPresetOnce.Do(func() {
|
||||
raw, err := ReadResourceFile("resources/TeamPresetConfig.json")
|
||||
if err != nil {
|
||||
teamPresetError = err
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &teamPresetRules); err != nil {
|
||||
teamPresetError = err
|
||||
return
|
||||
}
|
||||
if teamPresetRules.Count == 0 || teamPresetRules.MaxMembers == 0 || teamPresetRules.MaxNameLength < 1 {
|
||||
teamPresetError = fmt.Errorf("invalid TeamPresetConfig")
|
||||
}
|
||||
})
|
||||
return &teamPresetRules, teamPresetError
|
||||
}
|
||||
func (f *FreesrData) TeamPreset(index uint32) *TeamPreset {
|
||||
original := f.TeamPresets[index]
|
||||
p := &TeamPreset{Name: fmt.Sprintf("Team %d", index+1), Avatars: map[uint32]uint32{}, Favourite: true}
|
||||
if original != nil {
|
||||
p.Name = original.Name
|
||||
p.LeaderSlot = original.LeaderSlot
|
||||
p.Favourite = original.Favourite
|
||||
for slot, id := range original.Avatars {
|
||||
p.Avatars[slot] = id
|
||||
}
|
||||
}
|
||||
// Preserve edits made through the existing /api/persistent lineups field.
|
||||
if index == f.ActiveTeamPreset {
|
||||
p.Avatars = map[uint32]uint32{}
|
||||
for slot, id := range f.Lineups {
|
||||
p.Avatars[slot] = id
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
func (f *FreesrData) SaveTeamPreset(index uint32, preset *TeamPreset) error {
|
||||
if f.TeamPresets == nil {
|
||||
f.TeamPresets = map[uint32]*TeamPreset{}
|
||||
}
|
||||
if preset.Avatars[preset.LeaderSlot] == 0 {
|
||||
found := false
|
||||
for slot, id := range preset.Avatars {
|
||||
if id != 0 && (!found || slot < preset.LeaderSlot) {
|
||||
preset.LeaderSlot = slot
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
old, oldLineup := f.TeamPresets[index], f.Lineups
|
||||
f.TeamPresets[index] = preset
|
||||
if index == f.ActiveTeamPreset {
|
||||
f.Lineups = map[uint32]uint32{}
|
||||
for slot, id := range preset.Avatars {
|
||||
f.Lineups[slot] = id
|
||||
}
|
||||
}
|
||||
if err := f.SavePersistentChecked(); err != nil {
|
||||
if old == nil {
|
||||
delete(f.TeamPresets, index)
|
||||
} else {
|
||||
f.TeamPresets[index] = old
|
||||
}
|
||||
f.Lineups = oldLineup
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type TechniqueFood struct {
|
||||
RecoverMP uint32 `json:"recover_mp"`
|
||||
MaxUse uint32 `json:"max_use"`
|
||||
PileLimit uint32 `json:"pile_limit"`
|
||||
}
|
||||
type MazeSkillRule struct {
|
||||
ID uint32 `json:"MazeSkillId"`
|
||||
MPCost uint32 `json:"MPCost"`
|
||||
Trigger string `json:"SkillTriggerKey"`
|
||||
}
|
||||
type TechniqueRules struct {
|
||||
ClientVersion string `json:"client_version"`
|
||||
BaseMaxMP uint32 `json:"base_max_mp"`
|
||||
InitialMP uint32 `json:"initial_mp"`
|
||||
MaxMPByAvatar map[uint32]uint32 `json:"max_mp_by_avatar"`
|
||||
NonAttackTechniques []uint32 `json:"non_attack_techniques"`
|
||||
Avatars map[uint32][]MazeSkillRule `json:"avatars"`
|
||||
Foods map[uint32]TechniqueFood `json:"foods"`
|
||||
InitialFoodCount uint32 `json:"initial_food_count"`
|
||||
AutoFoodDefault bool `json:"auto_food_default"`
|
||||
}
|
||||
|
||||
var techniqueOnce sync.Once
|
||||
var techniqueRules TechniqueRules
|
||||
var techniqueErr error
|
||||
|
||||
func LoadTechniqueRules() (*TechniqueRules, error) {
|
||||
techniqueOnce.Do(func() {
|
||||
raw, err := ReadResourceFile("resources/TechniqueRules.json")
|
||||
if err != nil {
|
||||
techniqueErr = err
|
||||
return
|
||||
}
|
||||
if err = json.Unmarshal(raw, &techniqueRules); err != nil {
|
||||
techniqueErr = err
|
||||
return
|
||||
}
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
techniqueErr = err
|
||||
return
|
||||
}
|
||||
if techniqueRules.ClientVersion != cfg.ClientVersion || techniqueRules.BaseMaxMP == 0 || techniqueRules.InitialMP > techniqueRules.BaseMaxMP {
|
||||
techniqueErr = fmt.Errorf("invalid or mismatched TechniqueRules client resource")
|
||||
return
|
||||
}
|
||||
for id, f := range techniqueRules.Foods {
|
||||
if id == 0 || f.RecoverMP == 0 || f.MaxUse == 0 || techniqueRules.InitialFoodCount > f.PileLimit {
|
||||
techniqueErr = fmt.Errorf("invalid technique food %d", id)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return &techniqueRules, techniqueErr
|
||||
}
|
||||
func (r *TechniqueRules) MaxMP(avatars map[uint32]uint32) uint32 {
|
||||
maximum := r.BaseMaxMP
|
||||
// PearlSR buildLineup selects the largest team cap; it does not add both caps.
|
||||
for _, id := range avatars {
|
||||
if value := r.MaxMPByAvatar[id]; value > maximum {
|
||||
maximum = value
|
||||
}
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
// Called only by an explicit inventory/cast operation, never by save readers.
|
||||
func (f *FreesrData) EnsureTechniqueFoods() error {
|
||||
if f.TechniqueFoodStock != nil && f.AutoTechniqueFood != nil {
|
||||
return nil
|
||||
}
|
||||
r, err := LoadTechniqueRules()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.TechniqueFoodStock == nil {
|
||||
f.TechniqueFoodStock = map[uint32]uint32{}
|
||||
for id := range r.Foods {
|
||||
f.TechniqueFoodStock[id] = r.InitialFoodCount
|
||||
}
|
||||
}
|
||||
if f.AutoTechniqueFood == nil {
|
||||
value := r.AutoFoodDefault
|
||||
f.AutoTechniqueFood = &value
|
||||
}
|
||||
return f.SavePersistentChecked()
|
||||
}
|
||||
|
||||
// Refresh just the setting, and advance its merge baseline so a subsequent
|
||||
// session position save cannot undo an external API toggle.
|
||||
func (f *FreesrData) RefreshTechniqueSettings(p *Persistent) {
|
||||
f.AutoTechniqueFood = p.AutoTechniqueFood
|
||||
if snapshot, err := jsonObject(f.persistentSnapshot()); err == nil {
|
||||
if f.persistentBaseline == nil {
|
||||
f.persistentBaseline = map[string]any{}
|
||||
}
|
||||
f.persistentBaseline["auto_technique_food"] = snapshot["auto_technique_food"]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user