507 lines
20 KiB
Go
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
|
|
}
|