From 54d0db5f6640ee6c4503b3fd9ff3adbc7785b0b4 Mon Sep 17 00:00:00 2001 From: flashgnash Date: Tue, 4 Aug 2026 08:52:15 +0000 Subject: [PATCH] Add custom dice with text/image face overrides Adds campaign-scoped custom dice for systems that use dice with their own faces. A custom die behaves like a standard N-sided die: referenced in a formula (e.g. "1dfate") it is translated to its numeric die and treated as a number. When a die is rolled on its own, an overridden face's text or image is shown instead of the number. - CustomDie/DieFace model with formula translation + lone-die matching helpers - custom_dice table (campaign FK) and RollResult.FaceText/FaceImage columns - PlayerCharacterService.RollFor resolves custom dice and records the rolled face override - CampaignService CRUD for custom dice (DM-authorised) - CustomDiceEditor component + campaign page modal for managing dice/faces - RollFace component renders image/text/number; used in roll menu, character sheet history, character cards and home - RollMenu shows quick-insert buttons for the campaign's custom dice Co-Authored-By: Claude Opus 4.8 --- Components/CharacterCard.razor | 2 +- Components/CustomDiceEditor.razor | 170 +++++ Components/Pages/CampaignPage.razor | 11 + Components/Pages/Character.razor | 2 +- Components/Pages/Home.razor | 2 +- Components/RollFace.razor | 20 + Components/RollMenu.razor | 32 +- .../20260730212153_CustomDice.Designer.cs | 666 ++++++++++++++++++ Migrations/20260730212153_CustomDice.cs | 68 ++ Migrations/OrdisContextModelSnapshot.cs | 47 ++ Models/Campaign.cs | 2 + Models/CustomDie.cs | 81 +++ Models/RollResult.cs | 8 + OrdisContext.cs | 10 + Services/CampaignService.cs | 47 ++ Services/CharacterService.cs | 28 +- 16 files changed, 1189 insertions(+), 7 deletions(-) create mode 100644 Components/CustomDiceEditor.razor create mode 100644 Components/RollFace.razor create mode 100644 Migrations/20260730212153_CustomDice.Designer.cs create mode 100644 Migrations/20260730212153_CustomDice.cs create mode 100644 Models/CustomDie.cs diff --git a/Components/CharacterCard.razor b/Components/CharacterCard.razor index 990568a..084e57d 100644 --- a/Components/CharacterCard.razor +++ b/Components/CharacterCard.razor @@ -52,7 +52,7 @@ {
- @lastRoll.Result +
} diff --git a/Components/CustomDiceEditor.razor b/Components/CustomDiceEditor.razor new file mode 100644 index 0000000..4a72df0 --- /dev/null +++ b/Components/CustomDiceEditor.razor @@ -0,0 +1,170 @@ +@inject CampaignService CampaignService + +@* DM tool for defining custom dice whose faces can be overridden with text or images. + In formulas a custom die behaves as its numeric die (e.g. "1dfate" rolls a d6); when a + die is rolled on its own, an overridden face's text/image is shown instead of the number. *@ + +
+ @if (!Dice.Any()) + { + No custom dice yet. Add one below. + } + + @foreach (var die in Dice) + { +
+
+ + d + +
+ + + +
+ @if (_expanded == die.Id) + { +
+

+ Leave a face blank to keep its number. Text or an image URL overrides how that face is shown. +

+ + + + + + @for (int value = 1; value <= die.Sides; value++) + { + var v = value; + var face = GetFace(die, v); + + + + + + + } + +
FaceTextImage URL
@v + + + + + @if (!string.IsNullOrWhiteSpace(face.Image)) + { + @v + } +
+
+ } +
+ } + +
+ + d + + +
+ @if (!string.IsNullOrEmpty(_error)) + { + @_error + } +
+ +@code { + [Parameter] public int CampaignId { get; set; } + [Parameter] public string? DiscordId { get; set; } + + private List Dice { get; set; } = new(); + private Guid? _expanded; + private string _newName = ""; + private int _newSides = 6; + private string? _error; + + protected override async Task OnInitializedAsync() + { + if (CampaignId > 0) + Dice = await CampaignService.GetCustomDiceAsync(CampaignId); + } + + private void Toggle(Guid id) => _expanded = _expanded == id ? null : id; + + private static readonly System.Text.RegularExpressions.Regex NameRegex = + new(@"^[A-Za-z][A-Za-z0-9_]*$", System.Text.RegularExpressions.RegexOptions.Compiled); + + private void SetSides(CustomDie die, string? raw) + { + if (int.TryParse(raw, out var s) && s >= 1) + die.Sides = Math.Min(s, 1000); + } + + // Faces are stored as JSON, so edit a working copy and write it back. + private DieFace GetFace(CustomDie die, int value) + { + var faces = die.Faces; + return faces.FirstOrDefault(f => f.Value == value) ?? new DieFace { Value = value }; + } + + private void SetFace(CustomDie die, int value, string? text = null, string? image = null) + { + var faces = die.Faces; + var face = faces.FirstOrDefault(f => f.Value == value); + if (face == null) + { + face = new DieFace { Value = value }; + faces.Add(face); + } + if (text != null) face.Text = string.IsNullOrWhiteSpace(text) ? null : text; + if (image != null) face.Image = string.IsNullOrWhiteSpace(image) ? null : image; + + // Drop faces that no longer carry an override to keep the JSON tidy. + die.Faces = faces.Where(f => f.HasOverride).ToList(); + } + + private async Task AddAsync() + { + _error = null; + if (!NameRegex.IsMatch(_newName)) + { + _error = "Name must start with a letter and contain only letters, numbers or underscores."; + return; + } + if (Dice.Any(d => string.Equals(d.Name, _newName, StringComparison.OrdinalIgnoreCase))) + { + _error = "A die with that name already exists."; + return; + } + + var die = new CustomDie { Name = _newName, Sides = Math.Clamp(_newSides, 1, 1000), CampaignId = CampaignId }; + var saved = await CampaignService.SaveCustomDieAsync(die, DiscordId ?? ""); + Dice.Add(saved); + _expanded = saved.Id; + _newName = ""; + _newSides = 6; + } + + private async Task SaveAsync(CustomDie die) + { + _error = null; + if (!NameRegex.IsMatch(die.Name)) + { + _error = "Name must start with a letter and contain only letters, numbers or underscores."; + return; + } + await CampaignService.SaveCustomDieAsync(die, DiscordId ?? ""); + } + + private async Task DeleteAsync(CustomDie die) + { + await CampaignService.DeleteCustomDieAsync(die.Id, DiscordId ?? ""); + Dice.Remove(die); + if (_expanded == die.Id) _expanded = null; + } +} diff --git a/Components/Pages/CampaignPage.razor b/Components/Pages/CampaignPage.razor index 3dcda2d..5cda75e 100644 --- a/Components/Pages/CampaignPage.razor +++ b/Components/Pages/CampaignPage.razor @@ -19,6 +19,12 @@ + +
+ +
+
+
@@ -45,6 +51,11 @@ Default die: @Campaign.DefaultRollDie } Modifier formula: @(Campaign.StatModifierFormula ?? "5e") + @if (IsDM) { + + }
diff --git a/Components/Pages/Character.razor b/Components/Pages/Character.razor index f4c0781..4263e38 100644 --- a/Components/Pages/Character.razor +++ b/Components/Pages/Character.razor @@ -148,7 +148,7 @@ viewBox="0 0 16 16"> - @roll.Result + diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index b68664d..d858aea 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -124,7 +124,7 @@ {
- @lastRoll.Result +
} diff --git a/Components/RollFace.razor b/Components/RollFace.razor new file mode 100644 index 0000000..4afca52 --- /dev/null +++ b/Components/RollFace.razor @@ -0,0 +1,20 @@ +@* Renders a roll outcome. When the roll was a lone custom die whose face is overridden, + shows the face's image or text; otherwise falls back to the numeric result. *@ +@if (!string.IsNullOrWhiteSpace(Roll?.FaceImage)) +{ + @(Roll.FaceText ?? Roll.Result.ToString()) +} +else if (!string.IsNullOrWhiteSpace(Roll?.FaceText)) +{ + @Roll!.FaceText +} +else +{ + @(Roll?.Result) +} + +@code { + [Parameter] public RollResult? Roll { get; set; } + [Parameter] public string Size { get; set; } = "2em"; +} diff --git a/Components/RollMenu.razor b/Components/RollMenu.razor index f0ab125..f5cc40d 100644 --- a/Components/RollMenu.razor +++ b/Components/RollMenu.razor @@ -1,5 +1,6 @@ @rendermode InteractiveServer @inject PlayerCharacterService CharacterService +@inject CampaignService CampaignService @inject DiscordService DiscordService @inject ILogger Logger @@ -43,6 +44,21 @@ } + @if(CustomDice.Any()) { +
+

