Extract game roll service

This commit is contained in:
2026-04-04 23:41:15 +02:00
parent 9479b2e2f3
commit ad4241aaaf
3 changed files with 801 additions and 706 deletions

View File

@@ -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/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/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/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: Frontend:

View File

@@ -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<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<RollResult>.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<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.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<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_StateStore.CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<RollResult>.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<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.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<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
.TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
}
public ServiceResult<CampaignLogPage> 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<CampaignLogPage>.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<CampaignLogPage>.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<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
}
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
if (newEntries.Length == 0)
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false));
if (newEntries.Length > pageSize)
{
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
}
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
}
}
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{
lock (m_StateStore.Gate)
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
var entry = m_StateStore.RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_StateStore.CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray()));
}
}
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{
lock (m_StateStore.Gate)
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignStateSnapshot>.Success(ToCampaignStateSnapshot(context.Value!.Campaign));
}
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollDieResult> 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<RollDieResult> 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<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_DiceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult>
{
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<RollDieResult> 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<RollDieResult>(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<int>(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<int> RollRolemasterHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
{
var followUpRolls = new List<int>();
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<int> 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<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
if (subtractFollowUps)
{
var segments = new List<string> { $"({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<RollResult> RecordRollLocked(
UserAccount user,
Campaign campaign,
Character character,
Guid skillId,
RollVisibility visibility,
(int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollResult>.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<RollLogEntry> 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<RollDieResult> 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<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> 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<RollDieResult> 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<RollDieResult> dice)
{
var badges = new List<string>();
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<string> 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<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
return [];
try
{
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(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;
}

View File

@@ -1,4 +1,3 @@
using System.Text.Json;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RpgRoller.Contracts; using RpgRoller.Contracts;
@@ -26,8 +25,8 @@ public sealed class GameService : IGameService
m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService); m_AuthService = new(m_StateStore, passwordHasher, m_PersistenceService);
m_CampaignService = new(m_StateStore, m_PersistenceService); m_CampaignService = new(m_StateStore, m_PersistenceService);
m_CharacterService = 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_SkillService = new(m_StateStore, m_PersistenceService);
m_DiceRoller = diceRoller;
LoadStateFromDatabase(); LoadStateFromDatabase();
} }
@@ -255,439 +254,32 @@ public sealed class GameService : IGameService
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
{ {
lock (m_Gate) return m_RollService.RollSkill(sessionToken, skillId, visibility);
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_SkillsById.TryGetValue(skillId, out var skill))
return ServiceResult<RollResult>.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<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can roll this skill.");
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, skill.DiceRollDefinition);
if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.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<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility) public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
{ {
lock (m_Gate) return m_RollService.RollCustom(sessionToken, characterId, expression, visibility);
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
if (!m_CharactersById.TryGetValue(characterId, out var character))
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
if (!CanEditCharacterLocked(user.Id, character, campaign))
return ServiceResult<RollResult>.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<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
var parsedVisibility = RollVisibilityParser.Parse(visibility);
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.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<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) return m_RollService.GetCampaignLog(sessionToken, campaignId);
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Failure(context.Error!.Code, context.Error.Message);
var (user, campaign) = context.Value!;
var entries = GetVisibleCampaignLogEntriesLocked(user, campaign)
.TakeLast(CampaignLogHistoryWindowSize)
.Select(ToLogEntry)
.ToArray();
return ServiceResult<IReadOnlyList<CampaignLogEntry>>.Success(entries);
}
} }
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
{ {
lock (m_Gate) return m_RollService.GetCampaignLogPage(sessionToken, campaignId, afterRollId, limit);
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignLogPage>.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<CampaignLogPage>.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<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries.LastOrDefault()?.RollId, visibleEntries.Length > pageSize, true));
}
var newEntries = visibleEntries.Skip(afterIndex + 1).ToArray();
if (newEntries.Length == 0)
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage([], afterRollId, false, false));
if (newEntries.Length > pageSize)
{
var replacementEntries = newEntries.TakeLast(pageSize).Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(replacementEntries, replacementEntries[^1].RollId, true, true));
}
var appendedEntries = newEntries.Select(entry => ToLogListEntry(user, campaign, entry)).ToArray();
return ServiceResult<CampaignLogPage>.Success(new CampaignLogPage(appendedEntries, appendedEntries[^1].RollId, false, false));
}
} }
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
{ {
lock (m_Gate) return m_RollService.GetRollDetail(sessionToken, rollId);
{
var user = ResolveUserLocked(sessionToken);
if (user is null)
return ServiceResult<CampaignRollDetail>.Failure("unauthorized", "You must be logged in.");
var entry = m_RollLog.FirstOrDefault(candidate => candidate.Id == rollId);
if (entry is null)
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
if (!m_CampaignsById.TryGetValue(entry.CampaignId, out var campaign) || !CanViewRollLocked(user, campaign, entry))
return ServiceResult<CampaignRollDetail>.Failure("roll_not_found", "Roll was not found.");
return ServiceResult<CampaignRollDetail>.Success(new CampaignRollDetail(entry.Id, entry.Breakdown, DeserializeDice(entry.Dice).ToArray()));
}
} }
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId) public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
{ {
lock (m_Gate) return m_RollService.GetCampaignStateSnapshot(sessionToken, campaignId);
{
var context = ResolveContextLocked(sessionToken, campaignId);
if (!context.Succeeded)
return ServiceResult<CampaignStateSnapshot>.Failure(context.Error!.Code, context.Error.Message);
return ServiceResult<CampaignStateSnapshot>.Success(ToCampaignStateSnapshot(context.Value!.Campaign));
}
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollDieResult> 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<RollDieResult> 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<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_DiceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult>
{
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<RollDieResult> 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<RollDieResult>(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<int>(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<int> RollRolemasterHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
{
var followUpRolls = new List<int>();
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<int> 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<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
if (subtractFollowUps)
{
var segments = new List<string> { $"({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<RollResult> RecordRollLocked(
UserAccount user,
Campaign campaign,
Character character,
Guid skillId,
RollVisibility visibility,
(int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollResult>.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));
} }
private static UserSummary ToUserSummary(UserAccount user) 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)); 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<RollLogEntry> 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<RollDieResult> 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<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> 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<RollDieResult> 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<RollDieResult> dice)
{
var badges = new List<string>();
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<string> 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<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
return [];
try
{
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(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) private bool TryGetCurrentCampaignIdLocked(UserAccount user, out Guid campaignId)
{ {
campaignId = Guid.Empty; campaignId = Guid.Empty;
@@ -1135,30 +451,16 @@ public sealed class GameService : IGameService
m_PersistenceService.PersistStateLocked(); 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<Guid, Campaign> m_CampaignsById; private readonly Dictionary<Guid, Campaign> m_CampaignsById;
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById; private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
private readonly GameCampaignService m_CampaignService; private readonly GameCampaignService m_CampaignService;
private readonly GameCharacterService m_CharacterService; private readonly GameCharacterService m_CharacterService;
private readonly Dictionary<Guid, Character> m_CharactersById; private readonly Dictionary<Guid, Character> m_CharactersById;
private readonly GameAuthService m_AuthService; private readonly GameAuthService m_AuthService;
private readonly IDiceRoller m_DiceRoller;
private readonly object m_Gate; private readonly object m_Gate;
private readonly GamePersistenceService m_PersistenceService; private readonly GamePersistenceService m_PersistenceService;
private readonly List<RollLogEntry> m_RollLog; private readonly List<RollLogEntry> m_RollLog;
private readonly GameRollService m_RollService;
private readonly Dictionary<string, UserSession> m_SessionsByToken; private readonly Dictionary<string, UserSession> m_SessionsByToken;
private readonly GameSkillService m_SkillService; private readonly GameSkillService m_SkillService;
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById; private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;