#nullable enable using System; using System.Collections.Generic; using System.Linq; using Cysharp.Threading.Tasks; using ReplacedPerson.Core; using ShrinkDataSaver; using ShrinkEventBus; namespace ReplacedPerson.Runtime { public sealed class ReplacedPersonGameService : IDisposable { private const string SaveModuleKey = "demo.replaced-person"; private long _sequence; private ReplacedPersonSaveData _saveData; public static ReplacedPersonGameService? Current { get; private set; } public ReplacedHotReloadCatalog Content { get; } public ReplacedMatchEngine? Match { get; private set; } public ReplacedPersonSaveData SaveData => _saveData; public string ActiveEnemyId { get; private set; } = string.Empty; public ulong ActiveSeed { get; private set; } public event Action? Changed; public ReplacedPersonGameService() { Content = new ReplacedHotReloadCatalog(ReplacedBuiltInContent.Create()); _saveData = ReplacedPersonSaveMigration.CreateNew(); Current = this; ShrinkSave.SetCurrentSaveVersion(ReplacedPersonSaveData.CurrentVersion); ShrinkSave.RegisterModule(SaveModuleKey, () => _saveData, data => { _saveData = ReplacedPersonSaveMigration.Migrate(data ?? ReplacedPersonSaveMigration.CreateNew()); Changed?.Invoke(); }, new ModuleConfig { CriticalModule = true, AutoSaveIntervalSeconds = 60f }); } public ReplacedMatchEngine StartAi(string enemyId = "greed.tutorial", ulong? seed = null) { var catalog = Content.SnapshotForNewMatch(); if (!catalog.Enemies.TryGetValue(enemyId, out var enemy)) throw new ArgumentException("Unknown enemy: " + enemyId, nameof(enemyId)); var enemyDeck = new ReplacedDeck { NormalCards = new List(enemy.NormalDeck), EndCards = new List(enemy.EndDeck) }; var deckErrors = ReplacedDeckValidator.Validate(_saveData.ActiveDeck, catalog); if (deckErrors.Count > 0) _saveData.ActiveDeck = ReplacedBuiltInContent.CreateStarterDeck(); ActiveEnemyId = enemyId; ActiveSeed = seed ?? unchecked((ulong)DateTime.UtcNow.Ticks); Match = new ReplacedMatchEngine(catalog, _saveData.ActiveDeck, enemyDeck, ActiveSeed, "被替代之人", enemy.Name); _sequence = 0; PublishStateEvents(null, null); DriveAiIfNeeded(); Changed?.Invoke(); return Match; } public ReplacedCommandResult SubmitPlayer(ReplacedTurnSubmission submission) { if (Match == null) return new ReplacedCommandResult { ErrorCode = "match.missing", Message = "尚未开始对局。" }; var command = NewCommand(0, submission); var before = Match.State.Clone(); var result = Match.Apply(command); if (result.Accepted && !result.Duplicate) { EventBus.Post(new ReplacedCardsCommittedEvent { PlayerIndex = 0, NormalCardIds = submission.NormalCardIds.ToArray(), EndCardIds = submission.EndCardIds.ToArray() }); PublishStateEvents(before, Match.State); DriveAiIfNeeded(); CompleteRewardsIfNeeded(); Changed?.Invoke(); } return result; } public void GiveCard(string cardId) { if (!Content.Current.Cards.ContainsKey(cardId)) throw new ArgumentException("Unknown card: " + cardId); _saveData.CollectedCards.Add(cardId); EventBus.Post(new ReplacedRewardEvent { RewardId = cardId }); Changed?.Invoke(); } public void EquipOrnament(string ornamentId) { if (!_saveData.Ornaments.Contains(ornamentId)) return; if (_saveData.EquippedOrnaments.Contains(ornamentId)) _saveData.EquippedOrnaments.Remove(ornamentId); else _saveData.EquippedOrnaments.Add(ornamentId); Changed?.Invoke(); } public IReadOnlyList ValidateDeck() => ReplacedDeckValidator.Validate(_saveData.ActiveDeck, Content.Current); public async UniTask SaveAsync(int slot = 14) { try { await ShrinkSave.SaveSlotAsync(slot, new SaveOptions { SlotName = "被替代之人" }); EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = true }); } catch { EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = false }); throw; } } public async UniTask LoadAsync(int slot = 14) { try { await ShrinkSave.LoadSlotAsync(slot); EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = true }); Changed?.Invoke(); } catch { EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = false }); throw; } } public string BuildStatus() { if (Match == null) return $"idle chapter={_saveData.ChapterProgress} cards={_saveData.CollectedCards.Count} ornaments={_saveData.Ornaments.Count}"; var state = Match.State; return $"round={state.Round} phase={state.Phase} lead={state.LeadPlayer} hp={state.Players[0].Health}/{state.Players[1].Health} cost={state.Players[0].Cost}/{state.Players[1].Cost} hash={Match.ComputeStateHash()}"; } public string DumpMatch() { if (Match == null) return "no active match"; return string.Join("\n", Match.State.Log) + "\nstateHash=" + Match.ComputeStateHash(); } public string ReloadMods() { var snapshot = ReplacedPersonModBridge.BuildCatalogSnapshot(); Content.Stage(snapshot); return $"staged={Content.PendingHash}; active={Content.CurrentHash}; appliesTo=next-match"; } public void Dispose() { ShrinkSave.UnregisterModule(SaveModuleKey); if (ReferenceEquals(Current, this)) Current = null; } private void DriveAiIfNeeded() { while (Match != null && Match.State.Phase != ReplacedMatchPhase.Completed) { var expected = Match.State.Phase == ReplacedMatchPhase.AwaitingLead ? Match.State.LeadPlayer : 1 - Match.State.LeadPlayer; if (expected != 1) break; var ai = new ReplacedAi(Match.Content); var submission = ai.Choose(Match.State.Clone(), 1); var command = NewCommand(1, submission); var before = Match.State.Clone(); var result = Match.Apply(command); if (!result.Accepted) throw new InvalidOperationException("AI produced illegal command: " + result.ErrorCode); EventBus.Post(new ReplacedCardsCommittedEvent { PlayerIndex = 1, NormalCardIds = submission.NormalCardIds.ToArray(), EndCardIds = submission.EndCardIds.ToArray() }); PublishStateEvents(before, Match.State); } CompleteRewardsIfNeeded(); } private ReplacedMatchCommand NewCommand(int playerIndex, ReplacedTurnSubmission submission) => new() { Sequence = ++_sequence, IdempotencyToken = $"local-{playerIndex}-{_sequence}", PlayerIndex = playerIndex, Submission = submission }; private void PublishStateEvents(ReplacedMatchState? before, ReplacedMatchState? after) { if (Match == null) return; var state = after ?? Match.State; EventBus.Post(new ReplacedMatchPhaseEvent { Round = state.Round, MatchPhase = state.Phase, LeadPlayer = state.LeadPlayer, StateHash = Match.ComputeStateHash() }); if (before == null || !SameResolution(before.LastResolution, state.LastResolution)) foreach (var slot in state.LastResolution) { EventBus.Post(new ReplacedDiceEvent { Slot = slot.SlotIndex, PlayerOneRoll = slot.PlayerOneRoll, PlayerTwoRoll = slot.PlayerTwoRoll }); } if (before == null) return; for (var i = 0; i < 2; i++) { var damage = Math.Max(0, before.Players[i].Health - state.Players[i].Health); if (damage > 0) EventBus.Post(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health }); } } private static bool SameResolution(IReadOnlyList left, IReadOnlyList right) { if (left.Count != right.Count) return false; for (var i = 0; i < left.Count; i++) { var a = left[i]; var b = right[i]; if (a.SlotIndex != b.SlotIndex || a.PlayerOneRoll != b.PlayerOneRoll || a.PlayerTwoRoll != b.PlayerTwoRoll || a.PlayerOneValue != b.PlayerOneValue || a.PlayerTwoValue != b.PlayerTwoValue || a.DamageToPlayerOne != b.DamageToPlayerOne || a.DamageToPlayerTwo != b.DamageToPlayerTwo || !string.Equals(a.Summary, b.Summary, StringComparison.Ordinal)) return false; } return true; } private void CompleteRewardsIfNeeded() { if (Match?.State.Phase != ReplacedMatchPhase.Completed || Match.State.Winner != ReplacedWinner.PlayerOne) return; if (!Content.Current.Enemies.TryGetValue(ActiveEnemyId, out var enemy)) return; foreach (var reward in enemy.RewardIds) { var target = reward.StartsWith("ornament.", StringComparison.Ordinal) ? _saveData.Ornaments : _saveData.CollectedCards; if (target.Contains(reward)) continue; target.Add(reward); EventBus.Post(new ReplacedRewardEvent { RewardId = reward }); } _saveData.ChapterProgress = Math.Max(_saveData.ChapterProgress, ActiveEnemyId == "greed.full" ? 2 : 1); _saveData.RecentReplay = Match.Replay.Select(value => value.Clone()).ToList(); } } }