Implement phase 6 critical effect normalization

This commit is contained in:
2026-03-14 11:31:13 +01:00
parent 35c250666f
commit 521f0ff8d5
29 changed files with 932 additions and 55 deletions

View File

@@ -49,6 +49,43 @@
<div class="callout">
<h4>Affix Text</h4>
<p class="stacked-copy">@Result.AffixText</p>
@if (Result.Effects.Count > 0)
{
<div class="effect-stack">
<h5>Parsed Affixes</h5>
<ul class="effect-list">
@foreach (var effect in Result.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
</div>
}
</div>
}
else if (Result.Effects.Count > 0)
{
<div class="callout">
<h4>Parsed Affixes</h4>
<ul class="effect-list">
@foreach (var effect in Result.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
</div>
}
@@ -71,6 +108,22 @@
{
<p class="stacked-copy branch-affix">@branch.AffixText</p>
}
@if (branch.Effects.Count > 0)
{
<ul class="effect-list branch-effects">
@foreach (var effect in branch.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
}
</section>
}
</div>
@@ -116,4 +169,21 @@
return value;
}
}
private static string FormatEffect(CriticalEffectLookupResponse effect) =>
effect.EffectCode switch
{
"direct_hits" when effect.ValueInteger is not null => $"{effect.ValueInteger} direct hits",
"must_parry_rounds" when effect.DurationRounds is not null => $"Must parry for {FormatRounds(effect.DurationRounds.Value)}",
"no_parry_rounds" when effect.DurationRounds is not null => $"No parry for {FormatRounds(effect.DurationRounds.Value)}",
"stunned_rounds" when effect.DurationRounds is not null => $"Stunned for {FormatRounds(effect.DurationRounds.Value)}",
"bleed_per_round" when effect.PerRound is not null => $"Bleeds {effect.PerRound} hits per round",
"foe_penalty" when effect.Modifier is not null => $"Foe penalty {effect.Modifier:+#;-#;0}",
"attacker_bonus_next_round" when effect.Modifier is not null => $"Attacker bonus next round {effect.Modifier:+#;-#;0}",
"power_point_modifier" when !string.IsNullOrWhiteSpace(effect.ValueExpression) => $"Foe power-point modifier {effect.ValueExpression}",
_ => effect.EffectCode.Replace('_', ' ')
};
private static string FormatRounds(int value) =>
value == 1 ? "1 round" : $"{value} rounds";
}

View File

@@ -15,6 +15,7 @@ public sealed class RolemasterDbContext(DbContextOptions<RolemasterDbContext> op
public DbSet<CriticalRollBand> CriticalRollBands => Set<CriticalRollBand>();
public DbSet<CriticalResult> CriticalResults => Set<CriticalResult>();
public DbSet<CriticalBranch> CriticalBranches => Set<CriticalBranch>();
public DbSet<CriticalEffect> CriticalEffects => Set<CriticalEffect>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -87,5 +88,17 @@ public sealed class RolemasterDbContext(DbContextOptions<RolemasterDbContext> op
entity.Property(item => item.BranchKind).HasMaxLength(32);
entity.Property(item => item.ConditionKey).HasMaxLength(128);
});
modelBuilder.Entity<CriticalEffect>(entity =>
{
entity.HasIndex(item => item.EffectCode);
entity.HasIndex(item => item.CriticalResultId);
entity.HasIndex(item => item.CriticalBranchId);
entity.Property(item => item.EffectCode).HasMaxLength(64);
entity.Property(item => item.Target).HasMaxLength(32);
entity.Property(item => item.ValueExpression).HasMaxLength(128);
entity.Property(item => item.BodyPart).HasMaxLength(64);
entity.Property(item => item.SourceType).HasMaxLength(32);
});
}
}

View File

