Extract game roll service
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
792
RpgRoller/Services/GameRollService.cs
Normal file
792
RpgRoller/Services/GameRollService.cs
Normal 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;
|
||||
}
|
||||
@@ -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<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
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);
|
||||
}
|
||||
return m_RollService.RollSkill(sessionToken, skillId, visibility);
|
||||
}
|
||||
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
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);
|
||||
}
|
||||
return m_RollService.RollCustom(sessionToken, characterId, expression, visibility);
|
||||
}
|
||||
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_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);
|
||||
}
|
||||
return m_RollService.GetCampaignLog(sessionToken, campaignId);
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null)
|
||||
{
|
||||
lock (m_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));
|
||||
}
|
||||
return m_RollService.GetCampaignLogPage(sessionToken, campaignId, afterRollId, limit);
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
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()));
|
||||
}
|
||||
return m_RollService.GetRollDetail(sessionToken, rollId);
|
||||
}
|
||||
|
||||
public ServiceResult<CampaignStateSnapshot> GetCampaignStateSnapshot(string sessionToken, Guid campaignId)
|
||||
{
|
||||
lock (m_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_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));
|
||||
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<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)
|
||||
{
|
||||
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<Guid, Campaign> m_CampaignsById;
|
||||
private readonly Dictionary<Guid, GameCampaignStateTracker> m_CampaignStateById;
|
||||
private readonly GameCampaignService m_CampaignService;
|
||||
private readonly GameCharacterService m_CharacterService;
|
||||
private readonly Dictionary<Guid, Character> 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<RollLogEntry> m_RollLog;
|
||||
private readonly GameRollService m_RollService;
|
||||
private readonly Dictionary<string, UserSession> m_SessionsByToken;
|
||||
private readonly GameSkillService m_SkillService;
|
||||
private readonly Dictionary<Guid, SkillGroup> m_SkillGroupsById;
|
||||
|
||||
Reference in New Issue
Block a user