Files
bd2/go/internal/player/collection.go
T
2026-09-21 09:53:46 +08:00

795 lines
28 KiB
Go

package player
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/gamedata"
)
type CostumeUpgrade struct {
InvenIndex uint64 `json:"inven_index"`
CostumeID uint64 `json:"costume_id"`
Before uint64 `json:"before"`
After uint64 `json:"after"`
SortID uint64 `json:"sort_id"`
}
type CostumeExchange struct {
InvenIndex uint64 `json:"inven_index,omitempty"`
OriginalItemType uint64 `json:"original_item_type"`
OriginalItemID uint64 `json:"original_item_id"`
OriginalCount uint64 `json:"original_count"`
ExchangeItemType uint64 `json:"exchange_item_type"`
ExchangeItemID uint64 `json:"exchange_item_id,omitempty"`
ExchangeCount uint64 `json:"exchange_count"`
SortID uint64 `json:"sort_id"`
}
type CollectionGrant struct {
CharacterIndices []uint64 `json:"character_indices,omitempty"`
CostumeIndices []uint64 `json:"costume_indices,omitempty"`
Upgrades []CostumeUpgrade `json:"upgrades,omitempty"`
Exchanges []CostumeExchange `json:"exchanges,omitempty"`
ViewCostumeIDs []uint64 `json:"view_costume_ids"`
GachaGroupID uint64 `json:"gacha_group_id,omitempty"`
GachaPoint uint64 `json:"gacha_point,omitempty"`
GachaFixed []GachaFixedState `json:"gacha_fixed,omitempty"`
SelectionApplySortIDs []uint64 `json:"selection_apply_sort_ids,omitempty"`
}
type GachaUserState struct {
GroupID uint64 `json:"group_id"`
Point uint64 `json:"point"`
TotalBuyCount uint64 `json:"total_buy_count,omitempty"`
OneFreePickCount uint64 `json:"one_free_pick_count,omitempty"`
OneCashPickCount uint64 `json:"one_cash_pick_count,omitempty"`
TenFreePickCount uint64 `json:"ten_free_pick_count,omitempty"`
TenCashPickCount uint64 `json:"ten_cash_pick_count,omitempty"`
ExchangeItemCount uint64 `json:"exchange_item_count,omitempty"`
ExchangeMileageCount uint64 `json:"exchange_mileage_count,omitempty"`
}
type GachaFixedState struct {
FixedID uint64 `json:"fixed_id"`
Type uint64 `json:"type"`
Count uint64 `json:"count"`
ApplySort int `json:"apply_sort_id"`
}
type GachaPointExchange struct {
GroupID uint64 `json:"group_id"`
Count uint64 `json:"count"`
}
type GachaPurchase struct {
Group gamedata.GachaGroupDesign
BuyType uint64
Fixed []GachaFixedState
SelectionApplySortIDs []uint64
}
type GachaSelection struct {
GroupID uint64 `json:"group_id"`
Slot uint64 `json:"slot"`
ItemID uint64 `json:"item_id"`
}
type collectionSnapshot struct {
Version string `json:"version"`
NextCharacterIndex uint64 `json:"next_character_index"`
NextCostumeIndex uint64 `json:"next_costume_index"`
LatestPreview []uint64 `json:"latest_preview,omitempty"`
PreviewEventIndex uint64 `json:"preview_event_index,omitempty"`
PreviewLocked bool `json:"preview_locked,omitempty"`
Characters []Character `json:"characters,omitempty"`
Costumes []Costume `json:"costumes,omitempty"`
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels,omitempty"`
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
GachaCountCorrected bool `json:"gacha_count_corrected,omitempty"`
Grants map[string]CollectionGrant `json:"grants"`
}
// CollectionStore owns non-stackable character/costume rewards as one atomic
// save. It is shared by gacha, CharInfo, CostumeInfo and CharGrowth so a roll
// cannot exist only in the result animation and disappear after relogging.
type CollectionStore struct {
mu sync.Mutex
path string
base []Costume
baseCharacters []Character
data collectionSnapshot
}
func OpenCollectionStore(path string, base []Costume) (*CollectionStore, error) {
s := &CollectionStore{path: filepath.Clean(path), base: append([]Costume(nil), base...), data: collectionSnapshot{
Version: "2.34.13", NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{},
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
}}
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, &s.data); err != nil {
return nil, fmt.Errorf("player: decode collection: %w", err)
}
if s.data.Version != "2.34.13" || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 || s.data.Grants == nil {
return nil, errors.New("player: invalid collection save")
}
if s.data.BaseCostumeLevels == nil {
s.data.BaseCostumeLevels = map[string]uint64{}
}
if s.data.GachaSelections == nil {
s.data.GachaSelections = map[string][]GachaSelection{}
}
if s.data.StepUpProgress == nil {
s.data.StepUpProgress = map[string]uint64{}
}
if s.data.GachaUsers == nil {
s.data.GachaUsers = map[string]GachaUserState{}
}
if s.data.GachaFixed == nil {
s.data.GachaFixed = map[string]GachaFixedState{}
}
if s.data.GachaApplied == nil {
s.data.GachaApplied = map[string]bool{}
}
if s.data.GachaPointExchange == nil {
s.data.GachaPointExchange = map[string]GachaPointExchange{}
}
if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 {
return nil, err
}
return s, nil
}
// AttachBaseCharacters supplies the character instances owned by the world
// and starter stores. Collection characters must share those instances: a new
// costume for an already-owned CharTable ID is not a second character. The
// repair also migrates saves written by the old one-character-per-costume
// implementation without deleting any costume, level, or gacha grant.
func (s *CollectionStore) AttachBaseCharacters(base []Character) error {
if err := validateCharacters(base); err != nil && len(base) != 0 {
return fmt.Errorf("player: invalid base characters: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
canonical := make(map[uint64]uint64, len(base)+len(next.Characters))
for _, character := range base {
if _, exists := canonical[character.ID]; !exists {
canonical[character.ID] = character.InvenIndex
}
}
redirect := make(map[uint64]uint64)
kept := make([]Character, 0, len(next.Characters))
for _, character := range next.Characters {
if existing, exists := canonical[character.ID]; exists {
redirect[character.InvenIndex] = existing
continue
}
canonical[character.ID] = character.InvenIndex
kept = append(kept, character)
}
changed := len(kept) != len(next.Characters)
if changed {
next.Characters = kept
for i := range next.Costumes {
if replacement, exists := redirect[next.Costumes[i].UseChar]; exists {
next.Costumes[i].UseChar = replacement
}
}
for identity, grant := range next.Grants {
indices := grant.CharacterIndices[:0]
for _, index := range grant.CharacterIndices {
if _, removed := redirect[index]; !removed {
indices = append(indices, index)
}
}
grant.CharacterIndices = indices
next.Grants[identity] = grant
}
if err := s.commit(next); err != nil {
return err
}
}
s.baseCharacters = append([]Character(nil), base...)
return nil
}
// BindBaseCharacters attaches the authoritative base roster after the
// Haskell startup repair has removed duplicate designs. It never writes state.
func (s *CollectionStore) BindBaseCharacters(base []Character) error {
if err := validateCharacters(base); err != nil && len(base) != 0 {
return fmt.Errorf("player: invalid base characters: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
seen := make(map[uint64]uint64, len(base)+len(s.data.Characters))
for _, character := range base {
if prior := seen[character.ID]; prior != 0 {
return fmt.Errorf("player: duplicate base character design %d", character.ID)
}
seen[character.ID] = character.InvenIndex
}
for _, character := range s.data.Characters {
if prior := seen[character.ID]; prior != 0 {
return fmt.Errorf("player: unrepaired duplicate character design %d (%d and %d)", character.ID, prior, character.InvenIndex)
}
seen[character.ID] = character.InvenIndex
}
s.baseCharacters = append([]Character(nil), base...)
return nil
}
// RepairStepUpProgress derives the completed step count from durable grants
// written before step-up progress had its own save field. Grant identities may
// contain either the legacy request sequence or the newer login-session ID.
func (s *CollectionStore) RepairStepUpProgress(groupID uint64, gachaIDs []uint64) (uint64, error) {
if groupID == 0 || len(gachaIDs) == 0 {
return 0, errors.New("player: invalid step-up design")
}
s.mu.Lock()
defer s.mu.Unlock()
key := strconv.FormatUint(groupID, 10)
completed := s.data.StepUpProgress[key]
if completed > uint64(len(gachaIDs)) {
return 0, fmt.Errorf("player: step-up group %d progress %d exceeds %d", groupID, completed, len(gachaIDs))
}
derived := uint64(0)
for i, gachaID := range gachaIDs {
prefix := fmt.Sprintf("regular-gacha:%d:", gachaID)
found := false
for identity := range s.data.Grants {
if strings.HasPrefix(identity, prefix) {
found = true
break
}
}
if !found {
break
}
derived = uint64(i + 1)
}
if derived <= completed {
return completed, nil
}
next := cloneCollection(s.data)
next.StepUpProgress[key] = derived
if err := s.commit(next); err != nil {
return 0, err
}
return derived, nil
}
func (s *CollectionStore) StepUpProgress(groupID uint64) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.StepUpProgress[strconv.FormatUint(groupID, 10)]
}
// CompleteStepUp atomically advances one group. Repeating the same completed
// step is allowed so a lost HTTP response can be retried without reopening it.
func (s *CollectionStore) CompleteStepUp(groupID, step uint64) error {
if groupID == 0 || step == 0 {
return errors.New("player: invalid step-up completion")
}
s.mu.Lock()
defer s.mu.Unlock()
key := strconv.FormatUint(groupID, 10)
completed := s.data.StepUpProgress[key]
if completed >= step {
return nil
}
if completed+1 != step {
return fmt.Errorf("player: step-up group %d expects step %d, got %d", groupID, completed+1, step)
}
next := cloneCollection(s.data)
next.StepUpProgress[key] = step
return s.commit(next)
}
func (s *CollectionStore) SaveGachaSelections(groupID uint64, selections []GachaSelection) error {
if groupID == 0 || len(selections) == 0 {
return errors.New("player: invalid gacha selection")
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
next.GachaSelections[strconv.FormatUint(groupID, 10)] = append([]GachaSelection(nil), selections...)
return s.commit(next)
}
func (s *CollectionStore) GachaSelections(groupID uint64) []GachaSelection {
s.mu.Lock()
defer s.mu.Unlock()
return append([]GachaSelection(nil), s.data.GachaSelections[strconv.FormatUint(groupID, 10)]...)
}
func (s *CollectionStore) SetPreview(costumeIDs []uint64) error {
if len(costumeIDs) != 10 {
return errors.New("player: infinite preview must contain ten costumes")
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
next.LatestPreview = append([]uint64(nil), costumeIDs...)
next.PreviewEventIndex = 0
next.PreviewLocked = false
return s.commit(next)
}
// LockPreview records the official Resemara confirmation boundary. The
// client sends the schedule's EventIndex after the player accepts the latest
// preview; generating another preview clears this lock again.
func (s *CollectionStore) LockPreview(eventIndex uint64) error {
if eventIndex == 0 {
return errors.New("player: invalid infinite preview event index")
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.data.LatestPreview) != 10 {
return errors.New("player: no complete infinite gacha preview to lock")
}
next := cloneCollection(s.data)
next.PreviewEventIndex = eventIndex
next.PreviewLocked = true
return s.commit(next)
}
func (s *CollectionStore) PreviewLock() (uint64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.PreviewEventIndex, s.data.PreviewLocked
}
func (s *CollectionStore) LatestPreview() []uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return append([]uint64(nil), s.data.LatestPreview...)
}
func (s *CollectionStore) ConfirmInfinite(identity string, design *gamedata.InfiniteGachaDesign) (CollectionGrant, error) {
if identity == "" || design == nil {
return CollectionGrant{}, errors.New("player: invalid collection grant")
}
s.mu.Lock()
preview := append([]uint64(nil), s.data.LatestPreview...)
locked := s.data.PreviewLocked && s.data.PreviewEventIndex != 0
s.mu.Unlock()
if len(preview) != design.Count {
return CollectionGrant{}, errors.New("player: no complete infinite gacha preview to confirm")
}
if !locked {
return CollectionGrant{}, errors.New("player: infinite gacha preview is not locked")
}
return s.grantCostumes(identity, preview, design.Character, nil)
}
func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) {
if identity == "" || len(costumeIDs) == 0 || design == nil {
return CollectionGrant{}, errors.New("player: invalid regular gacha grant")
}
return s.grantCostumes(identity, costumeIDs, design.Character, nil)
}
func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) {
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
}
return s.grantCostumes(identity, costumeIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error {
applyGachaPurchase(next, identity, costumeIDs, purchase, grant)
return nil
})
}
// GrantEquipmentPurchase records only the durable gacha accounting. Equipment
// instances belong to EquipmentInventory (they have a different wire type),
// while points and fixed counters share GachaUserDBInfo/GachaFixedDBInfo with
// costume draws. The stored empty grant is the idempotency marker.
func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, purchase GachaPurchase) (CollectionGrant, error) {
if identity == "" || count == 0 || purchase.Group.ID == 0 {
return CollectionGrant{}, errors.New("player: invalid equipment gacha purchase")
}
s.mu.Lock()
defer s.mu.Unlock()
if grant, ok := s.data.Grants[identity]; ok {
return cloneGrant(grant), nil
}
next := cloneCollection(s.data)
grant := CollectionGrant{}
applyGachaPurchase(&next, identity, make([]uint64, count), purchase, &grant)
next.Grants[identity] = cloneGrant(grant)
if err := s.commit(next); err != nil {
return CollectionGrant{}, err
}
return grant, nil
}
func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) {
s.mu.Lock()
defer s.mu.Unlock()
if grant, ok := s.data.Grants[identity]; ok {
return cloneGrant(grant), nil
}
next := cloneCollection(s.data)
grant := CollectionGrant{ViewCostumeIDs: append([]uint64(nil), costumeIDs...)}
now := uint64(time.Now().UnixMilli())
for sortIndex, costumeID := range costumeIDs {
if costumeID == 0 {
return CollectionGrant{}, errors.New("player: preview has invalid costume")
}
characterDesign, found := character(costumeID)
if !found {
return CollectionGrant{}, fmt.Errorf("player: costume %d has no character design", costumeID)
}
maxLevel := characterDesign.CostumeMaxLevel
if maxLevel == 0 { // compatibility for small constructor-only unit fixtures
maxLevel = 5
}
if position, found := findCostume(next.Costumes, costumeID); found {
before := next.Costumes[position].Level
if before < maxLevel {
next.Costumes[position].Level++
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: next.Costumes[position].InvenIndex, CostumeID: costumeID, Before: before, After: next.Costumes[position].Level, SortID: uint64(sortIndex)})
} else {
exchange, err := costumeOverflowExchange(costumeID, next.Costumes[position].InvenIndex, uint64(sortIndex), characterDesign)
if err != nil {
return CollectionGrant{}, err
}
grant.Exchanges = append(grant.Exchanges, exchange)
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID})
}
continue
}
if base, found := findCostume(s.base, costumeID); found {
key := strconv.FormatUint(s.base[base].InvenIndex, 10)
before := next.BaseCostumeLevels[key]
if before < s.base[base].Level {
before = s.base[base].Level
}
if before < maxLevel {
next.BaseCostumeLevels[key] = before + 1
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: s.base[base].InvenIndex, CostumeID: costumeID, Before: before, After: before + 1, SortID: uint64(sortIndex)})
} else {
exchange, err := costumeOverflowExchange(costumeID, s.base[base].InvenIndex, uint64(sortIndex), characterDesign)
if err != nil {
return CollectionGrant{}, err
}
grant.Exchanges = append(grant.Exchanges, exchange)
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: costumeID, Before: maxLevel, After: maxLevel, SortID: exchange.SortID})
}
continue
}
characterIndex, characterExists := findCharacterByID(s.baseCharacters, next.Characters, characterDesign.ID)
costumeIndex := next.NextCostumeIndex
next.NextCostumeIndex++
if !characterExists {
characterIndex = next.NextCharacterIndex
next.NextCharacterIndex++
ownedCharacter := Character{InvenIndex: characterIndex, ID: characterDesign.ID, HP: characterDesign.HP, Level: 1,
CostumeID: costumeID, UseCostume: costumeIndex, TalentLevel: 1, SolidarityReward: 1,
ExpiryTime: ^uint64(0) - 32399999, ConnectPotentialCostume: costumeID}
next.Characters = append(next.Characters, ownedCharacter)
grant.CharacterIndices = append(grant.CharacterIndices, characterIndex)
}
costume := Costume{InvenIndex: costumeIndex, ID: costumeID, UseChar: characterIndex, SortID: uint64(sortIndex), TimeValue: now}
next.Costumes = append(next.Costumes, costume)
grant.CostumeIndices = append(grant.CostumeIndices, costumeIndex)
}
if mutate != nil {
if err := mutate(&next, &grant); err != nil {
return CollectionGrant{}, err
}
}
next.Grants[identity] = cloneGrant(grant)
if err := s.commit(next); err != nil {
return CollectionGrant{}, err
}
return grant, nil
}
func costumeOverflowExchange(costumeID, invenIndex, sortID uint64, design gamedata.CharacterDesign) (CostumeExchange, error) {
if design.OverflowItemType == 0 || design.OverflowItemCount == 0 {
return CostumeExchange{}, fmt.Errorf("player: costume %d has no overflow exchange design", costumeID)
}
return CostumeExchange{
InvenIndex: invenIndex,
OriginalItemType: 11, OriginalItemID: costumeID, OriginalCount: 1,
ExchangeItemType: design.OverflowItemType, ExchangeItemID: design.OverflowItemID,
ExchangeCount: design.OverflowItemCount, SortID: sortID,
}, nil
}
// RepairCostumeOverflow migrates grants written by the pre-cap implementation.
// It rewrites every illegal +6/+7 upgrade into the same GameData-driven
// post-max exchange a fresh request would have produced, then caps persisted
// CostumeDBInfo levels. Returning all exchange-bearing grants lets the wallet
// idempotently recover currency even after a crash between the two commits.
func (s *CollectionStore) RepairCostumeOverflow(character func(uint64) (gamedata.CharacterDesign, bool)) (map[string][]CostumeExchange, error) {
if character == nil {
return nil, errors.New("player: nil costume design resolver")
}
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
changed := false
for i := range next.Costumes {
design, ok := character(next.Costumes[i].ID)
if !ok || design.CostumeMaxLevel == 0 {
return nil, fmt.Errorf("player: costume %d has no max-level design", next.Costumes[i].ID)
}
if next.Costumes[i].Level > design.CostumeMaxLevel {
next.Costumes[i].Level = design.CostumeMaxLevel
changed = true
}
}
for i := range s.base {
key := strconv.FormatUint(s.base[i].InvenIndex, 10)
level := next.BaseCostumeLevels[key]
if level == 0 {
level = s.base[i].Level
}
design, ok := character(s.base[i].ID)
if !ok {
continue // starter costumes outside the active gacha pools are untouched
}
if design.CostumeMaxLevel == 0 {
return nil, fmt.Errorf("player: costume %d has no max-level design", s.base[i].ID)
}
if level > design.CostumeMaxLevel {
next.BaseCostumeLevels[key] = design.CostumeMaxLevel
changed = true
}
}
for identity, grant := range next.Grants {
kept := make([]CostumeUpgrade, 0, len(grant.Upgrades))
for _, upgrade := range grant.Upgrades {
design, ok := character(upgrade.CostumeID)
if !ok || design.CostumeMaxLevel == 0 {
return nil, fmt.Errorf("player: costume %d has no overflow repair design", upgrade.CostumeID)
}
if upgrade.After > design.CostumeMaxLevel {
exchange, err := costumeOverflowExchange(upgrade.CostumeID, upgrade.InvenIndex, upgrade.SortID, design)
if err != nil {
return nil, err
}
grant.Exchanges = append(grant.Exchanges, exchange)
changed = true
continue
}
kept = append(kept, upgrade)
}
grant.Upgrades = kept
for i := range grant.Exchanges {
exchange := &grant.Exchanges[i]
if exchange.InvenIndex == 0 {
if position, found := findCostume(next.Costumes, exchange.OriginalItemID); found {
exchange.InvenIndex = next.Costumes[position].InvenIndex
} else if position, found := findCostume(s.base, exchange.OriginalItemID); found {
exchange.InvenIndex = s.base[position].InvenIndex
} else {
return nil, fmt.Errorf("player: overflow costume %d has no inventory instance", exchange.OriginalItemID)
}
changed = true
}
found := false
for _, upgrade := range grant.Upgrades {
if upgrade.CostumeID == exchange.OriginalItemID && upgrade.SortID == exchange.SortID {
found = true
break
}
}
if !found {
design, _ := character(exchange.OriginalItemID)
grant.Upgrades = append(grant.Upgrades, CostumeUpgrade{InvenIndex: exchange.InvenIndex, CostumeID: exchange.OriginalItemID, Before: design.CostumeMaxLevel, After: design.CostumeMaxLevel, SortID: exchange.SortID})
changed = true
}
}
next.Grants[identity] = grant
}
if changed {
if err := s.commit(next); err != nil {
return nil, err
}
}
result := make(map[string][]CostumeExchange)
for identity, grant := range next.Grants {
if len(grant.Exchanges) != 0 {
result[identity] = append([]CostumeExchange(nil), grant.Exchanges...)
}
}
return result, nil
}
func (s *CollectionStore) Characters() []Character {
s.mu.Lock()
defer s.mu.Unlock()
return append([]Character(nil), s.data.Characters...)
}
func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, c := range s.data.Characters {
if c.InvenIndex == index {
return c, true
}
}
return Character{}, false
}
func (s *CollectionStore) UpdateCharacter(character Character) error {
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
for i := range next.Characters {
if next.Characters[i].InvenIndex == character.InvenIndex && next.Characters[i].ID == character.ID {
next.Characters[i] = character
return s.commit(next)
}
}
return fmt.Errorf("player: collection character %d not found", character.InvenIndex)
}
func (s *CollectionStore) Costumes() []Costume {
s.mu.Lock()
defer s.mu.Unlock()
result := append([]Costume(nil), s.base...)
for i := range result {
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
result[i].Level = level
}
}
return append(result, s.data.Costumes...)
}
func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) {
s.mu.Lock()
defer s.mu.Unlock()
grant, ok := s.data.Grants[identity]
return cloneGrant(grant), ok
}
func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
for _, character := range s.Characters() {
if character.InvenIndex == index {
return character, true
}
}
return Character{}, false
}
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
for _, costume := range s.Costumes() {
if costume.InvenIndex == index {
return costume, true
}
}
return Costume{}, false
}
func findCostume(costumes []Costume, id uint64) (int, bool) {
for i, costume := range costumes {
if costume.ID == id {
return i, true
}
}
return -1, false
}
func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) {
for _, character := range base {
if character.ID == id {
return character.InvenIndex, true
}
}
for _, character := range collection {
if character.ID == id {
return character.InvenIndex, true
}
}
return 0, false
}
func cloneGrant(grant CollectionGrant) CollectionGrant {
grant.CharacterIndices = append([]uint64(nil), grant.CharacterIndices...)
grant.CostumeIndices = append([]uint64(nil), grant.CostumeIndices...)
grant.Upgrades = append([]CostumeUpgrade(nil), grant.Upgrades...)
grant.Exchanges = append([]CostumeExchange(nil), grant.Exchanges...)
grant.ViewCostumeIDs = append([]uint64(nil), grant.ViewCostumeIDs...)
grant.GachaFixed = append([]GachaFixedState(nil), grant.GachaFixed...)
grant.SelectionApplySortIDs = append([]uint64(nil), grant.SelectionApplySortIDs...)
return grant
}
func cloneCollection(in collectionSnapshot) collectionSnapshot {
out := in
out.LatestPreview = append([]uint64(nil), in.LatestPreview...)
out.Characters = append([]Character(nil), in.Characters...)
out.Costumes = append([]Costume(nil), in.Costumes...)
out.BaseCostumeLevels = make(map[string]uint64, len(in.BaseCostumeLevels))
for k, v := range in.BaseCostumeLevels {
out.BaseCostumeLevels[k] = v
}
out.GachaSelections = make(map[string][]GachaSelection, len(in.GachaSelections))
for k, v := range in.GachaSelections {
out.GachaSelections[k] = append([]GachaSelection(nil), v...)
}
out.StepUpProgress = make(map[string]uint64, len(in.StepUpProgress))
for k, v := range in.StepUpProgress {
out.StepUpProgress[k] = v
}
out.GachaUsers = make(map[string]GachaUserState, len(in.GachaUsers))
for k, v := range in.GachaUsers {
out.GachaUsers[k] = v
}
out.GachaFixed = make(map[string]GachaFixedState, len(in.GachaFixed))
for k, v := range in.GachaFixed {
out.GachaFixed[k] = v
}
out.GachaApplied = make(map[string]bool, len(in.GachaApplied))
for k, v := range in.GachaApplied {
out.GachaApplied[k] = v
}
out.GachaPointExchange = make(map[string]GachaPointExchange, len(in.GachaPointExchange))
for k, v := range in.GachaPointExchange {
out.GachaPointExchange[k] = v
}
out.Grants = make(map[string]CollectionGrant, len(in.Grants))
for k, v := range in.Grants {
out.Grants[k] = cloneGrant(v)
}
return out
}
func (s *CollectionStore) commit(next collectionSnapshot) error {
b, err := json.Marshal(next)
if err != nil {
return err
}
dir := filepath.Dir(s.path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
f, err := os.CreateTemp(dir, ".collection-*.tmp")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err = f.Write(b); err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(f.Name(), s.path)
}
if err != nil {
return fmt.Errorf("player: persist collection: %w", err)
}
s.data = next
return nil
}