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

245 lines
8.1 KiB
Go

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...)}
}