Custom Dice

+
+ @foreach (var die in CustomDice) + { + + } +
+
+
+ } + @if(Character.SavedRollsDict != null) {

Custom Rolls

@@ -83,7 +99,7 @@

@ErrorMessage

} -

@LastResult?.Result

+

@if(LastResult?.Result != null) { (@foreach (var g in LastResult?.Rolls.GroupBy(r => r.Expression)) { @@ -150,17 +166,29 @@ private PlayerCharacter? _prevCharacter; + public List CustomDice { get; set; } = new(); + protected override void OnInitialized() { ClearRoll(); } - protected override void OnParametersSet() { + protected override async Task OnParametersSetAsync() { if (Character != _prevCharacter) { _prevCharacter = Character; ClearRoll(); + + CustomDice = Character?.Campaign?.Id is int campaignId + ? await CampaignService.GetCustomDiceAsync(campaignId) + : new(); } } + private void AddDie(CustomDie die) + { + var token = $"1d{die.Name}"; + RollText = string.IsNullOrWhiteSpace(RollText) ? token : $"{RollText}+{token}"; + } + private string Truncate(string input, int length = 3) { return input.Substring(0, Math.Min(length, input.Length)); diff --git a/Migrations/20260730212153_CustomDice.Designer.cs b/Migrations/20260730212153_CustomDice.Designer.cs new file mode 100644 index 0000000..27e9970 --- /dev/null +++ b/Migrations/20260730212153_CustomDice.Designer.cs @@ -0,0 +1,666 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Ordis.Migrations +{ + [DbContext(typeof(OrdisContext))] + [Migration("20260730212153_CustomDice")] + partial class CustomDice + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("BuffTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("EffectsJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("effects"); + + b.Property("Icon") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("SourceTemplateId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.HasIndex("OwnerId"); + + b.HasIndex("SourceTemplateId"); + + b.ToTable("buff_templates", (string)null); + }); + + modelBuilder.Entity("Campaign", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DefaultRollDie") + .HasColumnType("text"); + + b.Property("DungeonMasterId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatModifierFormula") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DungeonMasterId"); + + b.ToTable("Campaigns"); + }); + + modelBuilder.Entity("CharacterBuff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PlayerCharacterId") + .HasColumnType("integer"); + + b.Property("Stacks") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayerCharacterId"); + + b.HasIndex("TemplateId"); + + b.ToTable("character_buffs", (string)null); + }); + + modelBuilder.Entity("CharacterPreset", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("GaugesJson") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("StatBlockJson") + .HasColumnType("text") + .HasColumnName("stat_block"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("CharacterPresets", (string)null); + }); + + modelBuilder.Entity("CustomDie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("FacesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("faces"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sides") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("custom_dice", (string)null); + }); + + modelBuilder.Entity("Gauge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Colour") + .HasColumnType("text"); + + b.Property("GaugeType") + .HasColumnType("integer"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("Max") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerCharacterId") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerCharacterId"); + + b.ToTable("Gauges"); + }); + + modelBuilder.Entity("IndividualRollResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expression") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "expression"); + + b.Property("Result") + .HasColumnType("real") + .HasAnnotation("Relational:JsonPropertyName", "result"); + + b.Property("RollResultId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RollResultId"); + + b.ToTable("PastRolls"); + + b.HasAnnotation("Relational:JsonPropertyName", "rolls"); + }); + + modelBuilder.Entity("Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerCharacterId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerCharacterId"); + + b.ToTable("Item"); + }); + + modelBuilder.Entity("PlayerCharacter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("IsNpc") + .HasColumnType("boolean"); + + b.Property("Mana") + .HasColumnType("integer") + .HasColumnName("mana"); + + b.Property("ManaReadoutChannelId") + .HasColumnType("text") + .HasColumnName("mana_readout_channel_id"); + + b.Property("ManaReadoutMessageId") + .HasColumnType("text") + .HasColumnName("mana_readout_message_id"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RollServerId") + .HasColumnType("text") + .HasColumnName("roll_server_id"); + + b.Property("SavedRolls") + .HasColumnType("text") + .HasColumnName("saved_rolls"); + + b.Property("SpellBlock") + .HasColumnType("text") + .HasColumnName("spell_block"); + + b.Property("SpellBlockChannelId") + .HasColumnType("text") + .HasColumnName("spell_block_channel_id"); + + b.Property("SpellBlockHash") + .HasColumnType("text") + .HasColumnName("spell_block_hash"); + + b.Property("SpellBlockMessageId") + .HasColumnType("text") + .HasColumnName("spell_block_message_id"); + + b.Property("StatBlockChannelId") + .HasColumnType("text") + .HasColumnName("stat_block_channel_id"); + + b.Property("StatBlockHash") + .HasColumnType("text") + .HasColumnName("stat_block_hash"); + + b.Property("StatBlockJson") + .HasColumnType("text") + .HasColumnName("stat_block"); + + b.Property("StatBlockMessageId") + .HasColumnType("text") + .HasColumnName("stat_block_message_id"); + + b.Property("StatBlockServerId") + .HasColumnType("text") + .HasColumnName("stat_block_server_id"); + + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("new_characters_pkey"); + + b.HasIndex("CampaignId"); + + b.ToTable("characters", (string)null); + }); + + modelBuilder.Entity("Roll", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RollString") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpellId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.HasIndex("SpellId"); + + b.ToTable("Roll"); + }); + + modelBuilder.Entity("RollResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FaceImage") + .HasColumnType("text"); + + b.Property("FaceText") + .HasColumnType("text"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "message"); + + b.Property("PlayerCharacterId") + .HasColumnType("integer"); + + b.Property("Result") + .HasColumnType("real") + .HasAnnotation("Relational:JsonPropertyName", "result"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PlayerCharacterId"); + + b.ToTable("RollResults"); + }); + + modelBuilder.Entity("Server", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("DefaultRollChannel") + .HasColumnType("text") + .HasColumnName("default_roll_channel"); + + b.Property("DefaultRollServer") + .HasColumnType("text") + .HasColumnName("default_roll_server"); + + b.HasKey("Id") + .HasName("servers_pkey"); + + b.ToTable("servers", (string)null); + }); + + modelBuilder.Entity("Spell", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PlayerCharacterId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerCharacterId"); + + b.ToTable("Spell"); + }); + + modelBuilder.Entity("User", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Count") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("count"); + + b.Property("SelectedCharacter") + .HasColumnType("integer") + .HasColumnName("selected_character"); + + b.Property("SelectedCharacterId") + .HasColumnType("text") + .HasColumnName("selected_character_id"); + + b.Property("Username") + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("BuffTemplate", b => + { + b.HasOne("Campaign", "Campaign") + .WithMany() + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("User", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BuffTemplate", "SourceTemplate") + .WithMany() + .HasForeignKey("SourceTemplateId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Campaign"); + + b.Navigation("Owner"); + + b.Navigation("SourceTemplate"); + }); + + modelBuilder.Entity("Campaign", b => + { + b.HasOne("User", "DungeonMaster") + .WithMany() + .HasForeignKey("DungeonMasterId"); + + b.Navigation("DungeonMaster"); + }); + + modelBuilder.Entity("CharacterBuff", b => + { + b.HasOne("PlayerCharacter", "PlayerCharacter") + .WithMany("Buffs") + .HasForeignKey("PlayerCharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BuffTemplate", "Template") + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerCharacter"); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("CharacterPreset", b => + { + b.HasOne("Campaign", "Campaign") + .WithMany("Presets") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("CustomDie", b => + { + b.HasOne("Campaign", "Campaign") + .WithMany("CustomDice") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Gauge", b => + { + b.HasOne("PlayerCharacter", null) + .WithMany("Gauges") + .HasForeignKey("PlayerCharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("IndividualRollResult", b => + { + b.HasOne("RollResult", null) + .WithMany("Rolls") + .HasForeignKey("RollResultId"); + }); + + modelBuilder.Entity("Item", b => + { + b.HasOne("PlayerCharacter", null) + .WithMany("Inventory") + .HasForeignKey("PlayerCharacterId"); + }); + + modelBuilder.Entity("PlayerCharacter", b => + { + b.HasOne("Campaign", "Campaign") + .WithMany("Players") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Campaign"); + }); + + modelBuilder.Entity("Roll", b => + { + b.HasOne("Item", null) + .WithMany("Rolls") + .HasForeignKey("ItemId"); + + b.HasOne("Spell", null) + .WithMany("Rolls") + .HasForeignKey("SpellId"); + }); + + modelBuilder.Entity("RollResult", b => + { + b.HasOne("PlayerCharacter", null) + .WithMany("Rolls") + .HasForeignKey("PlayerCharacterId"); + }); + + modelBuilder.Entity("Spell", b => + { + b.HasOne("PlayerCharacter", null) + .WithMany("Spells") + .HasForeignKey("PlayerCharacterId"); + }); + + modelBuilder.Entity("Campaign", b => + { + b.Navigation("CustomDice"); + + b.Navigation("Players"); + + b.Navigation("Presets"); + }); + + modelBuilder.Entity("Item", b => + { + b.Navigation("Rolls"); + }); + + modelBuilder.Entity("PlayerCharacter", b => + { + b.Navigation("Buffs"); + + b.Navigation("Gauges"); + + b.Navigation("Inventory"); + + b.Navigation("Rolls"); + + b.Navigation("Spells"); + }); + + modelBuilder.Entity("RollResult", b => + { + b.Navigation("Rolls"); + }); + + modelBuilder.Entity("Spell", b => + { + b.Navigation("Rolls"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260730212153_CustomDice.cs b/Migrations/20260730212153_CustomDice.cs new file mode 100644 index 0000000..75e17f2 --- /dev/null +++ b/Migrations/20260730212153_CustomDice.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Ordis.Migrations +{ + /// + public partial class CustomDice : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FaceImage", + table: "RollResults", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "FaceText", + table: "RollResults", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "custom_dice", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "text", nullable: false), + Sides = table.Column(type: "integer", nullable: false), + faces = table.Column(type: "text", nullable: false), + CampaignId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_custom_dice", x => x.Id); + table.ForeignKey( + name: "FK_custom_dice_Campaigns_CampaignId", + column: x => x.CampaignId, + principalTable: "Campaigns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_custom_dice_CampaignId", + table: "custom_dice", + column: "CampaignId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "custom_dice"); + + migrationBuilder.DropColumn( + name: "FaceImage", + table: "RollResults"); + + migrationBuilder.DropColumn( + name: "FaceText", + table: "RollResults"); + } + } +} diff --git a/Migrations/OrdisContextModelSnapshot.cs b/Migrations/OrdisContextModelSnapshot.cs index 531f0ef..ff1c3e1 100644 --- a/Migrations/OrdisContextModelSnapshot.cs +++ b/Migrations/OrdisContextModelSnapshot.cs @@ -143,6 +143,34 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CharacterPresets", (string)null); }); + modelBuilder.Entity("CustomDie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CampaignId") + .HasColumnType("integer"); + + b.Property("FacesJson") + .IsRequired() + .HasColumnType("text") + .HasColumnName("faces"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sides") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CampaignId"); + + b.ToTable("custom_dice", (string)null); + }); + modelBuilder.Entity("Gauge", b => { b.Property("Id") @@ -357,6 +385,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("FaceImage") + .HasColumnType("text"); + + b.Property("FaceText") + .HasColumnType("text"); + b.Property("Message") .IsRequired() .HasColumnType("text") @@ -518,6 +552,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Campaign"); }); + modelBuilder.Entity("CustomDie", b => + { + b.HasOne("Campaign", "Campaign") + .WithMany("CustomDice") + .HasForeignKey("CampaignId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Campaign"); + }); + modelBuilder.Entity("Gauge", b => { b.HasOne("PlayerCharacter", null) @@ -578,6 +623,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Campaign", b => { + b.Navigation("CustomDice"); + b.Navigation("Players"); b.Navigation("Presets"); diff --git a/Models/Campaign.cs b/Models/Campaign.cs index ae20370..7af168b 100644 --- a/Models/Campaign.cs +++ b/Models/Campaign.cs @@ -10,6 +10,8 @@ public class Campaign public ICollection? Presets { get; set; } + public ICollection? CustomDice { get; set; } + public string? DefaultRollDie { get; set; } public string? StatModifierFormula { get; set; } } diff --git a/Models/CustomDie.cs b/Models/CustomDie.cs new file mode 100644 index 0000000..4b45c4f --- /dev/null +++ b/Models/CustomDie.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json; +using System.Text.RegularExpressions; + +// A single face of a custom die. The face's numeric Value is what a formula sees; +// Text and/or Image are optional cosmetic overrides shown when the die is rolled on its own. +public class DieFace +{ + public int Value { get; set; } + public string? Text { get; set; } + public string? Image { get; set; } + + public bool HasOverride => !string.IsNullOrWhiteSpace(Text) || !string.IsNullOrWhiteSpace(Image); +} + +// A campaign-scoped custom die (e.g. Fate/Fudge dice, symbol dice) for systems that use +// dice with their own faces. Mechanically it behaves like a standard N-sided die: in a +// formula it is treated as a number, but individual faces can be overridden with text or +// an image, which is displayed when the die is rolled by itself. +public class CustomDie +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + // Token used to reference the die in a formula, e.g. "fate" -> written as "1dfate". + // Must start with a letter so it never collides with standard numeric dice (d20, 2d6, ...). + public string Name { get; set; } = ""; + + public int Sides { get; set; } = 6; + + [Column("faces")] + public string FacesJson { get; set; } = "[]"; + + [NotMapped] + public List Faces + { + get => JsonSerializer.Deserialize>(FacesJson ?? "[]") ?? new(); + set => FacesJson = JsonSerializer.Serialize(value); + } + + public int CampaignId { get; set; } + public Campaign? Campaign { get; set; } + + // The override (if any) for a rolled value. + public DieFace? FaceFor(int value) => Faces.FirstOrDefault(f => f.Value == value && f.HasOverride); + + private static Regex TokenRegex(string name) => + new(@"\b(\d*)d" + Regex.Escape(name) + @"\b", RegexOptions.IgnoreCase); + + // Replaces custom-die references (e.g. "1dfate") with their underlying numeric die + // (e.g. "1d6") so the roll can be evaluated by the standard roll service. + public static string TranslateFormula(string? formula, IEnumerable dice) + { + if (string.IsNullOrWhiteSpace(formula)) return formula ?? ""; + var result = formula; + foreach (var die in dice) + { + if (string.IsNullOrWhiteSpace(die.Name)) continue; + result = TokenRegex(die.Name).Replace(result, m => $"{m.Groups[1].Value}d{die.Sides}"); + } + return result; + } + + // If the whole formula is a single custom die used on its own (e.g. "dfate" or "1dfate"), + // returns that die so its rolled face can be displayed. Anything more (extra dice, modifiers, + // or a count > 1) means it is being used as a number, so this returns null. + public static CustomDie? MatchLone(string? formula, IEnumerable dice) + { + if (string.IsNullOrWhiteSpace(formula)) return null; + var trimmed = formula.Trim(); + foreach (var die in dice) + { + if (string.IsNullOrWhiteSpace(die.Name)) continue; + var m = Regex.Match(trimmed, @"^(\d*)d" + Regex.Escape(die.Name) + @"$", RegexOptions.IgnoreCase); + if (m.Success && (m.Groups[1].Value is "" or "1")) + return die; + } + return null; + } +} diff --git a/Models/RollResult.cs b/Models/RollResult.cs index ba010e6..17c1b69 100644 --- a/Models/RollResult.cs +++ b/Models/RollResult.cs @@ -25,4 +25,12 @@ public class RollResult [JsonPropertyName("message")] public string Message { get; set; } + + // When a lone custom die is rolled, the rolled face's overridden text/image (if any). + // Not part of the roll-service payload — populated after the roll and persisted for display. + [JsonIgnore] + public string? FaceText { get; set; } + + [JsonIgnore] + public string? FaceImage { get; set; } } diff --git a/OrdisContext.cs b/OrdisContext.cs index 06ad42b..5c86f2f 100644 --- a/OrdisContext.cs +++ b/OrdisContext.cs @@ -20,6 +20,8 @@ public OrdisContext(DbContextOptions options) public virtual DbSet CharacterPresets { get; set; } + public virtual DbSet CustomDice { get; set; } + public virtual DbSet Users { get; set; } public virtual DbSet BuffTemplates { get; set; } @@ -59,6 +61,14 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasMany(e => e.Players).WithOne(e => e.Campaign).OnDelete(DeleteBehavior.Cascade); entity.HasMany(e => e.Presets).WithOne(e => e.Campaign).HasForeignKey(e => e.CampaignId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(e => e.CustomDice).WithOne(e => e.Campaign).HasForeignKey(e => e.CampaignId).OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("custom_dice"); + entity.HasKey(e => e.Id); + entity.Property(e => e.FacesJson).HasColumnName("faces"); }); modelBuilder.Entity(entity => diff --git a/Services/CampaignService.cs b/Services/CampaignService.cs index f17275b..ac668f9 100644 --- a/Services/CampaignService.cs +++ b/Services/CampaignService.cs @@ -265,6 +265,51 @@ public async Task> CreateCharactersFromPresetAsync(int cam liveUpdates.NotifyCampaignChanged(campaignId); return created; } + // -- Custom dice -- + + public async Task> GetCustomDiceAsync(int campaignId) + { + await using var db = await dbFactory.CreateDbContextAsync(); + return await db.CustomDice + .Where(d => d.CampaignId == campaignId) + .OrderBy(d => d.Name) + .ToListAsync(); + } + + public async Task SaveCustomDieAsync(CustomDie die, string callerDiscordId) + { + await using var db = await dbFactory.CreateDbContextAsync(); + await AssertIsDmAsync(db, die.CampaignId, callerDiscordId); + + var existing = die.Id != Guid.Empty ? await db.CustomDice.FindAsync(die.Id) : null; + if (existing == null) + { + if (die.Id == Guid.Empty) die.Id = Guid.NewGuid(); + db.CustomDice.Add(die); + } + else + { + existing.Name = die.Name; + existing.Sides = die.Sides; + existing.FacesJson = die.FacesJson; + } + + await db.SaveChangesAsync(); + liveUpdates.NotifyCampaignChanged(die.CampaignId); + return existing ?? die; + } + + public async Task DeleteCustomDieAsync(Guid dieId, string callerDiscordId) + { + await using var db = await dbFactory.CreateDbContextAsync(); + var die = await db.CustomDice.FindAsync(dieId); + if (die == null) return; + await AssertIsDmAsync(db, die.CampaignId, callerDiscordId); + db.CustomDice.Remove(die); + await db.SaveChangesAsync(); + liveUpdates.NotifyCampaignChanged(die.CampaignId); + } + public async Task DeleteAsync(int campaignId, string callerDiscordId) { await using var db = await dbFactory.CreateDbContextAsync(); await AssertIsDmAsync(db, campaignId, callerDiscordId); @@ -293,6 +338,8 @@ public async Task DeleteAsync(int campaignId, string callerDiscordId) { .Include(g => g.Presets) + .Include(g => g.CustomDice) + .SingleOrDefaultAsync(c => c.Id == id); } diff --git a/Services/CharacterService.cs b/Services/CharacterService.cs index d8e8a96..e0fee19 100644 --- a/Services/CharacterService.cs +++ b/Services/CharacterService.cs @@ -25,8 +25,14 @@ public async Task UpdateGaugeAsync(Gauge gauge) public async Task RollFor(PlayerCharacter character, String rollFormula) { + // Resolve any custom dice defined on the character's campaign. In a formula a custom + // die is treated as its underlying numeric die; if rolled on its own we later show + // the rolled face's text/image override instead of the number. + var customDice = await GetCustomDiceForCharacterAsync(character.Id); + var resolvedFormula = CustomDie.TranslateFormula(rollFormula, customDice); + var response = await httpClient.PostAsync( - $"http://localhost:3000/roll/{character.Id}/{rollFormula}", + $"http://localhost:3000/roll/{character.Id}/{resolvedFormula}", null ); @@ -48,12 +54,30 @@ public async Task RollFor(PlayerCharacter character, String rollForm throw new InvalidRollException(); } if (rollResult == null) throw new InvalidRollException(); - + + // If the whole roll was a single custom die, surface the rolled face's override. + var loneDie = CustomDie.MatchLone(rollFormula, customDice); + var face = loneDie?.FaceFor((int)Math.Round(rollResult.Result)); + if (face != null) + { + rollResult.FaceText = face.Text; + rollResult.FaceImage = face.Image; + } + await SaveRollAsync(character.Id, rollResult); return rollResult; } + private async Task> GetCustomDiceForCharacterAsync(int characterId) + { + using var db = await dbFactory.CreateDbContextAsync(); + var campaign = await db.Campaigns + .Include(c => c.CustomDice) + .FirstOrDefaultAsync(c => c.Players!.Any(p => p.Id == characterId)); + return campaign?.CustomDice?.ToList() ?? new(); + } + public async Task SaveRollAsync(int id, RollResult rollResult) { using var db = await dbFactory.CreateDbContextAsync();