506 lines
15 KiB
Go
506 lines
15 KiB
Go
// Package common ports the Rust `common` crate: the JSON data model shared by
|
|
// the sdkserver (srtools upload), the file watcher and the gameserver. JSON
|
|
// field names match the original FreeSR-format files byte-for-byte so the
|
|
// existing freesr-data.json / persistent / res.json / teleports.json keep
|
|
// working without conversion.
|
|
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MultiPathAvatar — string-serialized C-like enum in JSON, numeric in logic.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type MultiPathAvatar uint32
|
|
|
|
const (
|
|
MultiPathUnk MultiPathAvatar = 0
|
|
MultiPathMarchPreservation MultiPathAvatar = 1001
|
|
MultiPathMarchHunt MultiPathAvatar = 1224
|
|
MultiPathMalePhysical MultiPathAvatar = 8001
|
|
MultiPathFemalePhysical MultiPathAvatar = 8002
|
|
MultiPathMalePreservation MultiPathAvatar = 8003
|
|
MultiPathFemalePreservation MultiPathAvatar = 8004
|
|
MultiPathMaleHarmony MultiPathAvatar = 8005
|
|
MultiPathFemaleHarmony MultiPathAvatar = 8006
|
|
MultiPathMaleRemembrance MultiPathAvatar = 8007
|
|
MultiPathFemaleRemembrance MultiPathAvatar = 8008
|
|
MultiPathMaleElation MultiPathAvatar = 8009
|
|
MultiPathFemaleElation MultiPathAvatar = 8010
|
|
)
|
|
|
|
var multiPathByName = map[string]MultiPathAvatar{
|
|
"MalePyhsical": MultiPathMalePhysical, // typo kept from the Rust enum
|
|
"FemalePhysical": MultiPathFemalePhysical,
|
|
"MalePreservation": MultiPathMalePreservation,
|
|
"FemalePreservation": MultiPathFemalePreservation,
|
|
"MaleHarmony": MultiPathMaleHarmony,
|
|
"FemaleHarmony": MultiPathFemaleHarmony,
|
|
"MaleRemembrance": MultiPathMaleRemembrance,
|
|
"FemaleRemembrance": MultiPathFemaleRemembrance,
|
|
"MaleElation": MultiPathMaleElation,
|
|
"FemaleElation": MultiPathFemaleElation,
|
|
"MarchHunt": MultiPathMarchHunt,
|
|
"MarchPreservation": MultiPathMarchPreservation,
|
|
"Unk": MultiPathUnk,
|
|
}
|
|
|
|
var multiPathByID = map[uint32]MultiPathAvatar{
|
|
8001: MultiPathMalePhysical,
|
|
8002: MultiPathFemalePhysical,
|
|
8003: MultiPathMalePreservation,
|
|
8004: MultiPathFemalePreservation,
|
|
8005: MultiPathMaleHarmony,
|
|
8006: MultiPathFemaleHarmony,
|
|
8007: MultiPathMaleRemembrance,
|
|
8008: MultiPathFemaleRemembrance,
|
|
8009: MultiPathMaleElation,
|
|
8010: MultiPathFemaleElation,
|
|
1224: MultiPathMarchHunt,
|
|
1001: MultiPathMarchPreservation,
|
|
}
|
|
|
|
// MultiPathAvatarFromID mirrors Rust From<u32> (unknown -> Unk).
|
|
func MultiPathAvatarFromID(v uint32) MultiPathAvatar {
|
|
if m, ok := multiPathByID[v]; ok {
|
|
return m
|
|
}
|
|
return MultiPathUnk
|
|
}
|
|
|
|
// ID mirrors Rust From<MultiPathAvatar> for u32 (Unk -> 8006).
|
|
func (m MultiPathAvatar) ID() uint32 {
|
|
if m == MultiPathUnk {
|
|
return 8006
|
|
}
|
|
return uint32(m)
|
|
}
|
|
|
|
func (m MultiPathAvatar) String() string {
|
|
for name, v := range multiPathByName {
|
|
if v == m {
|
|
return name
|
|
}
|
|
}
|
|
return "Unk"
|
|
}
|
|
|
|
func (m MultiPathAvatar) MarshalJSON() ([]byte, error) { return json.Marshal(m.String()) }
|
|
|
|
func (m *MultiPathAvatar) UnmarshalJSON(data []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
// numeric fallback
|
|
var n uint32
|
|
if err2 := json.Unmarshal(data, &n); err2 == nil {
|
|
*m = MultiPathAvatarFromID(n)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
v, ok := multiPathByName[s]
|
|
if !ok {
|
|
v = MultiPathUnk
|
|
}
|
|
*m = v
|
|
return nil
|
|
}
|
|
|
|
// MultiPathAvatarType in the loaded schema uses the avatar id itself as the
|
|
// enum value (Mar7thKnightType=1001, BoyWarriorType=8001, ...), so the proto
|
|
// value is simply the numeric id (0 for Unk).
|
|
func (m MultiPathAvatar) ProtoType() uint32 { return uint32(m) }
|
|
|
|
func IsMainCharacter(avatarID uint32) bool { return avatarID >= 8001 && avatarID <= 8010 }
|
|
|
|
func IsMarch7th(avatarID uint32) bool { return avatarID == 1001 || avatarID == 1224 }
|
|
|
|
func BaseAvatarID(avatarID uint32) uint32 {
|
|
if IsMainCharacter(avatarID) {
|
|
return 8001
|
|
}
|
|
if IsMarch7th(avatarID) {
|
|
return 1001
|
|
}
|
|
return avatarID
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Item structs (freesr-data.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Lightcone struct {
|
|
Level uint32 `json:"level"`
|
|
ItemID uint32 `json:"item_id"`
|
|
EquipAvatar uint32 `json:"equip_avatar"`
|
|
Rank uint32 `json:"rank"`
|
|
Promotion uint32 `json:"promotion"`
|
|
InternalUID uint32 `json:"internal_uid"`
|
|
}
|
|
|
|
type SubAffix struct {
|
|
SubAffixID uint32 `json:"sub_affix_id"`
|
|
Count uint32 `json:"count"`
|
|
Step uint32 `json:"step"`
|
|
}
|
|
|
|
type Relic struct {
|
|
Level uint32 `json:"level"`
|
|
RelicID uint32 `json:"relic_id"`
|
|
MainAffixID uint32 `json:"main_affix_id"`
|
|
SubAffixes []SubAffix `json:"sub_affixes"`
|
|
InternalUID uint32 `json:"internal_uid"`
|
|
EquipAvatar uint32 `json:"equip_avatar"`
|
|
}
|
|
|
|
func (r *Relic) Slot() uint32 { return r.RelicID % 10 }
|
|
func (r *Relic) UniqueID() uint32 { return r.InternalUID }
|
|
|
|
func (l *Lightcone) UniqueID() uint32 { return 3000 + l.InternalUID }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Avatar (freesr-data.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type AvatarData struct {
|
|
Rank uint32 `json:"rank"`
|
|
Skills map[uint32]uint32 `json:"skills"`
|
|
SkillsByAnchorType map[uint32]uint32 `json:"skills_by_anchor_type"`
|
|
}
|
|
|
|
type AvatarJson struct {
|
|
AvatarID uint32 `json:"avatar_id"`
|
|
Data AvatarData `json:"data"`
|
|
Level uint32 `json:"level"`
|
|
Promotion uint32 `json:"promotion"`
|
|
Techniques []uint32 `json:"techniques"`
|
|
SPValue *uint32 `json:"sp_value"`
|
|
SPMax *uint32 `json:"sp_max"`
|
|
EnhancedID *uint32 `json:"enhanced_id"`
|
|
ExRankUnlocked bool `json:"ex_rank_unlocked,omitempty"`
|
|
}
|
|
|
|
func (a *AvatarJson) SPCur() uint32 {
|
|
if a.SPValue != nil {
|
|
return *a.SPValue
|
|
}
|
|
return 10000
|
|
}
|
|
|
|
func (a *AvatarJson) SPMaxVal() uint32 {
|
|
if a.SPMax != nil {
|
|
return *a.SPMax
|
|
}
|
|
return 10000
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Battle config (freesr-data.json)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type BattleType string
|
|
|
|
const (
|
|
BattleTypeDefault BattleType = "Default"
|
|
BattleTypeMOC BattleType = "MOC"
|
|
BattleTypePF BattleType = "PF"
|
|
BattleTypeSU BattleType = "SU"
|
|
BattleTypeAS BattleType = "AS"
|
|
BattleTypeAA BattleType = "AA"
|
|
)
|
|
|
|
type Monster struct {
|
|
Level uint32 `json:"level"`
|
|
MonsterID uint32 `json:"monster_id"`
|
|
MaxHP uint32 `json:"max_hp"`
|
|
}
|
|
|
|
type DynamicKey struct {
|
|
Key string `json:"key"`
|
|
Value uint32 `json:"value"`
|
|
}
|
|
|
|
type BattleBuffJson struct {
|
|
Level uint32 `json:"level"`
|
|
ID uint32 `json:"id"`
|
|
DynamicKey *DynamicKey `json:"dynamic_key"`
|
|
DynamicValues []DynamicKey `json:"dynamic_values"`
|
|
}
|
|
|
|
type RogueMagicComponentType uint32
|
|
|
|
const (
|
|
RogueMagicPassive RogueMagicComponentType = 3
|
|
RogueMagicActive RogueMagicComponentType = 4
|
|
RogueMagicAttach RogueMagicComponentType = 5
|
|
)
|
|
|
|
type RogueMagicComponent struct {
|
|
ID uint32 `json:"id"`
|
|
Level uint32 `json:"level"`
|
|
ComponentType RogueMagicComponentType `json:"component_type"`
|
|
}
|
|
|
|
type RogueMagicScepter struct {
|
|
Level uint32 `json:"level"`
|
|
ID uint32 `json:"id"`
|
|
Components []RogueMagicComponent `json:"components"`
|
|
}
|
|
|
|
type BattleConfig struct {
|
|
BattleType BattleType `json:"battle_type"`
|
|
Monsters [][]Monster `json:"monsters"`
|
|
Blessings []BattleBuffJson `json:"blessings"`
|
|
StageID uint32 `json:"stage_id"`
|
|
CycleCount uint32 `json:"cycle_count"`
|
|
PathResonanceID uint32 `json:"path_resonance_id"`
|
|
CustomStats []SubAffix `json:"custom_stats"`
|
|
Scepters []RogueMagicScepter `json:"scepters"`
|
|
CustomBattleLineup map[uint32]uint32 `json:"custom_battle_lineup"`
|
|
}
|
|
|
|
func DefaultBattleConfig() BattleConfig {
|
|
return BattleConfig{
|
|
BattleType: BattleTypeDefault,
|
|
Monsters: [][]Monster{{{Level: 60, MonsterID: 3014022}}},
|
|
StageID: 201012311,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistent (file: "persistent")
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Position struct {
|
|
X int32 `json:"x"`
|
|
Y int32 `json:"y"`
|
|
Z int32 `json:"z"`
|
|
RotY int32 `json:"rot_y"`
|
|
}
|
|
|
|
func DefaultPosition() Position {
|
|
return Position{X: -26968, Y: 78953, Z: 14457, RotY: 11858}
|
|
}
|
|
|
|
type Scene struct {
|
|
PlaneID uint32 `json:"plane_id"`
|
|
FloorID uint32 `json:"floor_id"`
|
|
EntryID uint32 `json:"entry_id"`
|
|
}
|
|
|
|
func DefaultScene() Scene {
|
|
return Scene{PlaneID: 20411, FloorID: 20411001, EntryID: 2041101}
|
|
}
|
|
|
|
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"`
|
|
DressedSkins map[uint32]uint32 `json:"dressed_skins"`
|
|
ExRanksUnlocked map[uint32]bool `json:"ex_ranks_unlocked"`
|
|
}
|
|
|
|
// DefaultPersistent mirrors the Rust Default impl.
|
|
func DefaultPersistent() Persistent {
|
|
sw := true
|
|
cg := true
|
|
return Persistent{
|
|
Lineups: map[uint32]uint32{0: 1313, 1: 1006, 2: 8001, 3: 1405},
|
|
Position: DefaultPosition(),
|
|
MainCharacter: MultiPathFemaleRemembrance,
|
|
Scene: DefaultScene(),
|
|
MarchType: MultiPathMarchHunt,
|
|
EnableSWGlobal: &sw,
|
|
EnableCastoriceGlobal: &cg,
|
|
DressedSkins: map[uint32]uint32{},
|
|
ExRanksUnlocked: map[uint32]bool{},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FreesrData
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type FreesrData struct {
|
|
Lightcones []Lightcone `json:"lightcones"`
|
|
Relics []Relic `json:"relics"`
|
|
Avatars map[uint32]*AvatarJson `json:"avatars"`
|
|
BattleConfig BattleConfig `json:"battle_config"`
|
|
|
|
// Fields stored in "persistent", merged on load (not serialized here).
|
|
Lineups map[uint32]uint32 `json:"-"`
|
|
Position Position `json:"-"`
|
|
Scene Scene `json:"-"`
|
|
MainCharacter MultiPathAvatar `json:"-"`
|
|
MarchType MultiPathAvatar `json:"-"`
|
|
EnableSWGlobal *bool `json:"-"`
|
|
EnableCastoriceGlobal *bool `json:"-"`
|
|
DressedSkins map[uint32]uint32 `json:"-"`
|
|
}
|
|
|
|
// LoadFreesrData reads freesr-data.json + persistent and merges them,
|
|
// mirroring FreesrData::load() from the Rust crate.
|
|
func LoadFreesrData() (*FreesrData, error) {
|
|
raw, err := os.ReadFile("freesr-data.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read freesr-data.json: %w", err)
|
|
}
|
|
|
|
var data FreesrData
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, fmt.Errorf("freesr-data.json is broken: %w, pls redownload", err)
|
|
}
|
|
|
|
if data.Avatars == nil {
|
|
data.Avatars = map[uint32]*AvatarJson{}
|
|
}
|
|
if data.BattleConfig.Monsters == nil && data.BattleConfig.StageID == 0 {
|
|
data.BattleConfig = DefaultBattleConfig()
|
|
}
|
|
|
|
// persistent is optional
|
|
pers, err := os.ReadFile("persistent")
|
|
if err == nil {
|
|
var p Persistent
|
|
if err := json.Unmarshal(pers, &p); err == nil {
|
|
data.Lineups = p.Lineups
|
|
data.Position = p.Position
|
|
data.Scene = p.Scene
|
|
data.MainCharacter = p.MainCharacter
|
|
data.MarchType = p.MarchType
|
|
data.EnableSWGlobal = p.EnableSWGlobal
|
|
data.EnableCastoriceGlobal = p.EnableCastoriceGlobal
|
|
data.DressedSkins = p.DressedSkins
|
|
for aid, unlocked := range p.ExRanksUnlocked {
|
|
if av, ok := data.Avatars[aid]; ok {
|
|
av.ExRankUnlocked = unlocked
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if data.DressedSkins == nil {
|
|
data.DressedSkins = map[uint32]uint32{}
|
|
}
|
|
|
|
// remove unequipped lightcones / relics (keeps huge FreeSR dumps usable)
|
|
if len(data.Lightcones) > 1500 {
|
|
kept := data.Lightcones[:0]
|
|
for _, v := range data.Lightcones {
|
|
if v.EquipAvatar != 0 {
|
|
kept = append(kept, v)
|
|
}
|
|
}
|
|
data.Lightcones = kept
|
|
}
|
|
if len(data.Relics) > 3000 {
|
|
kept := data.Relics[:0]
|
|
for _, v := range data.Relics {
|
|
if v.EquipAvatar != 0 {
|
|
kept = append(kept, v)
|
|
}
|
|
}
|
|
data.Relics = kept
|
|
}
|
|
|
|
data.VerifyLineup()
|
|
|
|
if data.MarchType.ID() > 8000 {
|
|
data.MarchType = MultiPathMarchHunt
|
|
}
|
|
|
|
return &data, nil
|
|
}
|
|
|
|
// SortedLineupKeys returns lineup slots in ascending order (BTreeMap order).
|
|
func (f *FreesrData) SortedLineupKeys() []uint32 {
|
|
keys := make([]uint32, 0, len(f.Lineups))
|
|
for k := range f.Lineups {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
|
|
return keys
|
|
}
|
|
|
|
// VerifyLineup pads the lineup to 4 slots, defaulting to {0:8001}, then saves.
|
|
func (f *FreesrData) VerifyLineup() {
|
|
if len(f.Lineups) == 0 {
|
|
f.Lineups = map[uint32]uint32{0: 8001, 1: 0, 2: 0, 3: 0}
|
|
} else if len(f.Lineups) < 4 {
|
|
for i := uint32(len(f.Lineups)); i < 4; i++ {
|
|
if _, ok := f.Lineups[i]; !ok {
|
|
f.Lineups[i] = 0
|
|
}
|
|
}
|
|
}
|
|
f.SavePersistent()
|
|
}
|
|
|
|
// SavePersistent writes the persistent subset back to the "persistent" file.
|
|
func (f *FreesrData) SavePersistent() {
|
|
exUnlocked := make(map[uint32]bool)
|
|
for aid, av := range f.Avatars {
|
|
if av.ExRankUnlocked {
|
|
exUnlocked[aid] = true
|
|
}
|
|
}
|
|
p := Persistent{
|
|
Lineups: f.Lineups,
|
|
Position: f.Position,
|
|
Scene: f.Scene,
|
|
MainCharacter: f.MainCharacter,
|
|
MarchType: f.MarchType,
|
|
EnableSWGlobal: f.EnableSWGlobal,
|
|
EnableCastoriceGlobal: f.EnableCastoriceGlobal,
|
|
DressedSkins: f.DressedSkins,
|
|
ExRanksUnlocked: exUnlocked,
|
|
}
|
|
out, err := json.MarshalIndent(p, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := os.WriteFile("persistent", out, 0o644); err == nil {
|
|
LogInfo("persistent saved")
|
|
}
|
|
}
|
|
|
|
// FindLightconeFor returns the lightcone equipped by the given avatar (first match).
|
|
func (f *FreesrData) FindLightconeFor(avatarID uint32) *Lightcone {
|
|
for i := range f.Lightcones {
|
|
if f.Lightcones[i].EquipAvatar == avatarID {
|
|
return &f.Lightcones[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindRelicsFor returns the relics equipped by the given avatar.
|
|
func (f *FreesrData) FindRelicsFor(avatarID uint32) []*Relic {
|
|
var out []*Relic
|
|
for i := range f.Relics {
|
|
if f.Relics[i].EquipAvatar == avatarID {
|
|
out = append(out, &f.Relics[i])
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// BaseAvatarIDs contains all playable base avatar IDs
|
|
var BaseAvatarIDs = []uint32{
|
|
8001, 1001,
|
|
1002, 1003, 1004, 1005, 1006, 1008, 1009, 1013, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108,
|
|
1109, 1110, 1111, 1112, 1201, 1202, 1203, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212,
|
|
1213, 1214, 1215, 1217, 1301, 1302, 1303, 1304, 1305, 1306, 1307, 1308, 1309, 1312, 1315, 1310,
|
|
1314, 1218, 1221, 1220, 1222, 1223, 1317, 1313, 1225, 1402, 1401, 1404, 1403, 1405, 1407, 1406,
|
|
1409, 1014, 1015, 1408, 1410, 1412, 1413, 1414, 1415, 1321, 1501, 1502, 1503, 1504, 1505, 1506,
|
|
1507, 1508, 1509, 1510, 1511, 1512, 1513,
|
|
}
|