451 lines
21 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|