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.
256 lines
11 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|