Files
GaCy-GO/internal/common/currency_wars_catalog.go
T
2026-10-28 18:54:43 +07:00

507 lines
20 KiB
Go

package common
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"sync"
)
// CurrencyWarsCatalog contains captured Client data, not an invented CW preset.
// Loading the catalog does not start a run, unlock progression, or register a
// protocol handler. Those operations need the independently mapped CW schema.
type CurrencyWarsCatalog struct {
ClientVersion string `json:"client_version"`
ActiveSeasonID uint32 `json:"active_season_id"`
ActiveSubSeasonID uint32 `json:"active_sub_season_id"`
CapturedSeasons []uint32 `json:"captured_seasons"`
SeasonModules []CurrencyWarsSeasonModule `json:"season_modules"`
Divisions []CurrencyWarsDivision `json:"divisions"`
Constants map[string]map[string]CurrencyWarsConstant `json:"constants"`
Entrance CurrencyWarsEntrance `json:"entrance"`
StageRoutes []CurrencyWarsRouteNode `json:"stage_routes"`
Tutorials []CurrencyWarsTutorial `json:"tutorials"`
CapturedRoles []CurrencyWarsCapturedRole `json:"active_handbook_default_roles"`
CapturedStages []CurrencyWarsCapturedStage `json:"active_stage_wrappers"`
CapturedVariants []CurrencyWarsRoleVariant `json:"captured_role_variants"`
PlayerLevels []CurrencyWarsPlayerLevel `json:"captured_player_levels"`
Chapters []CurrencyWarsChapter `json:"chapters"`
NodeTypes []CurrencyWarsNodeType `json:"node_types"`
MonsterCamps []CurrencyWarsMonsterCamp `json:"captured_monster_camps"`
Provenance struct {
GameAssemblySHA256 string `json:"game_assembly_sha256"`
ConstantsSHA256 string `json:"constants_capture_sha256"`
EntranceSHA256 string `json:"entrance_capture_sha256"`
RoomSHA256 string `json:"room_capture_sha256"`
ComparisonSHA256 string `json:"comparison_capture_sha256"`
RoutesSHA256 string `json:"routes_capture_sha256"`
TutorialsSHA256 string `json:"tutorials_capture_sha256"`
GameplaySHA256 string `json:"gameplay_capture_sha256"`
VariantsSHA256 string `json:"variants_capture_sha256"`
StarRowsSHA256 string `json:"star_rows_capture_sha256"`
StarRowsComplete bool `json:"star_rows_capture_complete"`
PlayerLevelsSHA256 string `json:"player_levels_capture_sha256"`
ChaptersSHA256 string `json:"chapters_capture_sha256"`
NodeTypesSHA256 string `json:"node_types_capture_sha256"`
MonsterCampsSHA256 string `json:"monster_camps_capture_sha256"`
} `json:"provenance"`
divisionByID map[uint32]CurrencyWarsDivision
activitySchedules []CurrencyWarsActivitySchedule
routeByID map[uint32][]CurrencyWarsRouteNode
roleByID map[uint32]CurrencyWarsCapturedRole
stageByID map[uint32]CurrencyWarsCapturedStage
variantByKey map[[2]uint32]CurrencyWarsRoleVariant
playerLevelByID map[uint32]CurrencyWarsPlayerLevel
chapterByID map[uint32]CurrencyWarsChapter
nodeTypeByID map[uint32]string
campByID map[[2]uint32]CurrencyWarsMonsterCamp
roleReplacements map[uint32]uint32
}
type CurrencyWarsTutorial struct {
ID uint32 `json:"ID"`
DivisionID uint32 `json:"DivisionID"`
RewardQuest uint32 `json:"RewardQuest"`
ForbiddenBattleFailChapterID uint32 `json:"ForbiddenBattleFailChapterID"`
ForbiddenSellRoleBeforeChapterID uint32 `json:"ForbiddenSellRoleBeforeChapterId"`
ForbiddenSellRoleBeforeSectionID uint32 `json:"ForbiddenSellRoleBeforeSectionId"`
HasMonsterReveal bool `json:"HasMonsterReveal"`
HasPortal bool `json:"HasPortal"`
HasInitialSupply bool `json:"HasInitialSupply"`
HasRouteShow bool `json:"HasRouteShow"`
HasEquipRecommendShow bool `json:"HasEquipRecommendShow"`
HasBossToastShow bool `json:"HasBossToastShow"`
UseTrialAvatar bool `json:"UseTrialAvatar"`
ForbiddenSellRoleDict map[uint32]uint32 `json:"ForbiddenSellRoleDict"`
// Preserve native keys until their chapter/section wire mapping is proven.
ForbiddenAutoOpenShopNodeList []json.RawMessage `json:"ForbiddenAutoOpenShopNodeList"`
}
// Route IDs and chapter/section coordinates come from the native route table.
// NodeTemplateID identifies configuration, not a battle StageID.
type CurrencyWarsRouteNode struct {
RouteID uint32 `json:"ID"`
ChapterID uint32 `json:"ChapterID"`
SectionID uint32 `json:"SectionID"`
NodeTemplateID uint32 `json:"NodeTemplateID"`
}
func (c *CurrencyWarsCatalog) validateRoutes() error {
digest, err := hex.DecodeString(c.Provenance.RoutesSHA256)
if err != nil || len(digest) != sha256.Size || len(c.StageRoutes) == 0 {
return fmt.Errorf("CW route capture or provenance is missing")
}
c.routeByID = make(map[uint32][]CurrencyWarsRouteNode)
seen := make(map[[3]uint32]bool)
for _, row := range c.StageRoutes {
key := [3]uint32{row.RouteID, row.ChapterID, row.SectionID}
if row.RouteID == 0 || row.ChapterID == 0 || row.SectionID == 0 || row.NodeTemplateID == 0 || seen[key] {
return fmt.Errorf("invalid or duplicate CW route node %v", key)
}
seen[key] = true
c.routeByID[row.RouteID] = append(c.routeByID[row.RouteID], row)
}
for _, nodes := range c.routeByID {
sort.Slice(nodes, func(i, j int) bool {
if nodes[i].ChapterID != nodes[j].ChapterID {
return nodes[i].ChapterID < nodes[j].ChapterID
}
return nodes[i].SectionID < nodes[j].SectionID
})
}
id, err := c.IntegerRule("GridFight_FirstPlay_StageRouteID")
if err != nil || id <= 0 || uint64(id) > uint64(^uint32(0)) || len(c.routeByID[uint32(id)]) == 0 {
return fmt.Errorf("CW tutorial route is absent from native route capture")
}
return c.validateTutorials()
}
func (c *CurrencyWarsCatalog) validateTutorials() error {
digest, err := hex.DecodeString(c.Provenance.TutorialsSHA256)
if err != nil || len(digest) != sha256.Size || len(c.Tutorials) == 0 {
return fmt.Errorf("CW tutorial capture or provenance is missing")
}
seen := make(map[uint32]bool)
for _, row := range c.Tutorials {
if row.ID == 0 || row.DivisionID == 0 || seen[row.ID] || row.ForbiddenSellRoleDict == nil || row.ForbiddenAutoOpenShopNodeList == nil {
return fmt.Errorf("invalid or duplicate CW tutorial %d", row.ID)
}
seen[row.ID] = true
for roleID, rank := range row.ForbiddenSellRoleDict {
if roleID == 0 || rank == 0 {
return fmt.Errorf("invalid CW tutorial sell restriction")
}
}
for _, raw := range row.ForbiddenAutoOpenShopNodeList {
var coordinates map[string]uint32
if err := json.Unmarshal(raw, &coordinates); err != nil || len(coordinates) == 0 {
return fmt.Errorf("invalid CW tutorial shop coordinates")
}
}
}
return nil
}
// Return a copy so run state cannot alter the shared configuration.
func (c *CurrencyWarsCatalog) RouteNodes(routeID uint32) ([]CurrencyWarsRouteNode, error) {
nodes, ok := c.routeByID[routeID]
if !ok {
return nil, fmt.Errorf("CW route %d has not been captured", routeID)
}
return append([]CurrencyWarsRouteNode(nil), nodes...), nil
}
// A mission entrance can share a floor with ordinary checkpoint teleports.
// Its position is the captured MapEntrance start anchor, not the first pillar.
type CurrencyWarsEntrance struct {
EntryID uint32 `json:"entry_id"`
PlaneID uint32 `json:"plane_id"`
FloorID uint32 `json:"floor_id"`
StartGroupID uint32 `json:"start_group_id"`
Anchor AnchorItem `json:"anchor"`
RoomGroups []CurrencyWarsRoomGroup `json:"room_groups"`
SavedValues map[string]int32 `json:"saved_values"`
SavedValueRules map[string]CurrencyWarsSavedValueRule `json:"saved_value_rules"`
}
type CurrencyWarsSavedValueRule struct {
DefaultValue int32 `json:"DefaultValue"`
MinValue int32 `json:"MinValue"`
MaxValue int32 `json:"MaxValue"`
AllowedValues []int32 `json:"AllowedValues"`
}
type CurrencyWarsRoomGroup struct {
GroupID uint32 `json:"group_id"`
Condition struct {
Name string `json:"name"`
Comparison string `json:"comparison"`
Value int32 `json:"value"`
} `json:"condition"`
Props []ScenePropInfo `json:"props"`
}
// ActivityScheduleData.activity_id carries a module ID, while panel_id carries
// its parent ActivityID (PearlSR services/events.zig, recorded revision).
type CurrencyWarsActivitySchedule struct {
ModuleID uint32
ActivityID uint32
}
type CurrencyWarsDivision struct {
ID uint32 `json:"ID"`
SeasonID uint32 `json:"SeasonID"`
DivisionLevel uint32 `json:"DivisionLevel"`
Progress uint32 `json:"Progress"`
IsPromotion bool `json:"IsPromotion"`
}
type CurrencyWarsSeasonModule struct {
OfferingID uint32 `json:"OfferingID"`
ActivityQuestConfigID uint32 `json:"ActivityQuestConfigID"`
SeasonID uint32 `json:"SeasonID"`
SubSeasonID uint32 `json:"SubSeasonID"`
MaxRewardExp uint32 `json:"MaxRewardExp"`
ActivityModuleID uint32 `json:"ActivityModuleID"`
}
// Preserve the native DynamicValue flags and payloads. Do not infer a type from
// a zero payload: for example, a legitimate integer rule can itself be zero.
type CurrencyWarsConstant struct {
IntValue int64 `json:"IntValue"`
DoubleValue float64 `json:"DoubleValue"`
BoolValue bool `json:"BoolValue"`
StringValue *string `json:"StringValue"`
ArrayValue []CurrencyWarsConstant `json:"ArrayValue"`
MapValue map[string]CurrencyWarsConstant `json:"MapValue"`
IsInt bool `json:"IsInt"`
IsFloat bool `json:"IsFloat"`
IsBool bool `json:"IsBool"`
IsString bool `json:"IsString"`
IsNull bool `json:"IsNull"`
IsArray bool `json:"IsArray"`
IsMap bool `json:"IsMap"`
}
var currencyWarsCatalogOnce sync.Once
var currencyWarsCatalog *CurrencyWarsCatalog
var currencyWarsCatalogErr error
func LoadCurrencyWarsCatalog() (*CurrencyWarsCatalog, error) {
currencyWarsCatalogOnce.Do(func() {
raw, err := ReadResourceFile("resources/CurrencyWarsCatalog.json")
if err != nil {
currencyWarsCatalogErr = err
return
}
var catalog CurrencyWarsCatalog
if err := json.Unmarshal(raw, &catalog); err != nil {
currencyWarsCatalogErr = fmt.Errorf("decode CW catalog: %w", err)
return
}
cfg, err := LoadRuntimeConfig()
if err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.validate(cfg.ClientVersion); err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.resolveActivity(); err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.validateRoutes(); err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.validateCapturedGameplay(); err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.validateEntrance(); err != nil {
currencyWarsCatalogErr = err
return
}
if err := catalog.applyRoleCompatibility(); err != nil {
currencyWarsCatalogErr = err
return
}
currencyWarsCatalog = &catalog
})
return currencyWarsCatalog, currencyWarsCatalogErr
}
func (c *CurrencyWarsCatalog) validateEntrance() error {
id, err := c.IntegerRule("GridFight_EntranceID")
e := c.Entrance
if err != nil || id <= 0 || int64(e.EntryID) != id || e.PlaneID == 0 || e.FloorID == 0 || e.StartGroupID == 0 || e.Anchor.ID == 0 {
return fmt.Errorf("CW entrance capture is missing or differs from native constant")
}
if digest, err := hex.DecodeString(c.Provenance.EntranceSHA256); err != nil || len(digest) != sha256.Size {
return fmt.Errorf("CW entrance capture SHA-256 is invalid")
}
raw, err := ReadResourceFile("resources/MapEntrance.json")
if err != nil {
return err
}
var table struct {
Entries []MapEntranceEntry `json:"map_entrance_config"`
}
if err := json.Unmarshal(raw, &table); err != nil {
return fmt.Errorf("decode CW MapEntrance: %w", err)
}
matches := 0
for _, row := range table.Entries {
if row.ID != e.EntryID {
continue
}
matches++
if row.PlaneID != e.PlaneID || row.FloorID != e.FloorID || row.StartGroupID != e.StartGroupID || row.StartAnchorID != e.Anchor.ID {
return fmt.Errorf("CW entrance capture differs from MapEntrance")
}
}
if matches != 1 {
return fmt.Errorf("CW entrance must have exactly one MapEntrance row")
}
for _, digestText := range []string{c.Provenance.RoomSHA256, c.Provenance.ComparisonSHA256} {
if digest, err := hex.DecodeString(digestText); err != nil || len(digest) != sha256.Size {
return fmt.Errorf("CW room/comparison capture SHA-256 is invalid")
}
}
if len(e.RoomGroups) == 0 {
return fmt.Errorf("CW room props have not been captured")
}
seenGroups := map[uint32]bool{}
neededValues := map[string]bool{}
for _, group := range e.RoomGroups {
if group.GroupID == 0 || seenGroups[group.GroupID] || group.Condition.Name == "" || group.Condition.Comparison != "GreaterEqual" || len(group.Props) == 0 {
return fmt.Errorf("invalid CW room group/condition")
}
seenGroups[group.GroupID] = true
neededValues[group.Condition.Name] = true
value, exists := e.SavedValues[group.Condition.Name]
rule, sourced := e.SavedValueRules[group.Condition.Name]
if !exists || !sourced || value < group.Condition.Value || value < rule.MinValue || value > rule.MaxValue || value != rule.MaxValue {
return fmt.Errorf("CW room saved-value selection differs from captured freeplay policy")
}
if len(rule.AllowedValues) > 0 {
allowed := false
for _, v := range rule.AllowedValues {
if v == value {
allowed = true
}
}
if !allowed {
return fmt.Errorf("CW room selected saved value is not allowed")
}
}
seenProps := map[uint32]bool{}
for _, prop := range group.Props {
if prop.GroupID != group.GroupID || prop.InstID == 0 || prop.PropID == 0 || seenProps[prop.InstID] {
return fmt.Errorf("invalid or duplicate CW room prop")
}
seenProps[prop.InstID] = true
}
}
if len(neededValues) != len(e.SavedValues) || len(neededValues) != len(e.SavedValueRules) {
return fmt.Errorf("CW saved-value profile contains unrelated fields")
}
return nil
}
func (c *CurrencyWarsCatalog) resolveActivity() error {
raw, err := ReadResourceFile("resources/ActivityConfig.json")
if err != nil {
return err
}
var data struct {
Rows []struct {
ActivityID uint32 `json:"ActivityID"`
Modules []uint32 `json:"ActivityModuleIDList"`
} `json:"activity_config"`
}
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("decode CW activity mapping: %w", err)
}
wholeSeason, err := c.IntegerRule("GridFight_WholeSeason_ActivityModuleID")
if err != nil || wholeSeason <= 0 || uint64(wholeSeason) > uint64(^uint32(0)) {
return fmt.Errorf("invalid CW whole-season activity module constant")
}
activeModule := c.ActiveSeasonModule().ActivityModuleID
seen := map[uint32]bool{}
for _, required := range []uint32{uint32(wholeSeason), activeModule} {
if seen[required] {
continue
}
seen[required] = true
var parent uint32
for _, row := range data.Rows {
for _, id := range row.Modules {
if id != required {
continue
}
if row.ActivityID == 0 || parent != 0 {
return fmt.Errorf("invalid or ambiguous CW activity mapping for module %d", required)
}
parent = row.ActivityID
}
}
if parent == 0 {
return fmt.Errorf("CW module %d is absent from ActivityConfig", required)
}
c.activitySchedules = append(c.activitySchedules, CurrencyWarsActivitySchedule{ModuleID: required, ActivityID: parent})
}
return nil
}
func (c *CurrencyWarsCatalog) ActivitySchedules() []CurrencyWarsActivitySchedule {
return append([]CurrencyWarsActivitySchedule(nil), c.activitySchedules...)
}
func (c *CurrencyWarsCatalog) validate(clientVersion string) error {
if c.ClientVersion == "" || c.ClientVersion != clientVersion {
return fmt.Errorf("CW catalog does not match configured Client revision")
}
for _, digest := range []string{c.Provenance.GameAssemblySHA256, c.Provenance.ConstantsSHA256} {
decoded, err := hex.DecodeString(digest)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("CW catalog is missing valid capture provenance")
}
}
seasons := make(map[uint32]bool, len(c.CapturedSeasons))
for _, id := range c.CapturedSeasons {
if id == 0 || seasons[id] {
return fmt.Errorf("invalid or duplicate CW captured season %d", id)
}
seasons[id] = true
}
if !seasons[c.ActiveSeasonID] || c.ActiveSubSeasonID == 0 {
return fmt.Errorf("CW active season has not been captured")
}
c.divisionByID = make(map[uint32]CurrencyWarsDivision, len(c.Divisions))
for _, division := range c.Divisions {
if division.ID == 0 || !seasons[division.SeasonID] || division.Progress == 0 {
return fmt.Errorf("invalid CW division %d", division.ID)
}
if _, exists := c.divisionByID[division.ID]; exists {
return fmt.Errorf("duplicate CW division %d", division.ID)
}
c.divisionByID[division.ID] = division
}
season, err := c.IntegerRule("GridFight_Season_ID")
if err != nil || season != int64(c.ActiveSeasonID) {
return fmt.Errorf("CW season selection differs from captured native constant")
}
subSeason, err := c.IntegerRule("GridFight_Sub_Season_ID")
if err != nil || subSeason != int64(c.ActiveSubSeasonID) {
return fmt.Errorf("CW subseason selection differs from captured native constant")
}
if len(c.SeasonDivisions(c.ActiveSeasonID)) == 0 {
return fmt.Errorf("CW active season has no divisions")
}
activeModules := 0
for _, module := range c.SeasonModules {
if !seasons[module.SeasonID] || module.SubSeasonID == 0 || module.OfferingID == 0 ||
module.ActivityModuleID == 0 || module.ActivityQuestConfigID == 0 || module.MaxRewardExp == 0 {
return fmt.Errorf("invalid CW season module %d/%d", module.SeasonID, module.SubSeasonID)
}
if module.SeasonID == c.ActiveSeasonID && module.SubSeasonID == c.ActiveSubSeasonID {
activeModules++
}
}
if activeModules != 1 {
return fmt.Errorf("CW catalog must contain exactly one active season module")
}
return nil
}
func (c *CurrencyWarsCatalog) ActiveSeasonModule() CurrencyWarsSeasonModule {
for _, module := range c.SeasonModules {
if module.SeasonID == c.ActiveSeasonID && module.SubSeasonID == c.ActiveSubSeasonID {
return module
}
}
// Only validated catalogs are returned by LoadCurrencyWarsCatalog.
panic("CW active season module missing from validated catalog")
}
// IntegerRule deliberately returns an error for an absent or mistyped rule;
// callers must never replace missing gameplay data with a hidden constant.
func (c *CurrencyWarsCatalog) IntegerRule(name string) (int64, error) {
value, ok := c.Constants["ConstValueCommonExcelTable"][name]
if !ok || !value.IsInt {
return 0, fmt.Errorf("CW integer rule missing or mistyped: %s", name)
}
return value.IntValue, nil
}
func (c *CurrencyWarsCatalog) Division(id uint32) (CurrencyWarsDivision, bool) {
division, ok := c.divisionByID[id]
return division, ok
}
// Return a copy so a run cannot mutate shared catalog state while sorting.
func (c *CurrencyWarsCatalog) SeasonDivisions(season uint32) []CurrencyWarsDivision {
var divisions []CurrencyWarsDivision
for _, division := range c.Divisions {
if division.SeasonID == season {
divisions = append(divisions, division)
}
}
sort.Slice(divisions, func(i, j int) bool {
a, b := divisions[i], divisions[j]
if a.DivisionLevel != b.DivisionLevel {
return a.DivisionLevel < b.DivisionLevel
}
if a.Progress != b.Progress {
return a.Progress < b.Progress
}
return a.ID < b.ID
})
return divisions
}