Files
cneicy ad5a7b68a3 feat(sdk): migrate to EventBus 2.0
Replace the legacy EventBase runtime with generated multi-bus bindings and explicit scheduling. Migrate app, data, network, demo, and mod consumers; add generated network-event registration and owner-scoped mod content overrides.
2026-08-26 01:15:48 +08:00

256 lines
11 KiB
C#

#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<string>(enemy.NormalDeck),
EndCards = new List<string>(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<string> 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<ReplacedSlotResolution> left, IReadOnlyList<ReplacedSlotResolution> 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();
}
}
}