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/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:
|
||||||
|
|
||||||
|
|||||||
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.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;
|
||||||
|
|||||||
Reference in New Issue
Block a user