From ad4241aaaf22b2ff579bdfc4477db39ea4b9ef52 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 23:41:15 +0200 Subject: [PATCH] Extract game roll service --- README.md | 1 + RpgRoller/Services/GameRollService.cs | 792 ++++++++++++++++++++++++++ RpgRoller/Services/GameService.cs | 714 +---------------------- 3 files changed, 801 insertions(+), 706 deletions(-) create mode 100644 RpgRoller/Services/GameRollService.cs diff --git a/README.md b/README.md index b8f52aa..1bd4b13 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Backend: - `RpgRoller/Services/GameCampaignService.cs`: extracted campaign creation, visibility reads, roster reads, and deletion workflows behind the same facade contract - `RpgRoller/Services/GameCharacterService.cs`: extracted character creation, transfer, activation, deletion, and owner-scoped listing workflows behind the same facade contract - `RpgRoller/Services/GameSkillService.cs`: extracted skill-group CRUD, skill CRUD, sheet shaping, and ruleset-specific validation orchestration behind the same facade contract +- `RpgRoller/Services/GameRollService.cs`: extracted roll execution, log shaping, roll detail visibility, and campaign-state snapshot reads behind the same facade contract Frontend: diff --git a/RpgRoller/Services/GameRollService.cs b/RpgRoller/Services/GameRollService.cs new file mode 100644 index 0000000..29fa6ed --- /dev/null +++ b/RpgRoller/Services/GameRollService.cs @@ -0,0 +1,792 @@ +using System.Text.Json; +using RpgRoller.Contracts; +using RpgRoller.Domain; + +namespace RpgRoller.Services; + +public sealed class GameRollService +{ + public GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller) + { + m_StateStore = stateStore; + m_PersistenceService = persistenceService; + m_DiceRoller = diceRoller; + } + + public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill)) + return ServiceResult.Failure("skill_not_found", "Skill was not found."); + + var character = m_StateStore.CharactersById[skill.CharacterId]; + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can roll this skill."); + + var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition); + if (!parsedExpression.Succeeded) + return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); + + var parsedVisibility = RollVisibilityParser.Parse(visibility); + if (!parsedVisibility.Succeeded) + return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); + + var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); + } + } + + public ServiceResult RollCustom(string sessionToken, Guid characterId, string expression, string visibility) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character)) + return ServiceResult.Failure("character_not_found", "Character was not found."); + + if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) + return ServiceResult.Failure(campaignError!.Code, campaignError.Message); + + if (!CanEditCharacterLocked(user.Id, character, campaign)) + return ServiceResult.Failure("forbidden", "Only the owner or GM can make a custom roll for this character."); + + var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression); + if (!parsedExpression.Succeeded) + return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); + + var parsedVisibility = RollVisibilityParser.Parse(visibility); + if (!parsedVisibility.Succeeded) + return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); + + var (wildDice, allowFumble, fumbleRange) = CustomRollOptionsResolver.Resolve(campaign.Ruleset); + var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange); + return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); + } + } + + public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) + { + lock (m_StateStore.Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); + + var (user, campaign) = context.Value!; + var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) + .TakeLast(CampaignLogHistoryWindowSize) + .Select(ToLogEntry) + .ToArray(); + + return ServiceResult>.Success(entries); + } + } + + public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) + { + lock (m_StateStore.Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + + var (user, campaign) = context.Value!; + var pageSize = NormalizeCampaignLogPageSize(limit); + var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray(); + + if (!afterRollId.HasValue) + { + var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); + return ServiceResult.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false)); + } + + var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value); + if (afterIndex < 0) + { + var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); + return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true)); + } + + var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray(); + if (newEntries.Length == 0) + return ServiceResult.Success(new CampaignLogPage([], afterRollId, false, false)); + + if (newEntries.Length > pageSize) + { + var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); + return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true)); + } + + var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); + return ServiceResult.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false)); + } + } + + public ServiceResult GetRollDetail(string sessionToken, Guid rollId) + { + lock (m_StateStore.Gate) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult.Failure("unauthorized", "You must be logged in."); + + var entry = m_StateStore.RollLog.FirstOrDefault(candidate => candidate.Id == rollId); + if (entry is null) + return ServiceResult.Failure("roll_not_found", "Roll was not found."); + + if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry)) + return ServiceResult.Failure("roll_not_found", "Roll was not found."); + + return ServiceResult.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray())); + } + } + + public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) + { + lock (m_StateStore.Gate) + { + var context = ResolveContextLocked(sessionToken, campaignId); + if (!context.Succeeded) + return ServiceResult.Failure(context.Error!.Code, context.Error.Message); + + return ServiceResult.Success(ToCampaignStateSnapshot(context.Value!.Campaign)); + } + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) + { + if (ruleset == RulesetKind.D6) + return ComputeD6Roll(expression, wildDice, allowFumble); + + if (ruleset == RulesetKind.Rolemaster) + { + return expression.Kind switch + { + DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, fumbleRange.GetValueOrDefault()), + _ => ComputeRolemasterStandardRoll(expression) + }; + } + + return ComputeStandardRoll(expression); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeStandardRoll(DiceExpression expression) + { + var diceValues = new int[expression.DiceCount]; + var dice = new RollDieResult[expression.DiceCount]; + var total = expression.Modifier; + for (var i = 0; i < expression.DiceCount; i += 1) + { + var value = m_DiceRoller.Roll(expression.Sides); + diceValues[i] = value; + dice[i] = new(value, false, false, false, false, false); + total += value; + } + + return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterStandardRoll(DiceExpression expression) + { + var diceValues = new int[expression.DiceCount]; + var dice = new RollDieResult[expression.DiceCount]; + var total = expression.Modifier; + for (var i = 0; i < expression.DiceCount; i += 1) + { + var value = m_DiceRoller.Roll(expression.Sides); + diceValues[i] = value; + dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); + total += value; + } + + return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange) + { + var initialRoll = m_DiceRoller.Roll(expression.Sides); + var followUpRolls = new List(); + int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; + var dice = new List + { + CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) + }; + + var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; + var subtractFollowUps = false; + if (initialRoll >= 96) + { + followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: false)); + baseTotal += followUpRolls.Sum(); + } + else if (initialRoll <= fumbleRange) + { + subtractFollowUps = true; + followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: true)); + baseTotal -= followUpRolls.Sum(); + } + + var total = baseTotal + expression.Modifier; + var breakdown = BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total); + return (total, breakdown, dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble) + { + var initialDice = expression.DiceCount; + var currentDice = initialDice; + var pendingExplodingDice = 0; + var pendingFumbles = 0; + var dieResults = new List(initialDice); + + for (var i = 0; i < currentDice; i += 1) + { + var roll = m_DiceRoller.Roll(expression.Sides); + var isWild = i < wildDice; + var isCrit = false; + var isFumble = false; + var isAdded = false; + + if (isWild) + { + if (roll == expression.Sides) + { + pendingExplodingDice += 1; + currentDice += 1; + isCrit = true; + } + else if (allowFumble && roll == 1) + { + pendingFumbles += 1; + isFumble = true; + } + } + + if (pendingExplodingDice > 0 && i >= initialDice) + { + pendingExplodingDice -= 1; + isAdded = true; + if (roll == expression.Sides) + { + pendingExplodingDice += 1; + currentDice += 1; + } + } + + dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded)); + } + + for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1) + for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1) + { + if (dieResults[i].Roll != roll) + continue; + + dieResults[i] = dieResults[i] with + { + Removed = true, + Added = false, + Crit = false, + Fumble = false + }; + pendingFumbles -= 1; + } + + var total = expression.Modifier; + var includedDice = new List(dieResults.Count); + foreach (var die in dieResults) + { + if (die.Fumble) + { + total += 1; + includedDice.Add(1); + } + else if (!die.Removed) + { + total += die.Roll; + includedDice.Add(die.Roll); + } + } + + return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults); + } + + private IEnumerable RollRolemasterHighOpenEndedChain(List dice, int sequenceStart, bool subtract) + { + var followUpRolls = new List(); + var sequence = sequenceStart; + + while (true) + { + var roll = m_DiceRoller.Roll(100); + followUpRolls.Add(roll); + dice.Add(CreateRolemasterDie( + roll, + sequence, + subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, + subtract ? -roll : roll)); + + sequence += 1; + if (roll < 96) + break; + } + + return followUpRolls; + } + + private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) + { + return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); + } + + private static string BuildBreakdown(IEnumerable diceValues, int modifier, int total) + { + var dicePart = string.Join("+", diceValues); + if (string.IsNullOrWhiteSpace(dicePart)) + dicePart = "0"; + + return BuildModifierBreakdown(dicePart, modifier, total); + } + + private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total) + { + if (subtractFollowUps) + { + var segments = new List { $"({FormatRolemasterTriggerRoll(initialRoll)})" }; + segments.AddRange(followUpRolls.Select(roll => $"-{roll}")); + if (modifier > 0) + segments.Add($"+{modifier}"); + else if (modifier < 0) + segments.Add(modifier.ToString()); + + return $"{string.Join(" ", segments)} = {total}"; + } + + var core = initialRoll.ToString(); + if (followUpRolls.Count > 0) + { + var followUpBreakdown = string.Join("+", followUpRolls); + core = subtractFollowUps ? $"{core}-({followUpBreakdown})" : $"{core}+{followUpBreakdown}"; + } + + return BuildModifierBreakdown(core, modifier, total); + } + + private static string FormatRolemasterTriggerRoll(int roll) + { + return roll.ToString("00"); + } + + private static string BuildModifierBreakdown(string core, int modifier, int total) + { + return modifier switch + { + > 0 => $"{core}+{modifier}={total}", + < 0 => $"{core}{modifier}={total}", + _ => $"{core}={total}" + }; + } + + private ServiceResult RecordRollLocked( + UserAccount user, + Campaign campaign, + Character character, + Guid skillId, + RollVisibility visibility, + (int Total, string Breakdown, IReadOnlyList Dice) roll, + string canonicalExpression) + { + var entry = new RollLogEntry + { + Id = Guid.NewGuid(), + CampaignId = campaign.Id, + CharacterId = character.Id, + SkillId = skillId, + RollerUserId = user.Id, + Visibility = visibility, + Result = roll.Total, + Breakdown = FormatLoggedBreakdown(skillId, canonicalExpression, roll.Breakdown), + Dice = SerializeDice(roll.Dice), + TimestampUtc = DateTimeOffset.UtcNow + }; + + m_StateStore.RollLog.Add(entry); + TouchLogLocked(campaign.Id); + + m_PersistenceService.PersistStateLocked(); + return ServiceResult.Success(ToRollResult(entry, roll.Dice)); + } + + private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown) + { + return skillId == CustomRollSkillId + ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" + : breakdown; + } + + private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) + { + var user = ResolveUserLocked(sessionToken); + if (user is null) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); + + if (!m_StateStore.CampaignsById.TryGetValue(campaignId, out var campaign)) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); + + if (!CanViewCampaignLocked(user.Id, campaign.Id)) + return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); + + return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); + } + + private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) + { + var state = GetOrCreateCampaignStateLocked(campaign.Id); + var characterVersions = state.CharacterVersions + .OrderBy(version => version.Key) + .Select(version => new CharacterStateVersion(version.Key, version.Value)) + .ToArray(); + + return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); + } + + private IEnumerable GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign) + { + return m_StateStore.RollLog + .Where(r => r.CampaignId == campaign.Id) + .Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id) + .OrderBy(r => r.TimestampUtc) + .ThenBy(r => r.Id); + } + + private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList dice) + { + return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc); + } + + private CampaignLogEntry ToLogEntry(RollLogEntry entry) + { + var dice = DeserializeDice(entry.Dice); + var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; + var skillName = ResolveLoggedSkillName(entry); + var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId); + + return new( + entry.Id, + entry.CampaignId, + entry.CharacterId, + characterName, + entry.SkillId, + skillName, + entry.RollerUserId, + rollerDisplayName, + entry.Visibility == RollVisibility.Public ? "public" : "private", + entry.Result, + entry.Breakdown, + dice, + entry.TimestampUtc); + } + + private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry) + { + var dice = DeserializeDice(entry.Dice); + var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; + var skillName = ResolveLoggedSkillName(entry); + var loggedExpression = ResolveLoggedExpression(entry); + var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice); + + return new( + entry.Id, + characterName, + skillName, + ResolveLogRollerLabel(user, campaign, entry), + ResolveLogVisibilityLabel(user, campaign, entry), + ResolveLogVisibilityStyle(user, campaign, entry), + entry.Result, + BuildCompactLogSummary(dice), + eventBadges, + entry.TimestampUtc); + } + + private static string SerializeDice(IReadOnlyList dice) + { + return JsonSerializer.Serialize(dice, DiceJsonOptions); + } + + private static string BuildCompactLogSummary(IReadOnlyList dice) + { + if (dice.Count == 0) + return "No detail available."; + + if (dice.Any(die => IsRolemasterDieKind(die.Kind))) + return BuildRolemasterCompactLogSummary(dice); + + return string.Join(", ", dice.Select(die => die.Roll.ToString())); + } + + private static string BuildRolemasterCompactLogSummary(IReadOnlyList dice) + { + var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); + if (openEndedInitial is not null) + { + var highFollowUps = dice + .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)) + .Select(die => die.Roll.ToString()) + .ToArray(); + if (highFollowUps.Length > 0) + return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; + + var lowFollowUps = dice + .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)) + .Select(die => die.Roll.ToString()) + .ToArray(); + if (lowFollowUps.Length > 0) + return $"({FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; + + return $"{openEndedInitial.Roll} | open-ended"; + } + + if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal))) + { + var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString())); + return $"{preview} | rolemaster"; + } + + return string.Join(", ", dice.Select(die => die.Roll.ToString())); + } + + private static bool IsRolemasterDieKind(string? kind) + { + return kind is RollDieKinds.RolemasterStandard or + RollDieKinds.RolemasterOpenEndedInitial or + RollDieKinds.RolemasterOpenEndedHigh or + RollDieKinds.RolemasterOpenEndedLowSubtract; + } + + private string ResolveLoggedSkillName(RollLogEntry entry) + { + if (entry.SkillId == CustomRollSkillId) + return CustomRollLabel; + + return m_StateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill"; + } + + private string? ResolveLoggedExpression(RollLogEntry entry) + { + if (entry.SkillId == CustomRollSkillId) + return ExtractCustomRollExpression(entry.Breakdown); + + return m_StateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null; + } + + private static string? ExtractCustomRollExpression(string breakdown) + { + var separatorIndex = breakdown.IndexOf(CustomRollBreakdownSeparator, StringComparison.Ordinal); + if (separatorIndex <= 0) + return null; + + return breakdown[..separatorIndex]; + } + + private static string[]? BuildCompactLogEventBadges(Campaign campaign, string? expression, IReadOnlyList dice) + { + var badges = new List(); + + switch (campaign.Ruleset) + { + case RulesetKind.D6: + AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6"); + AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1"); + break; + case RulesetKind.Dnd5e: + if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression)) + { + AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20"); + AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1"); + } + + break; + case RulesetKind.Rolemaster: + AddBadgeIfMissing( + badges, + dice.Any(die => + string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && + !die.SignedContribution.HasValue), + "rf"); + AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100"); + AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); + break; + } + + return badges.Count == 0 ? null : badges.ToArray(); + } + + private static void AddBadgeIfMissing(List badges, bool condition, string code) + { + if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal))) + return; + + badges.Add(code); + } + + private static bool IsSingleD20Expression(string expression) + { + var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); + return parsedExpression.Succeeded && + parsedExpression.Value!.DiceCount == 1 && + parsedExpression.Value.Sides == 20; + } + + private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry) + { + return CanViewCampaignLocked(user.Id, campaign.Id) && + (entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id); + } + + private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry) + { + if (entry.RollerUserId == user.Id) + return "You"; + + if (entry.RollerUserId == campaign.GmUserId) + return "GM"; + + return ResolveOwnerDisplayName(entry.RollerUserId); + } + + private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry) + { + if (entry.Visibility != RollVisibility.Private) + return "Public"; + + if (entry.RollerUserId == user.Id) + return "Private (you)"; + + return campaign.GmUserId == user.Id ? "Private (GM view)" : "Private"; + } + + private static string ResolveLogVisibilityStyle(UserAccount user, Campaign campaign, RollLogEntry entry) + { + if (entry.Visibility != RollVisibility.Private) + return "public"; + + if (entry.RollerUserId == user.Id) + return "private-self"; + + return campaign.GmUserId == user.Id ? "private-gm" : "private-generic"; + } + + private string ResolveOwnerDisplayName(Guid ownerUserId) + { + return m_StateStore.UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName) + ? owner.DisplayName + : "Unknown owner"; + } + + private static IReadOnlyList DeserializeDice(string serializedDice) + { + if (string.IsNullOrWhiteSpace(serializedDice)) + return []; + + try + { + return JsonSerializer.Deserialize>(serializedDice, DiceJsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) + { + return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; + } + + private bool CanViewCampaignLocked(Guid userId, Guid campaignId) + { + if (m_StateStore.UsersById.TryGetValue(userId, out var user) && RoleSerializer.HasRole(user.Roles, UserRoles.Admin)) + return true; + + var campaign = m_StateStore.CampaignsById[campaignId]; + if (campaign.GmUserId == userId) + return true; + + return m_StateStore.CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId); + } + + private UserAccount? ResolveUserLocked(string sessionToken) + { + if (string.IsNullOrWhiteSpace(sessionToken)) + return null; + + if (!m_StateStore.SessionsByToken.TryGetValue(sessionToken, out var session)) + return null; + + return m_StateStore.UsersById.GetValueOrDefault(session.UserId); + } + + private bool TryResolveCharacterCampaignLocked(Character character, out Campaign campaign, out ServiceError? error) + { + campaign = default!; + if (!character.CampaignId.HasValue || !m_StateStore.CampaignsById.TryGetValue(character.CampaignId.Value, out var resolvedCampaign) || resolvedCampaign is null) + { + error = new ServiceError("character_not_in_campaign", "Character is not linked to a campaign."); + return false; + } + + campaign = resolvedCampaign; + error = null; + return true; + } + + private void TouchLogLocked(Guid? campaignId) + { + if (!campaignId.HasValue || !m_StateStore.CampaignsById.ContainsKey(campaignId.Value)) + return; + + var state = GetOrCreateCampaignStateLocked(campaignId.Value); + state.TotalVersion += 1; + state.LogVersion += 1; + } + + private GameCampaignStateTracker GetOrCreateCampaignStateLocked(Guid campaignId) + { + if (!m_StateStore.CampaignStateById.TryGetValue(campaignId, out var state)) + { + state = new GameCampaignStateTracker(); + m_StateStore.CampaignStateById[campaignId] = state; + } + + return state; + } + + private static int NormalizeCampaignLogPageSize(int? limit) + { + if (!limit.HasValue) + return CampaignLogLivePageSize; + + return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize); + } + + private const int CampaignLogHistoryWindowSize = 100; + private const int CampaignLogLivePageSize = 25; + private const string CustomRollBreakdownSeparator = " => "; + private static readonly Guid CustomRollSkillId = Guid.Empty; + private const string CustomRollLabel = "Custom roll"; + private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); + private readonly IDiceRoller m_DiceRoller; + private readonly GamePersistenceService m_PersistenceService; + private readonly GameStateStore m_StateStore; +} diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index 02c631f..628a02a 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using RpgRoller.Contracts; @@ -26,8 +25,8 @@ public sealed class GameService : IGameService m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_CampaignService = new(m_StateStore, m_PersistenceService); m_CharacterService = new(m_StateStore, m_PersistenceService); + m_RollService = new(m_StateStore, m_PersistenceService, diceRoller); m_SkillService = new(m_StateStore, m_PersistenceService); - m_DiceRoller = diceRoller; LoadStateFromDatabase(); } @@ -255,439 +254,32 @@ public sealed class GameService : IGameService public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_SkillsById.TryGetValue(skillId, out var skill)) - return ServiceResult.Failure("skill_not_found", "Skill was not found."); - - var character = m_CharactersById[skill.CharacterId]; - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can roll this skill."); - - var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition); - if (!parsedExpression.Succeeded) - return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); - - var parsedVisibility = RollVisibilityParser.Parse(visibility); - if (!parsedVisibility.Succeeded) - return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); - - var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange); - return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); - } + return m_RollService.RollSkill(sessionToken, skillId, visibility); } public ServiceResult RollCustom(string sessionToken, Guid characterId, string expression, string visibility) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - if (!m_CharactersById.TryGetValue(characterId, out var character)) - return ServiceResult.Failure("character_not_found", "Character was not found."); - - if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError)) - return ServiceResult.Failure(campaignError!.Code, campaignError.Message); - - if (!CanEditCharacterLocked(user.Id, character, campaign)) - return ServiceResult.Failure("forbidden", "Only the owner or GM can make a custom roll for this character."); - - var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression); - if (!parsedExpression.Succeeded) - return ServiceResult.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message); - - var parsedVisibility = RollVisibilityParser.Parse(visibility); - if (!parsedVisibility.Succeeded) - return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); - - var (wildDice, allowFumble, fumbleRange) = CustomRollOptionsResolver.Resolve(campaign.Ruleset); - var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange); - return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); - } + return m_RollService.RollCustom(sessionToken, characterId, expression, visibility); } public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) { - lock (m_Gate) - { - var context = ResolveContextLocked(sessionToken, campaignId); - if (!context.Succeeded) - return ServiceResult>.Failure(context.Error!.Code, context.Error.Message); - - var (user, campaign) = context.Value!; - var entries = GetVisibleCampaignLogEntriesLocked(user, campaign) - .TakeLast(CampaignLogHistoryWindowSize) - .Select(ToLogEntry) - .ToArray(); - - return ServiceResult>.Success(entries); - } + return m_RollService.GetCampaignLog(sessionToken, campaignId); } public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) { - lock (m_Gate) - { - var context = ResolveContextLocked(sessionToken, campaignId); - if (!context.Succeeded) - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - - var (user, campaign) = context.Value!; - var pageSize = NormalizeCampaignLogPageSize(limit); - var visibleEntries = GetVisibleCampaignLogEntriesLocked(user, campaign).ToArray(); - - if (!afterRollId.HasValue) - { - var initialEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); - return ServiceResult.Success(new CampaignLogPage(initialEntries, initialEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, false)); - } - - var afterIndex = Array.FindIndex(visibleEntries, entry => entry.Id == afterRollId.Value); - if (afterIndex < 0) - { - var replacementEntries = visibleEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); - return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true)); - } - - var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray(); - if (newEntries.Length == 0) - return ServiceResult.Success(new CampaignLogPage([], afterRollId, false, false)); - - if (newEntries.Length > pageSize) - { - var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); - return ServiceResult.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true)); - } - - var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray(); - return ServiceResult.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false)); - } + return m_RollService.GetCampaignLogPage(sessionToken, campaignId, afterRollId, limit); } public ServiceResult GetRollDetail(string sessionToken, Guid rollId) { - lock (m_Gate) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult.Failure("unauthorized", "You must be logged in."); - - var entry = m_RollLog.FirstOrDefault(candidate => candidate.Id == rollId); - if (entry is null) - return ServiceResult.Failure("roll_not_found", "Roll was not found."); - - if (!m_CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry)) - return ServiceResult.Failure("roll_not_found", "Roll was not found."); - - return ServiceResult.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray())); - } + return m_RollService.GetRollDetail(sessionToken, rollId); } public ServiceResult GetCampaignStateSnapshot(string sessionToken, Guid campaignId) { - lock (m_Gate) - { - var context = ResolveContextLocked(sessionToken, campaignId); - if (!context.Succeeded) - return ServiceResult.Failure(context.Error!.Code, context.Error.Message); - - return ServiceResult.Success(ToCampaignStateSnapshot(context.Value!.Campaign)); - } - } - - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) - { - if (ruleset == RulesetKind.D6) - return ComputeD6Roll(expression, wildDice, allowFumble); - - if (ruleset == RulesetKind.Rolemaster) - { - return expression.Kind switch - { - DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, fumbleRange.GetValueOrDefault()), - _ => ComputeRolemasterStandardRoll(expression) - }; - } - - return ComputeStandardRoll(expression); - } - - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeStandardRoll(DiceExpression expression) - { - var diceValues = new int[expression.DiceCount]; - var dice = new RollDieResult[expression.DiceCount]; - var total = expression.Modifier; - for (var i = 0; i < expression.DiceCount; i += 1) - { - var value = m_DiceRoller.Roll(expression.Sides); - diceValues[i] = value; - dice[i] = new(value, false, false, false, false, false); - total += value; - } - - return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); - } - - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterStandardRoll(DiceExpression expression) - { - var diceValues = new int[expression.DiceCount]; - var dice = new RollDieResult[expression.DiceCount]; - var total = expression.Modifier; - for (var i = 0; i < expression.DiceCount; i += 1) - { - var value = m_DiceRoller.Roll(expression.Sides); - diceValues[i] = value; - dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); - total += value; - } - - return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice); - } - - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange) - { - var initialRoll = m_DiceRoller.Roll(expression.Sides); - var followUpRolls = new List(); - int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; - var dice = new List - { - CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) - }; - - var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; - var subtractFollowUps = false; - if (initialRoll >= 96) - { - followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: false)); - baseTotal += followUpRolls.Sum(); - } - else if (initialRoll <= fumbleRange) - { - subtractFollowUps = true; - followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: true)); - baseTotal -= followUpRolls.Sum(); - } - - var total = baseTotal + expression.Modifier; - var breakdown = BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total); - return (total, breakdown, dice); - } - - private (int Total, string Breakdown, IReadOnlyList Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble) - { - var initialDice = expression.DiceCount; - var currentDice = initialDice; - var pendingExplodingDice = 0; - var pendingFumbles = 0; - var dieResults = new List(initialDice); - - for (var i = 0; i < currentDice; i += 1) - { - var roll = m_DiceRoller.Roll(expression.Sides); - var isWild = i < wildDice; - var isCrit = false; - var isFumble = false; - var isAdded = false; - - if (isWild) - { - if (roll == expression.Sides) - { - pendingExplodingDice += 1; - currentDice += 1; - isCrit = true; - } - else if (allowFumble && roll == 1) - { - pendingFumbles += 1; - isFumble = true; - } - } - - if (pendingExplodingDice > 0 && i >= initialDice) - { - pendingExplodingDice -= 1; - isAdded = true; - if (roll == expression.Sides) - { - pendingExplodingDice += 1; - currentDice += 1; - } - } - - dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded)); - } - - for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1) - for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1) - { - if (dieResults[i].Roll != roll) - continue; - - dieResults[i] = dieResults[i] with - { - Removed = true, - Added = false, - Crit = false, - Fumble = false - }; - pendingFumbles -= 1; - } - - var total = expression.Modifier; - var includedDice = new List(dieResults.Count); - foreach (var die in dieResults) - { - if (die.Fumble) - { - total += 1; - includedDice.Add(1); - } - else if (!die.Removed) - { - total += die.Roll; - includedDice.Add(die.Roll); - } - } - - return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults); - } - - private IEnumerable RollRolemasterHighOpenEndedChain(List dice, int sequenceStart, bool subtract) - { - var followUpRolls = new List(); - var sequence = sequenceStart; - - while (true) - { - var roll = m_DiceRoller.Roll(100); - followUpRolls.Add(roll); - dice.Add(CreateRolemasterDie( - roll, - sequence, - subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, - subtract ? -roll : roll)); - - sequence += 1; - if (roll < 96) - break; - } - - return followUpRolls; - } - - private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) - { - return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); - } - - private static string BuildBreakdown(IEnumerable diceValues, int modifier, int total) - { - var dicePart = string.Join("+", diceValues); - if (string.IsNullOrWhiteSpace(dicePart)) - dicePart = "0"; - - return BuildModifierBreakdown(dicePart, modifier, total); - } - - private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total) - { - if (subtractFollowUps) - { - var segments = new List { $"({FormatRolemasterTriggerRoll(initialRoll)})" }; - segments.AddRange(followUpRolls.Select(roll => $"-{roll}")); - if (modifier > 0) - segments.Add($"+{modifier}"); - else if (modifier < 0) - segments.Add(modifier.ToString()); - - return $"{string.Join(" ", segments)} = {total}"; - } - - var core = initialRoll.ToString(); - if (followUpRolls.Count > 0) - { - var followUpBreakdown = string.Join("+", followUpRolls); - core = subtractFollowUps ? $"{core}-({followUpBreakdown})" : $"{core}+{followUpBreakdown}"; - } - - return BuildModifierBreakdown(core, modifier, total); - } - - private static string FormatRolemasterTriggerRoll(int roll) - { - return roll.ToString("00"); - } - - private static string BuildModifierBreakdown(string core, int modifier, int total) - { - return modifier switch - { - > 0 => $"{core}+{modifier}={total}", - < 0 => $"{core}{modifier}={total}", - _ => $"{core}={total}" - }; - } - - private ServiceResult RecordRollLocked( - UserAccount user, - Campaign campaign, - Character character, - Guid skillId, - RollVisibility visibility, - (int Total, string Breakdown, IReadOnlyList Dice) roll, - string canonicalExpression) - { - var entry = new RollLogEntry - { - Id = Guid.NewGuid(), - CampaignId = campaign.Id, - CharacterId = character.Id, - SkillId = skillId, - RollerUserId = user.Id, - Visibility = visibility, - Result = roll.Total, - Breakdown = FormatLoggedBreakdown(skillId, canonicalExpression, roll.Breakdown), - Dice = SerializeDice(roll.Dice), - TimestampUtc = DateTimeOffset.UtcNow - }; - - m_RollLog.Add(entry); - TouchLogLocked(campaign.Id); - - PersistStateLocked(); - return ServiceResult.Success(ToRollResult(entry, roll.Dice)); - } - - private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown) - { - return skillId == CustomRollSkillId - ? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}" - : breakdown; - } - - private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId) - { - var user = ResolveUserLocked(sessionToken); - if (user is null) - return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("unauthorized", "You must be logged in."); - - if (!m_CampaignsById.TryGetValue(campaignId, out var campaign)) - return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("campaign_not_found", "Campaign was not found."); - - if (!CanViewCampaignLocked(user.Id, campaign.Id)) - return ServiceResult<(UserAccount User, Campaign Campaign)>.Failure("forbidden", "You are not a participant in this campaign."); - - return ServiceResult<(UserAccount User, Campaign Campaign)>.Success((user, campaign)); + return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId); } private static UserSummary ToUserSummary(UserAccount user) @@ -700,282 +292,6 @@ public sealed class GameService : IGameService return new(user.Id, user.Username, user.DisplayName, RoleSerializer.Parse(user.Roles)); } - private CampaignStateSnapshot ToCampaignStateSnapshot(Campaign campaign) - { - var state = GetOrCreateCampaignStateLocked(campaign.Id); - var characterVersions = state.CharacterVersions - .OrderBy(version => version.Key) - .Select(version => new CharacterStateVersion(version.Key, version.Value)) - .ToArray(); - - return new CampaignStateSnapshot(campaign.Id, state.TotalVersion, state.RosterVersion, state.LogVersion, characterVersions); - } - - private IEnumerable GetVisibleCampaignLogEntriesLocked(UserAccount user, Campaign campaign) - { - return m_RollLog - .Where(r => r.CampaignId == campaign.Id) - .Where(r => r.Visibility == RollVisibility.Public || r.RollerUserId == user.Id || campaign.GmUserId == user.Id) - .OrderBy(r => r.TimestampUtc) - .ThenBy(r => r.Id); - } - - private static RollResult ToRollResult(RollLogEntry entry, IReadOnlyList dice) - { - return new(entry.Id, entry.CampaignId, entry.CharacterId, entry.SkillId, entry.RollerUserId, entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Result, entry.Breakdown, dice, entry.TimestampUtc); - } - - private CampaignLogEntry ToLogEntry(RollLogEntry entry) - { - var dice = DeserializeDice(entry.Dice); - var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; - var skillName = ResolveLoggedSkillName(entry); - var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId); - - return new( - entry.Id, - entry.CampaignId, - entry.CharacterId, - characterName, - entry.SkillId, - skillName, - entry.RollerUserId, - rollerDisplayName, - entry.Visibility == RollVisibility.Public ? "public" : "private", - entry.Result, - entry.Breakdown, - dice, - entry.TimestampUtc); - } - - private CampaignLogListEntry ToLogListEntry(UserAccount user, Campaign campaign, RollLogEntry entry) - { - var dice = DeserializeDice(entry.Dice); - var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; - var skillName = ResolveLoggedSkillName(entry); - var loggedExpression = ResolveLoggedExpression(entry); - var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice); - - return new( - entry.Id, - characterName, - skillName, - ResolveLogRollerLabel(user, campaign, entry), - ResolveLogVisibilityLabel(user, campaign, entry), - ResolveLogVisibilityStyle(user, campaign, entry), - entry.Result, - BuildCompactLogSummary(dice), - eventBadges, - entry.TimestampUtc); - } - - private static string SerializeDice(IReadOnlyList dice) - { - return JsonSerializer.Serialize(dice, DiceJsonOptions); - } - - private static string BuildCompactLogSummary(IReadOnlyList dice) - { - if (dice.Count == 0) - return "No detail available."; - - if (dice.Any(die => IsRolemasterDieKind(die.Kind))) - return BuildRolemasterCompactLogSummary(dice); - - return string.Join(", ", dice.Select(die => die.Roll.ToString())); - } - - private static string BuildRolemasterCompactLogSummary(IReadOnlyList dice) - { - var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); - if (openEndedInitial is not null) - { - var highFollowUps = dice - .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)) - .Select(die => die.Roll.ToString()) - .ToArray(); - if (highFollowUps.Length > 0) - return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; - - var lowFollowUps = dice - .Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)) - .Select(die => die.Roll.ToString()) - .ToArray(); - if (lowFollowUps.Length > 0) - return $"({FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; - - return $"{openEndedInitial.Roll} | open-ended"; - } - - if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal))) - { - var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString())); - return $"{preview} | rolemaster"; - } - - return string.Join(", ", dice.Select(die => die.Roll.ToString())); - } - - private static bool IsRolemasterDieKind(string? kind) - { - return kind is RollDieKinds.RolemasterStandard or - RollDieKinds.RolemasterOpenEndedInitial or - RollDieKinds.RolemasterOpenEndedHigh or - RollDieKinds.RolemasterOpenEndedLowSubtract; - } - - private string ResolveLoggedSkillName(RollLogEntry entry) - { - if (entry.SkillId == CustomRollSkillId) - return CustomRollLabel; - - return m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill"; - } - - private string? ResolveLoggedExpression(RollLogEntry entry) - { - if (entry.SkillId == CustomRollSkillId) - return ExtractCustomRollExpression(entry.Breakdown); - - return m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null; - } - - private static string? ExtractCustomRollExpression(string breakdown) - { - var separatorIndex = breakdown.IndexOf(CustomRollBreakdownSeparator, StringComparison.Ordinal); - if (separatorIndex <= 0) - return null; - - return breakdown[..separatorIndex]; - } - - private static string[]? BuildCompactLogEventBadges(Campaign campaign, string? expression, IReadOnlyList dice) - { - var badges = new List(); - - switch (campaign.Ruleset) - { - case RulesetKind.D6: - AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6"); - AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1"); - break; - case RulesetKind.Dnd5e: - if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression)) - { - AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20"); - AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1"); - } - - break; - case RulesetKind.Rolemaster: - AddBadgeIfMissing( - badges, - dice.Any(die => - string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && - !die.SignedContribution.HasValue), - "rf"); - AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100"); - AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); - break; - } - - return badges.Count == 0 ? null : badges.ToArray(); - } - - private static void AddBadgeIfMissing(List badges, bool condition, string code) - { - if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal))) - return; - - badges.Add(code); - } - - private static bool IsSingleD20Expression(string expression) - { - var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); - return parsedExpression.Succeeded && - parsedExpression.Value!.DiceCount == 1 && - parsedExpression.Value.Sides == 20; - } - - private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry) - { - return CanViewCampaignLocked(user.Id, campaign.Id) && - (entry.Visibility == RollVisibility.Public || entry.RollerUserId == user.Id || campaign.GmUserId == user.Id); - } - - private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry) - { - if (entry.RollerUserId == user.Id) - return "You"; - - if (entry.RollerUserId == campaign.GmUserId) - return "GM"; - - return ResolveOwnerDisplayName(entry.RollerUserId); - } - - private static string ResolveLogVisibilityLabel(UserAccount user, Campaign campaign, RollLogEntry entry) - { - if (entry.Visibility != RollVisibility.Private) - return "Public"; - - if (entry.RollerUserId == user.Id) - return "Private (you)"; - - return campaign.GmUserId == user.Id ? "Private (GM view)" : "Private"; - } - - private static string ResolveLogVisibilityStyle(UserAccount user, Campaign campaign, RollLogEntry entry) - { - if (entry.Visibility != RollVisibility.Private) - return "public"; - - if (entry.RollerUserId == user.Id) - return "private-self"; - - return campaign.GmUserId == user.Id ? "private-gm" : "private-generic"; - } - - private string ResolveOwnerDisplayName(Guid ownerUserId) - { - return m_UsersById.TryGetValue(ownerUserId, out var owner) && !string.IsNullOrWhiteSpace(owner.DisplayName) - ? owner.DisplayName - : "Unknown owner"; - } - - private static IReadOnlyList DeserializeDice(string serializedDice) - { - if (string.IsNullOrWhiteSpace(serializedDice)) - return []; - - try - { - return JsonSerializer.Deserialize>(serializedDice, DiceJsonOptions) ?? []; - } - catch (JsonException) - { - return []; - } - } - - private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) - { - return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; - } - - private bool CanViewCampaignLocked(Guid userId, Guid campaignId) - { - if (m_UsersById.TryGetValue(userId, out var user) && UserHasRoleLocked(user, UserRoles.Admin)) - return true; - - var campaign = m_CampaignsById[campaignId]; - if (campaign.GmUserId == userId) - return true; - - return m_CharactersById.Values.Any(c => c.CampaignId.HasValue && c.CampaignId.Value == campaignId && c.OwnerUserId == userId); - } - private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId) { campaignId = Guid.Empty; @@ -1135,30 +451,16 @@ public sealed class GameService : IGameService m_PersistenceService.PersistStateLocked(); } - private static int NormalizeCampaignLogPageSize(int? limit) - { - if (!limit.HasValue) - return CampaignLogLivePageSize; - - return Math.Clamp(limit.Value, 1, CampaignLogLivePageSize); - } - - private const int CampaignLogHistoryWindowSize = 100; - private const int CampaignLogLivePageSize = 25; - private const string CustomRollBreakdownSeparator = " => "; - private static readonly Guid CustomRollSkillId = Guid.Empty; - private const string CustomRollLabel = "Custom roll"; - private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions(); private readonly Dictionary m_CampaignsById; private readonly Dictionary m_CampaignStateById; private readonly GameCampaignService m_CampaignService; private readonly GameCharacterService m_CharacterService; private readonly Dictionary m_CharactersById; private readonly GameAuthService m_AuthService; - private readonly IDiceRoller m_DiceRoller; private readonly object m_Gate; private readonly GamePersistenceService m_PersistenceService; private readonly List m_RollLog; + private readonly GameRollService m_RollService; private readonly Dictionary m_SessionsByToken; private readonly GameSkillService m_SkillService; private readonly Dictionary m_SkillGroupsById;