package common import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "strconv" ) const currencyWarsSaveFormat = 1 // Version of this repository's save envelope. var ErrCurrencyWarsSaveConflict = errors.New("CW save changed in another session") // WireData is a serialized GridFightGetDataScRsp, retaining unknown protocol // fields. UID and Client identity prevent restoring a different account/schema. type CurrencyWarsSnapshot struct { FormatVersion uint32 `json:"format_version"` UID uint32 `json:"uid"` ClientVersion string `json:"client_version"` AssemblySHA256 string `json:"assembly_sha256"` Revision uint64 `json:"revision"` WireData []byte `json:"wire_data"` Runtime *CurrencyWarsRuntimeState `json:"runtime,omitempty"` } // Server-side rules are kept beside, never smuggled into, the Client protocol. // RunID prevents a rule from a finished run leaking into a later challenge. type CurrencyWarsRuntimeState struct { RunID uint32 `json:"run_id"` Rules map[string]uint32 `json:"rules,omitempty"` } func (s *CurrencyWarsSnapshot) validate() error { if s == nil || s.FormatVersion != currencyWarsSaveFormat || s.UID == 0 || s.ClientVersion == "" || len(s.WireData) == 0 { return fmt.Errorf("invalid CW snapshot envelope") } if digest, err := hex.DecodeString(s.AssemblySHA256); err != nil || len(digest) != sha256.Size { return fmt.Errorf("invalid CW snapshot source hash") } return nil } // ReadCurrencyWarsSnapshot never creates or rewrites persistent. A revision // mismatch is surfaced rather than silently replacing progress with defaults. func ReadCurrencyWarsSnapshot(uid uint32, version, assemblySHA string) (*CurrencyWarsSnapshot, error) { root, err := readCurrencyWarsPersistentRoot() if err != nil { return nil, err } var accounts map[string]json.RawMessage if raw, ok := root["currency_wars"]; ok { if err := json.Unmarshal(raw, &accounts); err != nil || accounts == nil { return nil, fmt.Errorf("invalid CW account store") } } raw, found := accounts[strconv.FormatUint(uint64(uid), 10)] if !found { return nil, nil } var snapshot CurrencyWarsSnapshot if err := json.Unmarshal(raw, &snapshot); err != nil { return nil, fmt.Errorf("decode CW snapshot: %w", err) } if err := snapshot.validate(); err != nil { return nil, err } if snapshot.UID != uid || snapshot.ClientVersion != version || snapshot.AssemblySHA256 != assemblySHA || snapshot.Revision == 0 { return nil, fmt.Errorf("CW snapshot account/Client revision does not match") } return &snapshot, nil } func readCurrencyWarsPersistentRoot() (map[string]json.RawMessage, error) { raw, err := os.ReadFile("persistent") if os.IsNotExist(err) { return make(map[string]json.RawMessage), nil } if err != nil { return nil, err } var root map[string]json.RawMessage if err := json.Unmarshal(raw, &root); err != nil || root == nil { return nil, fmt.Errorf("invalid persistent object; CW save refused") } return root, nil } // CommitCurrencyWarsSnapshot updates only this UID's CW value, sharing the // existing cross-process lock and atomic writer. expectedRevision prevents a // stale game session from overwriting a later run. The caller must validate // the protocol payload before committing; reads do not perform migration. func CommitCurrencyWarsSnapshot(expectedRevision uint64, next CurrencyWarsSnapshot) (uint64, error) { return commitCurrencyWarsSnapshot(expectedRevision, next, "") } // A grant and its queue acknowledgement share one atomic file replacement. // A crash or disconnect cannot replay an already committed additive grant. func CommitCurrencyWarsCommand(expectedRevision uint64, next CurrencyWarsSnapshot, commandID string) (uint64, error) { if commandID == "" { return 0, fmt.Errorf("CW command ID missing") } return commitCurrencyWarsSnapshot(expectedRevision, next, commandID) } func commitCurrencyWarsSnapshot(expectedRevision uint64, next CurrencyWarsSnapshot, commandID string) (uint64, error) { if err := next.validate(); err != nil { return 0, err } persistentWriteMu.Lock() defer persistentWriteMu.Unlock() unlock, err := lockPersistentFile() if err != nil { return 0, err } defer unlock() root, err := readCurrencyWarsPersistentRoot() if err != nil { return 0, err } accounts := make(map[string]json.RawMessage) if raw, exists := root["currency_wars"]; exists { if err := json.Unmarshal(raw, &accounts); err != nil || accounts == nil { return 0, fmt.Errorf("invalid CW account store; save refused") } } key := strconv.FormatUint(uint64(next.UID), 10) var revision uint64 var previousFields map[string]json.RawMessage if raw, exists := accounts[key]; exists { if err := json.Unmarshal(raw, &previousFields); err != nil { return 0, err } var previous CurrencyWarsSnapshot if err := json.Unmarshal(raw, &previous); err != nil { return 0, err } if err := previous.validate(); err != nil { return 0, err } if previous.UID != next.UID || previous.ClientVersion != next.ClientVersion || previous.AssemblySHA256 != next.AssemblySHA256 || previous.Revision == 0 { return 0, fmt.Errorf("CW snapshot source differs; migration is required") } revision = previous.Revision if next.Runtime != nil && previous.Runtime != nil && next.Runtime.RunID == previous.Runtime.RunID && next.Runtime.Rules == nil { next.Runtime.Rules = previous.Runtime.Rules } } if revision != expectedRevision { return 0, ErrCurrencyWarsSaveConflict } if revision == ^uint64(0) { return 0, fmt.Errorf("CW snapshot revision exhausted") } next.Revision = revision + 1 // Retain unrelated envelope fields added by tools or a newer writer. encoded, err := json.Marshal(next) if err != nil { return 0, err } var nextFields map[string]json.RawMessage if err = json.Unmarshal(encoded, &nextFields); err != nil { return 0, err } if next.Runtime != nil && previousFields != nil { var oldRuntime map[string]json.RawMessage var oldRunID uint32 if raw := previousFields["runtime"]; len(raw) != 0 && json.Unmarshal(raw, &oldRuntime) == nil { _ = json.Unmarshal(oldRuntime["run_id"], &oldRunID) if oldRunID == next.Runtime.RunID { var runtimeFields map[string]json.RawMessage if err = json.Unmarshal(nextFields["runtime"], &runtimeFields); err != nil { return 0, err } for name, value := range runtimeFields { oldRuntime[name] = value } if next.Runtime.Rules != nil && len(next.Runtime.Rules) == 0 { delete(oldRuntime, "rules") } nextFields["runtime"], err = json.Marshal(oldRuntime) if err != nil { return 0, err } } } } if previousFields == nil { previousFields = map[string]json.RawMessage{} } for name, value := range nextFields { previousFields[name] = value } if next.Runtime == nil { delete(previousFields, "runtime") } accounts[key], err = json.Marshal(previousFields) if err != nil { return 0, err } root["currency_wars"], err = json.Marshal(accounts) if err != nil { return 0, err } if commandID != "" { var commands []CurrencyWarsCommand if raw, ok := root["currency_wars_commands"]; ok { if err := json.Unmarshal(raw, &commands); err != nil { return 0, err } } found := false remaining := make([]CurrencyWarsCommand, 0, len(commands)) for _, cmd := range commands { if cmd.ID == commandID { if found || cmd.UID != next.UID { return 0, fmt.Errorf("CW command identity differs") } found = true } else { remaining = append(remaining, cmd) } } if !found { return 0, fmt.Errorf("CW command no longer queued") } raw, err := json.Marshal(remaining) if err != nil { return 0, err } root["currency_wars_commands"] = raw } if err := writeJSONAtomic("persistent", root); err != nil { return 0, err } return next.Revision, nil } func NewCurrencyWarsSnapshot(uid uint32, version, assemblySHA string, wire []byte) CurrencyWarsSnapshot { return CurrencyWarsSnapshot{FormatVersion: currencyWarsSaveFormat, UID: uid, ClientVersion: version, AssemblySHA256: assemblySHA, WireData: append([]byte(nil), wire...)} }