@@ -39,5 +39,52 @@ public static class RolemasterDbSchemaUpgrader
ON "CriticalBranches" ("CriticalResultId", "SortOrder");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "CriticalEffects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CriticalEffects" PRIMARY KEY AUTOINCREMENT,
"CriticalResultId" INTEGER NULL,
"CriticalBranchId" INTEGER NULL,
"EffectCode" TEXT NOT NULL,
"Target" TEXT NULL,
"ValueInteger" INTEGER NULL,
"ValueDecimal" TEXT NULL,
"ValueExpression" TEXT NULL,
"DurationRounds" INTEGER NULL,
"PerRound" INTEGER NULL,
"Modifier" INTEGER NULL,
"BodyPart" TEXT NULL,
"IsPermanent" INTEGER NOT NULL,
"SourceType" TEXT NOT NULL,
"SourceText" TEXT NULL,
CONSTRAINT "FK_CriticalEffects_CriticalResults_CriticalResultId"
FOREIGN KEY ("CriticalResultId") REFERENCES "CriticalResults" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_CriticalEffects_CriticalBranches_CriticalBranchId"
FOREIGN KEY ("CriticalBranchId") REFERENCES "CriticalBranches" ("Id") ON DELETE CASCADE
);
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_EffectCode"
ON "CriticalEffects" ("EffectCode");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_CriticalResultId"
ON "CriticalEffects" ("CriticalResultId");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_CriticalBranchId"
ON "CriticalEffects" ("CriticalBranchId");
""",
cancellationToken);
}
}

View File

@@ -14,4 +14,5 @@ public sealed class CriticalBranch
public string ParsedJson { get; set; } = "{}";
public int SortOrder { get; set; }
public CriticalResult CriticalResult { get; set; } = null!;
public List<CriticalEffect> Effects { get; set; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalEffect
{
public int Id { get; set; }
public int? CriticalResultId { get; set; }
public int? CriticalBranchId { get; set; }
public string EffectCode { get; set; } = string.Empty;
public string? Target { get; set; }
public int? ValueInteger { get; set; }
public decimal? ValueDecimal { get; set; }
public string? ValueExpression { get; set; }
public int? DurationRounds { get; set; }
public int? PerRound { get; set; }
public int? Modifier { get; set; }
public string? BodyPart { get; set; }
public bool IsPermanent { get; set; }
public string SourceType { get; set; } = "symbol";
public string? SourceText { get; set; }
public CriticalResult? CriticalResult { get; set; }
public CriticalBranch? CriticalBranch { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace RolemasterDb.App.Domain;
public static class CriticalEffectCodes
{
public const string DirectHits = "direct_hits";
public const string MustParryRounds = "must_parry_rounds";
public const string NoParryRounds = "no_parry_rounds";
public const string StunnedRounds = "stunned_rounds";
public const string BleedPerRound = "bleed_per_round";
public const string FoePenalty = "foe_penalty";
public const string AttackerBonusNextRound = "attacker_bonus_next_round";
public const string PowerPointModifier = "power_point_modifier";
}

View File

@@ -17,4 +17,5 @@ public sealed class CriticalResult
public CriticalColumn CriticalColumn { get; set; } = null!;
public CriticalRollBand CriticalRollBand { get; set; } = null!;
public List<CriticalBranch> Branches { get; set; } = [];
public List<CriticalEffect> Effects { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace RolemasterDb.App.Features;
public sealed record CriticalEffectLookupResponse(
string EffectCode,
string? Target,
int? ValueInteger,
string? ValueExpression,
int? DurationRounds,
int? PerRound,
int? Modifier,
string? BodyPart,
bool IsPermanent,
string SourceType,
string? SourceText);

View File

@@ -52,6 +52,7 @@ public sealed record CriticalBranchLookupResponse(
string ConditionText,
string Description,
string? AffixText,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
string RawText,
int SortOrder);
@@ -73,6 +74,7 @@ public sealed record CriticalLookupResponse(
string RawCellText,
string Description,
string? AffixText,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
IReadOnlyList<CriticalBranchLookupResponse> Branches,
string ParseStatus,
string ParsedJson);

View File

@@ -132,6 +132,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
item.RawCellText,
item.DescriptionText,
item.RawAffixText,
item.Effects
.OrderBy(effect => effect.Id)
.Select(effect => new CriticalEffectLookupResponse(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText))
.ToList(),
item.Branches
.OrderBy(branch => branch.SortOrder)
.Select(branch => new CriticalBranchLookupResponse(
@@ -140,6 +155,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
branch.Effects
.OrderBy(effect => effect.Id)
.Select(effect => new CriticalEffectLookupResponse(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText))
.ToList(),
branch.RawText,
branch.SortOrder))
.ToList(),

View File

@@ -255,6 +255,49 @@ textarea {
margin-bottom: 0;
}
.effect-stack {
margin-top: 0.85rem;
}
.effect-stack h5 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #75562f;
}
.effect-list {
list-style: none;
padding: 0;
margin: 0.7rem 0 0;
display: grid;
gap: 0.45rem;
}
.effect-item {
display: flex;
gap: 0.65rem;
align-items: baseline;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
border-radius: 12px;
background: rgba(255, 252, 244, 0.72);
border: 1px solid rgba(127, 96, 55, 0.12);
}
.effect-token {
padding: 0.12rem 0.38rem;
border-radius: 999px;
background: rgba(238, 223, 193, 0.72);
color: #5b4327;
font-size: 0.82rem;
}
.branch-effects {
margin-top: 0.75rem;
}
.error-text {
color: #8d2b1e;
}