Files
Workspace/Assets/Demos/Demo2/Runtime/Demo2GameService.cs
T
2026-08-18 02:03:34 +08:00

451 lines
21 KiB
C#

#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<string> _idempotencyTokens = new(StringComparer.Ordinal);
private readonly HashSet<string> _readyPlayers = new(StringComparer.Ordinal);
private readonly List<string> _ruleIds = new();
private readonly List<string> _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<string> RuleIds => _ruleIds;
public IReadOnlyList<string> 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<long, ulong>? TickAdvanced;
public event Action<Demo2BuildCommand>? 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<Demo2SimulationState>(_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<Demo2EconomyState>(_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<Demo2MachineState>(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<Demo2SimulationState>(_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<Demo2EconomyState>(_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<Demo2SimulationState>(_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<Demo2SimulationState>(_world.Singleton);
var economy = _world.EntityManager.GetComponentData<Demo2EconomyState>(_world.Singleton);
var core = _world.EntityManager.GetComponentData<Demo2CoreHealth>(_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<Demo2StableId>(entity)) continue;
var id = manager.GetComponentData<Demo2StableId>(entity).Value;
var pos = manager.GetComponentData<Demo2GridPosition>(entity);
if (manager.HasComponent<Demo2MachineState>(entity))
{
var machine = manager.GetComponentData<Demo2MachineState>(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<Demo2MaterialState>(entity))
{
var material = manager.GetComponentData<Demo2MaterialState>(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<Demo2EconomyState>(_world.Singleton);
var stats = manager.GetComponentData<Demo2CycleStats>(_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<Demo2TurretAmmo>(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<Demo2CoreHealth>(_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<Demo2SimulationState>(_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<Demo2EconomyState>(_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<Demo2MachineState>(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<string>(_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<Demo2Snapshot>(_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<Demo2RuleDefinition> 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<Demo2SimulationState>(_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<Demo2CoreHealth>(_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<Demo2GridPosition>(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;
}
}
}