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 }