#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Text; using Cysharp.Threading.Tasks; using Demo2.Domain; using Demo2.ECS; using Newtonsoft.Json; using ShrinkDataSaver; using ShrinkEventBus; using Unity.Collections; using Unity.Entities; namespace Demo2.Runtime { public sealed class Demo2GameService : IDisposable { private const string SaveModuleKey = "demo2.rule-factory"; private const int SaveSlot = 2; private readonly HashSet _idempotencyTokens = new(StringComparer.Ordinal); private readonly HashSet _readyPlayers = new(StringComparer.Ordinal); private readonly List _ruleIds = new(); private readonly List _rewardChoices = new(); private Demo2ContentRegistry _content; private Demo2ContentRegistry? _pendingContent; private Demo2EcsWorld? _world; private Demo2SaveData _saveData = new(); private double _tickAccumulator; private long _commandSequence; private int _speed = 1; private bool _waveSettled; public static Demo2GameService? Current { get; private set; } public Demo2EcsWorld? EcsWorld => _world; public Demo2ContentRegistry Content => _content; public IReadOnlyList RuleIds => _ruleIds; public IReadOnlyList RewardChoices => _rewardChoices; public Demo2Phase Phase { get; private set; } = Demo2Phase.Title; public int Cycle { get; private set; } = 1; public float PhaseRemainingSeconds { get; private set; } public int Speed => _speed; public long LastFirepower { get; private set; } public long LastEnemyHealth { get; private set; } public bool LastDefenseSucceeded { get; private set; } public string LastError { get; private set; } = string.Empty; public event Action? Changed; public event Action? TickAdvanced; public event Action? AuthoritativeBuildAccepted; public Demo2GameService() { _content = Demo2BuiltInContent.Create(); Current = this; ShrinkSave.SetCurrentSaveVersion(Demo2SaveData.CurrentVersion); ShrinkSave.RegisterModule(SaveModuleKey, CaptureSaveData, RestoreSaveData, new ModuleConfig { CriticalModule = true, AutoSaveIntervalSeconds = 0 }); } public void StartNewGame(ulong seed = 0) { if (seed == 0) seed = unchecked((ulong)DateTime.UtcNow.Ticks); _world?.Dispose(); _ruleIds.Clear(); _ruleIds.Add("production.dense-ammo"); _world = new Demo2EcsWorld(seed, ResolveRules()); _saveData = new Demo2SaveData { Seed = seed, Money = 500, CoreHealth = 3, Cycle = 1 }; Cycle = 1; _idempotencyTokens.Clear(); _readyPlayers.Clear(); _commandSequence = 0; _speed = 1; BeginPhase(Demo2Phase.Build); } public void AdvanceFrame(float unscaledDeltaTime) { if (_world == null || Phase is Demo2Phase.Title or Demo2Phase.Lobby or Demo2Phase.Reward or Demo2Phase.Victory or Demo2Phase.Defeat) return; var scaled = Math.Min(0.1f, Math.Max(0, unscaledDeltaTime)) * _speed; PhaseRemainingSeconds = Math.Max(0, PhaseRemainingSeconds - scaled); _tickAccumulator += scaled; var tickSeconds = 1.0 / Demo2Protocol.TickRate; var guard = 0; while (_tickAccumulator >= tickSeconds && guard++ < 400) { _tickAccumulator -= tickSeconds; _world.Tick(); var state = _world.EntityManager.GetComponentData(_world.Singleton); TickAdvanced?.Invoke(state.Tick, state.StateHash); } if (PhaseRemainingSeconds <= 0) AdvancePhase(); Changed?.Invoke(); } public Demo2CommandResult SubmitBuild(Demo2BuildCommand command, bool authoritative = true) { if (_world == null) return Reject("world.missing", command); if (Phase != Demo2Phase.Build) return Reject("phase.not-build", command); if (string.IsNullOrWhiteSpace(command.IdempotencyToken)) return Reject("token.missing", command); if (!_idempotencyTokens.Add(command.IdempotencyToken)) return new Demo2CommandResult { Accepted = true, Duplicate = true }; if (command.X < 0 || command.X >= Demo2Protocol.GridWidth || command.Y < 0 || command.Y >= Demo2Protocol.GridHeight) return Reject("grid.out-of-range", command); var manager = _world.EntityManager; var existing = FindMachine(manager, command.X, command.Y); if (!command.Remove && existing != Entity.Null) return Reject("grid.occupied", command); if (command.Remove && existing == Entity.Null) return Reject("grid.empty", command); var economy = manager.GetComponentData(_world.Singleton); var cost = command.Remove ? 0 : Demo2BuildCommandSystem.Cost(command.Kind); if (!command.Remove && economy.Money < cost) return Reject("money.insufficient", command); var refund = command.Remove ? Demo2BuildCommandSystem.Cost(manager.GetComponentData(existing).Kind) : 0; _world.QueueBuild(command); _world.Tick(); if (authoritative) AuthoritativeBuildAccepted?.Invoke(command); EventBus.TriggerEvent(new Demo2BuildEvent { PlayerId = command.PlayerId, Kind = command.Kind, X = command.X, Y = command.Y, Accepted = true }); Changed?.Invoke(); return new Demo2CommandResult { Accepted = true, Refund = refund }; } public Demo2CommandResult BuildLocal(Demo2MachineKind kind, int x, int y, byte rotation = 0, bool remove = false) => SubmitBuild(new Demo2BuildCommand { Sequence = ++_commandSequence, IdempotencyToken = "local-" + _commandSequence, PlayerId = "local", Kind = kind, X = x, Y = y, Rotation = rotation, Remove = remove }); public void SetReady(string playerId, bool ready) { if (ready) _readyPlayers.Add(playerId); else _readyPlayers.Remove(playerId); if (Phase == Demo2Phase.Build && _readyPlayers.Count >= 1) AdvancePhase(); } public void StartWaveImmediately() { if (_world == null) return; if (Phase == Demo2Phase.Build) BeginPhase(Demo2Phase.Production); else if (Phase == Demo2Phase.Production) BeginPhase(Demo2Phase.Defense); } public void SetSpeed(int speed) { _speed = Math.Max(1, Math.Min(20, speed)); if (_world != null) { var state = _world.EntityManager.GetComponentData(_world.Singleton); state.SpeedMultiplier = _speed; _world.EntityManager.SetComponentData(_world.Singleton, state); } Changed?.Invoke(); } public bool ChooseReward(string ruleId, int replaceSlot = -1) { if (Phase != Demo2Phase.Reward || !_rewardChoices.Contains(ruleId)) return false; if (replaceSlot >= 0 && replaceSlot < _ruleIds.Count) _ruleIds[replaceSlot] = ruleId; else if (_ruleIds.Count < 5) _ruleIds.Add(ruleId); else _ruleIds[4] = ruleId; _world!.SetRules(ResolveRules()); var economy = _world.EntityManager.GetComponentData(_world.Singleton); economy.Money = SaturatingMath.Add(economy.Money, 100 + Cycle * 25); _world.EntityManager.SetComponentData(_world.Singleton, economy); ApplyMachineUpgrade(); EventBus.TriggerEvent(new Demo2RewardEvent { Cycle = Cycle, RuleId = ruleId, Slot = Math.Max(0, _ruleIds.IndexOf(ruleId)) }); if (Cycle >= Demo2Protocol.CycleCount) BeginPhase(Demo2Phase.Victory); else { Cycle++; if (_pendingContent != null) { _content = _pendingContent; _pendingContent = null; } BeginPhase(Demo2Phase.Build); } return true; } public bool ReorderRule(int from, int to) { if (from < 0 || from >= _ruleIds.Count || to < 0 || to >= _ruleIds.Count || from == to) return false; var value = _ruleIds[from]; _ruleIds.RemoveAt(from); _ruleIds.Insert(to, value); _world?.SetRules(ResolveRules()); Changed?.Invoke(); return true; } public void SpawnItem(Demo2MaterialKind kind, int quantity, int x = 4, int y = 7) { if (_world == null) return; var state = _world.EntityManager.GetComponentData(_world.Singleton); Demo2EcsFactory.CreateMaterial(_world.EntityManager, ++state.NextStableId, kind, x, y, 0, Math.Max(1, quantity)); _world.EntityManager.SetComponentData(_world.Singleton, state); _world.Tick(); Changed?.Invoke(); } public string StageModReload() { _pendingContent = Demo2ModBridge.BuildCatalogSnapshot(); return "staged=" + _pendingContent.ComputeContentHash() + "; appliesTo=next-cycle-or-next-game"; } public Demo2Snapshot CaptureSnapshot() => _world?.CaptureSnapshot(_ruleIds) ?? new Demo2Snapshot(); public void RestoreSnapshot(Demo2Snapshot snapshot) { if (snapshot.SimulationVersion != Demo2Protocol.SimulationVersion) throw new InvalidOperationException("Simulation version mismatch."); _world?.Dispose(); _ruleIds.Clear(); _ruleIds.AddRange(snapshot.RuleIds.Take(5)); _world = new Demo2EcsWorld(snapshot.Seed, ResolveRules()); _world.RestoreSnapshot(snapshot); Cycle = snapshot.Cycle; Phase = snapshot.Phase; Changed?.Invoke(); } public async UniTask SaveAsync() { if (Phase != Demo2Phase.Build) throw new InvalidOperationException("Demo2 can only save during Build or at a cycle boundary."); await ShrinkSave.SaveSlotAsync(SaveSlot, new SaveOptions { SlotName = "规则工厂" }); } public async UniTask LoadAsync() { await ShrinkSave.LoadSlotAsync(SaveSlot); Changed?.Invoke(); } public string BuildStatus() { if (_world == null) return "idle"; var simulation = _world.EntityManager.GetComponentData(_world.Singleton); var economy = _world.EntityManager.GetComponentData(_world.Singleton); var core = _world.EntityManager.GetComponentData(_world.Singleton); return $"cycle={Cycle}/{Demo2Protocol.CycleCount} phase={Phase} remaining={PhaseRemainingSeconds:0.0}s core={core.Value} money={economy.Money} payload={economy.Payload} multiplier={economy.MultiplierMilli / 1000f:0.00} hash={simulation.StateHash:x16}"; } public string DumpEcs() { if (_world == null) return "world unavailable"; var manager = _world.EntityManager; var text = new StringBuilder(BuildStatus()).AppendLine(); using var entities = manager.GetAllEntities(Allocator.Temp); for (var i = 0; i < entities.Length; i++) { var entity = entities[i]; if (!manager.HasComponent(entity)) continue; var id = manager.GetComponentData(entity).Value; var pos = manager.GetComponentData(entity); if (manager.HasComponent(entity)) { var machine = manager.GetComponentData(entity); text.AppendLine($"#{id} machine={machine.Kind} grid={pos.X},{pos.Y} input={machine.StoredInput} progress={machine.ProgressTicks} faults={machine.Faults}"); } else if (manager.HasComponent(entity)) { var material = manager.GetComponentData(entity); text.AppendLine($"#{id} item={material.Kind} grid={pos.X},{pos.Y} quantity={material.Quantity} progress={material.SubCellProgress}"); } } return text.ToString(); } public void Dispose() { ShrinkSave.UnregisterModule(SaveModuleKey); _world?.Dispose(); _world = null; if (ReferenceEquals(Current, this)) Current = null; } private void AdvancePhase() { switch (Phase) { case Demo2Phase.Build: BeginPhase(Demo2Phase.Production); break; case Demo2Phase.Production: BeginPhase(Demo2Phase.Defense); break; case Demo2Phase.Defense: if (!LastDefenseSucceeded && GetCoreHealth() <= 0) BeginPhase(Demo2Phase.Defeat); else BeginPhase(Demo2Phase.Reward); break; } } private void BeginPhase(Demo2Phase phase) { Phase = phase; _tickAccumulator = 0; _waveSettled = false; if (_world != null) { _world.SetCycle(Cycle); _world.SetPhase(phase); } PhaseRemainingSeconds = phase switch { Demo2Phase.Build => 60, Demo2Phase.Production => 90, Demo2Phase.Defense => 20, _ => 0 }; if (phase == Demo2Phase.Production) ResetCycleStatistics(); if (phase == Demo2Phase.Defense) SettleWave(); if (phase == Demo2Phase.Reward) RollRewardChoices(); _readyPlayers.Clear(); PublishPhase(); Changed?.Invoke(); } private void SettleWave() { if (_world == null || _waveSettled) return; _waveSettled = true; _world.Tick(); var manager = _world.EntityManager; var economy = manager.GetComponentData(_world.Singleton); var stats = manager.GetComponentData(_world.Singleton); var turretQuery = manager.CreateEntityQuery(typeof(Demo2TurretAmmo)); long ammo = 0; using (var turrets = turretQuery.ToEntityArray(Allocator.Temp)) for (var i = 0; i < turrets.Length; i++) { ammo = SaturatingMath.Add(ammo, manager.GetComponentData(turrets[i]).Value); manager.SetComponentData(turrets[i], new Demo2TurretAmmo()); } economy.Payload = SaturatingMath.Add(economy.Payload, SaturatingMath.Add(SaturatingMath.Multiply(ammo, 25), SaturatingMath.Multiply(stats.Processed, 15))); LastFirepower = SaturatingMath.MultiplyScaled(economy.Payload, economy.MultiplierMilli); LastEnemyHealth = _content.Waves.First(value => value.Cycle == Cycle).TotalHealth; LastDefenseSucceeded = LastFirepower >= LastEnemyHealth; var excess = Math.Max(0, LastFirepower - LastEnemyHealth); economy.ExcessFirepower = excess; if (LastDefenseSucceeded) economy.Money = SaturatingMath.Add(economy.Money, 100 + excess / 20); else { var core = manager.GetComponentData(_world.Singleton); core.Value--; manager.SetComponentData(_world.Singleton, core); } manager.SetComponentData(_world.Singleton, economy); EventBus.TriggerEvent(new Demo2SettlementEvent { Cycle = Cycle, Firepower = LastFirepower, EnemyHealth = LastEnemyHealth, Excess = excess, Defended = LastDefenseSucceeded, CoreHealth = GetCoreHealth() }); } private void RollRewardChoices() { _rewardChoices.Clear(); var state = _world!.EntityManager.GetComponentData(_world.Singleton); var random = new DeterministicRandom(state.Seed + (ulong)(Cycle * 7919)); var candidates = _content.Rules.Keys.Where(id => !_ruleIds.Contains(id)).OrderBy(id => id, StringComparer.Ordinal).ToList(); while (_rewardChoices.Count < 3 && candidates.Count > 0) { var index = random.NextInt(0, candidates.Count); _rewardChoices.Add(candidates[index]); candidates.RemoveAt(index); } } private void ResetCycleStatistics() { if (_world == null) return; var manager = _world.EntityManager; manager.SetComponentData(_world.Singleton, new Demo2CycleStats()); var economy = manager.GetComponentData(_world.Singleton); economy.Payload = 0; economy.MultiplierMilli = SaturatingMath.Scale; economy.Energy = 0; economy.ExcessFirepower = 0; manager.SetComponentData(_world.Singleton, economy); } private void ApplyMachineUpgrade() { if (_world == null) return; var manager = _world.EntityManager; var query = manager.CreateEntityQuery(typeof(Demo2MachineState)); using var entities = query.ToEntityArray(Allocator.Temp); for (var i = 0; i < entities.Length; i++) { var machine = manager.GetComponentData(entities[i]); machine.ProcessTicks = Math.Max(1, machine.ProcessTicks * 9 / 10); manager.SetComponentData(entities[i], machine); } } private Demo2SaveData CaptureSaveData() { if (_world == null) return _saveData; var snapshot = CaptureSnapshot(); _saveData.Cycle = Cycle; _saveData.CoreHealth = snapshot.CoreHealth; _saveData.Money = snapshot.Money; _saveData.Seed = snapshot.Seed; _saveData.RuleIds = new List(_ruleIds); _saveData.SnapshotJson = JsonConvert.SerializeObject(snapshot); _saveData.SnapshotHash = snapshot.StateHash.ToString("x16"); return _saveData; } private void RestoreSaveData(Demo2SaveData? data) { _saveData = data ?? new Demo2SaveData(); if (string.IsNullOrWhiteSpace(_saveData.SnapshotJson)) { StartNewGame(_saveData.Seed); return; } var snapshot = JsonConvert.DeserializeObject(_saveData.SnapshotJson) ?? throw new InvalidOperationException("Demo2 snapshot is invalid."); if (!string.Equals(snapshot.StateHash.ToString("x16"), _saveData.SnapshotHash, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Demo2 snapshot hash validation failed."); RestoreSnapshot(snapshot); } private IReadOnlyList ResolveRules() => _ruleIds.Where(id => _content.Rules.ContainsKey(id)).Select(id => _content.Rules[id]).ToList(); private void PublishPhase() { var hash = _world == null ? 0UL : _world.EntityManager.GetComponentData(_world.Singleton).StateHash; EventBus.TriggerEvent(new Demo2PhaseEvent { SimulationPhase = Phase, Cycle = Cycle, RemainingSeconds = PhaseRemainingSeconds, StateHash = hash }); } private Demo2CommandResult Reject(string errorCode, Demo2BuildCommand command) { LastError = errorCode; EventBus.TriggerEvent(new Demo2BuildEvent { PlayerId = command.PlayerId, Kind = command.Kind, X = command.X, Y = command.Y, Accepted = false, ErrorCode = errorCode }); return new Demo2CommandResult { ErrorCode = errorCode, Refund = 0 }; } private int GetCoreHealth() => _world == null ? 0 : _world.EntityManager.GetComponentData(_world.Singleton).Value; private static Entity FindMachine(EntityManager manager, int x, int y) { var query = manager.CreateEntityQuery(typeof(Demo2GridPosition), typeof(Demo2MachineState)); using var entities = query.ToEntityArray(Allocator.Temp); using var positions = query.ToComponentDataArray(Allocator.Temp); for (var i = 0; i < entities.Length; i++) if (positions[i].X == x && positions[i].Y == y) return entities[i]; return Entity.Null; } } }