Initial commit

This commit is contained in:
2026-03-14 00:32:26 +01:00
commit 70a35f3985
109 changed files with 62554 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["RolemasterDb.App.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row">
<div>
<span class="eyebrow">Starter stack</span>
<strong>.NET 10 + Blazor + Minimal API + EF Core + SQLite</strong>
</div>
<span class="status-pill">Seeded for attack and critical lookups</span>
</div>
<article class="content-shell">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,97 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
min-width: 0;
}
.sidebar {
background:
radial-gradient(circle at top, rgba(196, 167, 107, 0.28), transparent 35%),
linear-gradient(180deg, #24130d 0%, #3c2415 46%, #130d0b 100%);
border-right: 1px solid rgba(196, 167, 107, 0.2);
}
.top-row {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 1.15rem 1.5rem;
border-bottom: 1px solid rgba(111, 87, 59, 0.28);
background: rgba(250, 245, 234, 0.84);
backdrop-filter: blur(18px);
}
.eyebrow {
display: block;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #7c5b33;
}
.status-pill {
padding: 0.45rem 0.8rem;
border-radius: 999px;
border: 1px solid rgba(111, 87, 59, 0.2);
background: rgba(255, 252, 246, 0.8);
color: #5b4427;
font-size: 0.82rem;
}
.content-shell {
padding: 1.5rem;
}
@media (max-width: 640.98px) {
.top-row {
flex-direction: column;
align-items: flex-start;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 290px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
}
#blazor-error-ui {
color-scheme: light only;
background: #682e24;
color: #fffaf2;
bottom: 0;
box-shadow: 0 -1px 12px rgba(0, 0, 0, 0.22);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -0,0 +1,24 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid brand-shell">
<a class="navbar-brand" href="">RolemasterDB</a>
<p class="brand-copy">Automatic attack and critical lookup for a SQLite-backed starter dataset.</p>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler')?.click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Lookup Desk
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="api">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> API Surface
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,114 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(226, 195, 128, 0.22);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 244, 218, 0.82%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.06);
}
.navbar-toggler:checked {
background-color: rgba(226, 195, 128, 0.3);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0, 0, 0, 0.16);
}
.navbar-brand {
font-size: 1.45rem;
font-family: Cambria, Georgia, serif;
letter-spacing: 0.04em;
color: #fff1d2;
text-decoration: none;
}
.brand-shell {
padding: 1.2rem 0.5rem 1rem 0.5rem;
}
.brand-copy {
margin: 0.55rem 0 0;
color: rgba(255, 241, 210, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.98rem;
padding-bottom: 0.35rem;
}
.nav-item:first-of-type {
padding-top: 1.25rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #f3ddbc;
background: none;
border: none;
border-radius: 12px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
padding: 0 1rem;
}
.nav-item ::deep a.active {
background: linear-gradient(135deg, rgba(230, 195, 126, 0.22), rgba(255, 245, 225, 0.1));
color: #fff7e2;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255, 248, 234, 0.08);
color: #fff7e2;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
display: block;
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,43 @@
@page "/api"
<PageTitle>API Surface</PageTitle>
<section class="hero-panel">
<span class="eyebrow">Minimal API</span>
<h1 class="page-title">Endpoints for attack and critical lookups.</h1>
<p class="lede">The Blazor UI uses the same lookup service that the API exposes, so this page doubles as the first integration contract.</p>
</section>
<div class="api-grid">
<section class="panel">
<h2 class="panel-title">Reference data</h2>
<p class="panel-copy"><code>GET /api/reference-data</code></p>
<pre class="code-block">{
"attackTables": [
{ "key": "broadsword", "label": "Broadsword" }
]
}</pre>
</section>
<section class="panel">
<h2 class="panel-title">Attack lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/attack</code></p>
<pre class="code-block">{
"attackTable": "broadsword",
"armorType": "AT10",
"roll": 111,
"criticalRoll": 72
}</pre>
</section>
<section class="panel">
<h2 class="panel-title">Critical lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/critical</code></p>
<pre class="code-block">{
"criticalType": "slash",
"column": "B",
"roll": 72,
"group": null
}</pre>
</section>
</div>

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,309 @@
@page "/"
@rendermode InteractiveServer
@inject LookupService LookupService
<PageTitle>Lookup Desk</PageTitle>
@if (referenceData is null)
{
<section class="hero-panel">
<h1 class="page-title">Summoning tables...</h1>
<p class="lede">Loading the starter attack and critical data from SQLite.</p>
</section>
}
else
{
<section class="hero-panel">
<span class="eyebrow">Rolemaster Lookup Desk</span>
<h1 class="page-title">Resolve the attack roll, then the critical, from one place.</h1>
<p class="lede">
This starter app seeds a small SQLite dataset and exposes the same lookup flow through Blazor and minimal APIs.
The current data is intentionally limited to a first pass so the import pipeline can grow from a working base.
</p>
<div class="tag-row">
<span class="tag">@referenceData.AttackTables.Count attack tables</span>
<span class="tag">@referenceData.CriticalTables.Count critical tables</span>
<span class="tag">@referenceData.ArmorTypes.Count armor types</span>
<span class="tag">SQLite file: <code>rolemaster.db</code></span>
</div>
</section>
<div class="dashboard-grid">
<section class="panel">
<h2 class="panel-title">Automatic Attack Lookup</h2>
<p class="panel-copy">Choose an attack table, armor type, and attack roll. If the attack produces a critical and you provide the critical roll, the app resolves that follow-up automatically.</p>
<div class="lookup-form">
<div class="form-grid">
<div class="field-shell">
<label for="attack-table">Attack table</label>
<select id="attack-table" class="input-shell" @bind="attackInput.AttackTable">
@foreach (var attackTable in referenceData.AttackTables)
{
<option value="@attackTable.Key">@attackTable.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="armor-type">Armor type</label>
<select id="armor-type" class="input-shell" @bind="attackInput.ArmorType">
@foreach (var armorType in referenceData.ArmorTypes)
{
<option value="@armorType.Key">@armorType.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="attack-roll">Attack roll</label>
<input id="attack-roll" class="input-shell" type="number" min="1" max="300" @bind="attackInput.AttackRoll" />
</div>
<div class="field-shell">
<label for="critical-roll">Critical roll</label>
<input id="critical-roll" class="input-shell" type="number" min="1" max="100" @bind="attackInput.CriticalRollText" />
</div>
</div>
<div class="action-row">
<button class="btn-ritual" @onclick="RunAttackLookupAsync">Resolve attack</button>
<span class="muted">Leave critical roll blank to stop after the attack table result.</span>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(attackError))
{
<p class="error-text">@attackError</p>
}
@if (attackResult is not null)
{
<div class="result-shell">
<div class="result-card">
<h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3>
<div class="result-stats">
<span class="stat-pill">Roll band: @attackResult.RollBand</span>
<span class="stat-pill">Hits: @attackResult.Hits</span>
@if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<span class="stat-pill">@attackResult.CriticalSeverity @attackResult.CriticalType critical</span>
}
else
{
<span class="stat-pill">No critical</span>
}
</div>
<p><strong>Table notation:</strong> @attackResult.RawNotation</p>
@if (!string.IsNullOrWhiteSpace(attackResult.Notes))
{
<p class="muted">@attackResult.Notes</p>
}
@if (attackResult.AutoCritical is not null)
{
<div class="callout">
<h4>Automatic critical resolution</h4>
<div class="result-stats">
<span class="stat-pill">@attackResult.AutoCritical.CriticalTableName</span>
<span class="stat-pill">Column: @attackResult.AutoCritical.Column</span>
<span class="stat-pill">Band: @attackResult.AutoCritical.RollBand</span>
</div>
<p><strong>@attackResult.AutoCritical.Description</strong></p>
@if (!string.IsNullOrWhiteSpace(attackResult.AutoCritical.AffixText))
{
<p class="muted">@attackResult.AutoCritical.AffixText</p>
}
</div>
}
else if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
<div class="callout">The attack produced a critical. Add a critical roll to resolve it automatically.</div>
}
</div>
</div>
}
</section>
<section class="panel">
<h2 class="panel-title">Direct Critical Lookup</h2>
<p class="panel-copy">Use this when you already know the critical table, column, and roll.</p>
<div class="lookup-form">
<div class="form-grid">
<div class="field-shell">
<label for="critical-table">Critical table</label>
<select id="critical-table" class="input-shell" value="@criticalInput.CriticalType" @onchange="HandleCriticalTableChanged">
@foreach (var criticalTable in referenceData.CriticalTables)
{
<option value="@criticalTable.Key">@criticalTable.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="critical-column">Column</label>
<select id="critical-column" class="input-shell" @bind="criticalInput.Column">
@foreach (var column in SelectedCriticalTable?.Columns ?? [])
{
<option value="@column.Key">@column.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="critical-roll-direct">Critical roll</label>
<input id="critical-roll-direct" class="input-shell" type="number" min="1" max="100" @bind="criticalInput.Roll" />
</div>
</div>
<div class="action-row">
<button class="btn-ritual" @onclick="RunCriticalLookupAsync">Resolve critical</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(criticalError))
{
<p class="error-text">@criticalError</p>
}
@if (criticalResult is not null)
{
<div class="result-shell">
<div class="result-card">
<h3>@criticalResult.CriticalTableName</h3>
<div class="result-stats">
<span class="stat-pill">Column: @criticalResult.Column</span>
<span class="stat-pill">Band: @criticalResult.RollBand</span>
<span class="stat-pill">Roll: @criticalResult.Roll</span>
</div>
<p><strong>@criticalResult.Description</strong></p>
@if (!string.IsNullOrWhiteSpace(criticalResult.AffixText))
{
<p class="muted">@criticalResult.AffixText</p>
}
</div>
</div>
}
</section>
<section class="panel">
<h2 class="panel-title">Seeded Reference Data</h2>
<p class="panel-copy">The schema supports much more than what is seeded today. These are the initial tables standing up the flow.</p>
<div class="table-list">
@foreach (var attackTable in referenceData.AttackTables)
{
<div class="table-list-item">
<strong>@attackTable.Label</strong>
<span class="muted">Attack table key: <code>@attackTable.Key</code></span>
</div>
}
@foreach (var criticalTable in referenceData.CriticalTables)
{
<div class="table-list-item">
<strong>@criticalTable.Label</strong>
<span class="muted">Critical key: <code>@criticalTable.Key</code>, columns: @string.Join(", ", criticalTable.Columns.Select(column => column.Label))</span>
</div>
}
</div>
</section>
</div>
}
@code {
private LookupReferenceData? referenceData;
private AttackLookupForm attackInput = new();
private CriticalLookupForm criticalInput = new();
private AttackLookupResponse? attackResult;
private CriticalLookupResponse? criticalResult;
private string? attackError;
private string? criticalError;
private CriticalTableReference? SelectedCriticalTable =>
referenceData?.CriticalTables.FirstOrDefault(table => table.Key == criticalInput.CriticalType);
protected override async Task OnInitializedAsync()
{
referenceData = await LookupService.GetReferenceDataAsync();
attackInput.AttackTable = referenceData.AttackTables.FirstOrDefault()?.Key ?? string.Empty;
attackInput.ArmorType = referenceData.ArmorTypes.FirstOrDefault()?.Key ?? string.Empty;
var initialCriticalTable = referenceData.CriticalTables.FirstOrDefault();
criticalInput.CriticalType = initialCriticalTable?.Key ?? string.Empty;
criticalInput.Column = initialCriticalTable?.Columns.FirstOrDefault()?.Key ?? string.Empty;
}
private async Task RunAttackLookupAsync()
{
attackError = null;
attackResult = null;
if (!int.TryParse(attackInput.CriticalRollText, out var criticalRoll) && !string.IsNullOrWhiteSpace(attackInput.CriticalRollText))
{
attackError = "Critical roll must be empty or a whole number.";
return;
}
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(
attackInput.AttackTable,
attackInput.ArmorType,
attackInput.AttackRoll,
string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
if (response is null)
{
attackError = "No seeded attack result matched that table, armor type, and roll.";
return;
}
attackResult = response;
}
private async Task RunCriticalLookupAsync()
{
criticalError = null;
criticalResult = null;
var response = await LookupService.LookupCriticalAsync(new CriticalLookupRequest(
criticalInput.CriticalType,
criticalInput.Column,
criticalInput.Roll,
null));
if (response is null)
{
criticalError = "No seeded critical result matched that table, column, and roll.";
return;
}
criticalResult = response;
}
private void HandleCriticalTableChanged(ChangeEventArgs args)
{
criticalInput.CriticalType = args.Value?.ToString() ?? string.Empty;
var table = referenceData?.CriticalTables.FirstOrDefault(item => item.Key == criticalInput.CriticalType);
criticalInput.Column = table?.Columns.FirstOrDefault()?.Key ?? string.Empty;
criticalResult = null;
criticalError = null;
}
private sealed class AttackLookupForm
{
public string AttackTable { get; set; } = string.Empty;
public string ArmorType { get; set; } = string.Empty;
public int AttackRoll { get; set; } = 66;
public string? CriticalRollText { get; set; } = "72";
}
private sealed class CriticalLookupForm
{
public string CriticalType { get; set; } = string.Empty;
public string Column { get; set; } = string.Empty;
public int Roll { get; set; } = 72;
}
}

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View File

@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using RolemasterDb.App.Data
@using RolemasterDb.App.Features
@using RolemasterDb.App
@using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Layout

View File

@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Domain;
namespace RolemasterDb.App.Data;
public sealed class RolemasterDbContext(DbContextOptions<RolemasterDbContext> options) : DbContext(options)
{
public DbSet<ArmorType> ArmorTypes => Set<ArmorType>();
public DbSet<AttackTable> AttackTables => Set<AttackTable>();
public DbSet<AttackRollBand> AttackRollBands => Set<AttackRollBand>();
public DbSet<AttackResult> AttackResults => Set<AttackResult>();
public DbSet<CriticalTable> CriticalTables => Set<CriticalTable>();
public DbSet<CriticalGroup> CriticalGroups => Set<CriticalGroup>();
public DbSet<CriticalColumn> CriticalColumns => Set<CriticalColumn>();
public DbSet<CriticalRollBand> CriticalRollBands => Set<CriticalRollBand>();
public DbSet<CriticalResult> CriticalResults => Set<CriticalResult>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ArmorType>(entity =>
{
entity.HasIndex(item => item.Code).IsUnique();
entity.Property(item => item.Code).HasMaxLength(32);
entity.Property(item => item.Label).HasMaxLength(128);
});
modelBuilder.Entity<AttackTable>(entity =>
{
entity.HasIndex(item => item.Slug).IsUnique();
entity.Property(item => item.Slug).HasMaxLength(64);
entity.Property(item => item.DisplayName).HasMaxLength(128);
entity.Property(item => item.AttackKind).HasMaxLength(32);
});
modelBuilder.Entity<AttackRollBand>(entity =>
{
entity.HasIndex(item => new { item.AttackTableId, item.Label }).IsUnique();
entity.HasIndex(item => new { item.AttackTableId, item.MinRoll, item.MaxRoll });
});
modelBuilder.Entity<AttackResult>(entity =>
{
entity.HasIndex(item => new { item.AttackTableId, item.ArmorTypeId, item.AttackRollBandId }).IsUnique();
entity.Property(item => item.CriticalType).HasMaxLength(64);
entity.Property(item => item.CriticalSeverity).HasMaxLength(8);
entity.Property(item => item.RawNotation).HasMaxLength(256);
});
modelBuilder.Entity<CriticalTable>(entity =>
{
entity.HasIndex(item => item.Slug).IsUnique();
entity.Property(item => item.Slug).HasMaxLength(64);
entity.Property(item => item.DisplayName).HasMaxLength(128);
entity.Property(item => item.Family).HasMaxLength(32);
});
modelBuilder.Entity<CriticalGroup>(entity =>
{
entity.HasIndex(item => new { item.CriticalTableId, item.GroupKey }).IsUnique();
entity.Property(item => item.GroupKey).HasMaxLength(64);
});
modelBuilder.Entity<CriticalColumn>(entity =>
{
entity.HasIndex(item => new { item.CriticalTableId, item.ColumnKey }).IsUnique();
entity.Property(item => item.ColumnKey).HasMaxLength(64);
entity.Property(item => item.Role).HasMaxLength(32);
});
modelBuilder.Entity<CriticalRollBand>(entity =>
{
entity.HasIndex(item => new { item.CriticalTableId, item.Label }).IsUnique();
entity.HasIndex(item => new { item.CriticalTableId, item.MinRoll, item.MaxRoll });
});
modelBuilder.Entity<CriticalResult>(entity =>
{
entity.HasIndex(item => new { item.CriticalTableId, item.CriticalGroupId, item.CriticalColumnId, item.CriticalRollBandId }).IsUnique();
entity.Property(item => item.ParseStatus).HasMaxLength(32);
});
}
}

View File

@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
namespace RolemasterDb.App.Data;
public static class RolemasterDbInitializer
{
public static async Task InitializeAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
await using var scope = services.CreateAsyncScope();
var dbFactory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<RolemasterDbContext>>();
await using var dbContext = await dbFactory.CreateDbContextAsync(cancellationToken);
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
if (await dbContext.AttackTables.AnyAsync(cancellationToken) || await dbContext.CriticalTables.AnyAsync(cancellationToken))
{
return;
}
RolemasterSeedData.Seed(dbContext);
await dbContext.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,302 @@
using RolemasterDb.App.Domain;
namespace RolemasterDb.App.Data;
public static class RolemasterSeedData
{
public static void Seed(RolemasterDbContext dbContext)
{
var armorTypes = CreateArmorTypes();
dbContext.ArmorTypes.AddRange(armorTypes);
var armorLookup = armorTypes.ToDictionary(item => item.Code, StringComparer.OrdinalIgnoreCase);
var attackTables = CreateAttackTables(armorLookup);
dbContext.AttackTables.AddRange(attackTables);
var criticalTables = CreateCriticalTables();
dbContext.CriticalTables.AddRange(criticalTables);
}
private static List<ArmorType> CreateArmorTypes() =>
[
new ArmorType { Code = "AT1", Label = "AT1 - Robes", SortOrder = 1 },
new ArmorType { Code = "AT5", Label = "AT5 - Rigid Leather", SortOrder = 5 },
new ArmorType { Code = "AT10", Label = "AT10 - Chain", SortOrder = 10 },
new ArmorType { Code = "AT15", Label = "AT15 - Plate", SortOrder = 15 },
new ArmorType { Code = "AT20", Label = "AT20 - Full Plate", SortOrder = 20 }
];
private static List<AttackTable> CreateAttackTables(IReadOnlyDictionary<string, ArmorType> armorLookup)
{
var broadsword = new AttackTable
{
Slug = "broadsword",
DisplayName = "Broadsword",
AttackKind = "melee",
Notes = "Starter subset with slash criticals to prove the end-to-end lookup flow."
};
var shortBow = new AttackTable
{
Slug = "short_bow",
DisplayName = "Short Bow",
AttackKind = "missile",
Notes = "Starter subset with puncture criticals."
};
AddAttackData(
broadsword,
armorLookup,
"slash",
new (string Label, int MinRoll, int? MaxRoll)[]
{
("01-25", 1, 25),
("26-50", 26, 50),
("51-75", 51, 75),
("76-100", 76, 100),
("101-125", 101, 125),
("126+", 126, null)
},
new Dictionary<string, (int Hits, string? Severity)[]>
{
["AT1"] = [(0, null), (4, "A"), (10, "B"), (18, "C"), (26, "D"), (34, "E")],
["AT5"] = [(0, null), (2, null), (8, "A"), (14, "B"), (22, "C"), (30, "D")],
["AT10"] = [(0, null), (0, null), (4, null), (10, "A"), (18, "B"), (26, "C")],
["AT15"] = [(0, null), (0, null), (2, null), (6, null), (12, "A"), (20, "B")],
["AT20"] = [(0, null), (0, null), (0, null), (4, null), (8, null), (16, "A")]
});
AddAttackData(
shortBow,
armorLookup,
"puncture",
new (string Label, int MinRoll, int? MaxRoll)[]
{
("01-25", 1, 25),
("26-50", 26, 50),
("51-75", 51, 75),
("76-100", 76, 100),
("101-125", 101, 125),
("126+", 126, null)
},
new Dictionary<string, (int Hits, string? Severity)[]>
{
["AT1"] = [(0, null), (3, "A"), (8, "B"), (12, "C"), (18, "D"), (24, "E")],
["AT5"] = [(0, null), (1, null), (5, "A"), (9, "B"), (14, "C"), (20, "D")],
["AT10"] = [(0, null), (0, null), (2, null), (6, "A"), (10, "B"), (15, "C")],
["AT15"] = [(0, null), (0, null), (1, null), (4, "A"), (8, "B"), (12, "C")],
["AT20"] = [(0, null), (0, null), (0, null), (2, null), (5, "A"), (9, "B")]
});
return [broadsword, shortBow];
}
private static void AddAttackData(
AttackTable table,
IReadOnlyDictionary<string, ArmorType> armorLookup,
string criticalType,
IReadOnlyList<(string Label, int MinRoll, int? MaxRoll)> rollBands,
IReadOnlyDictionary<string, (int Hits, string? Severity)[]> matrix)
{
table.RollBands = rollBands
.Select((band, index) => new AttackRollBand
{
Label = band.Label,
MinRoll = band.MinRoll,
MaxRoll = band.MaxRoll,
SortOrder = index + 1
})
.ToList();
var rollBandLookup = table.RollBands.ToDictionary(item => item.SortOrder);
foreach (var (armorCode, results) in matrix)
{
for (var index = 0; index < results.Length; index++)
{
var result = results[index];
table.Results.Add(new AttackResult
{
ArmorType = armorLookup[armorCode],
AttackRollBand = rollBandLookup[index + 1],
Hits = result.Hits,
CriticalType = result.Severity is null ? null : criticalType,
CriticalSeverity = result.Severity,
RawNotation = BuildAttackNotation(result.Hits, result.Severity, criticalType),
Notes = result.Severity is null
? "No critical triggered from the seeded starter data."
: "Critical can be resolved automatically if a critical roll is supplied."
});
}
}
}
private static List<CriticalTable> CreateCriticalTables()
{
return
[
CreateCriticalTable(
slug: "slash",
displayName: "Slash Critical",
notes: "Starter subset derived from the critical-table schema design notes.",
matrix: new Dictionary<string, (string Description, string Affix)[]>
{
["A"] =
[
("Glancing slash across the forearm.", "+2 hits, 1 bleed"),
("Rib-level cut knocks the foe back a step.", "+4 hits, 1 stun, 2 bleed"),
("Deep cut to the thigh unbalances the target.", "+6 hits, 2 stun, 3 bleed"),
("Shoulder opened; guard collapses.", "+8 hits, 3 stun, 4 bleed"),
("Neck line opened. The fight is over.", "Instant death")
],
["B"] =
[
("Slash cuts through the hand and weapon grip wavers.", "+4 hits, 1 stun, 2 bleed"),
("Diagonal cut across the chest steals breath.", "+6 hits, 2 stun, 3 bleed"),
("Hip strike drives the foe to one knee.", "+8 hits, 3 stun, 4 bleed"),
("Sword arm carved deeply; weapon drops.", "+10 hits, 4 stun, 5 bleed"),
("Throat opened in a full red arc.", "Instant death")
],
["C"] =
[
("Forearm split to the bone; parry ruined.", "+6 hits, 2 stun, 3 bleed"),
("Wide cut over the ribs spins the foe.", "+8 hits, 3 stun, 4 bleed"),
("Hamstring sliced; prone unless magically supported.", "+10 hits, 4 stun, 6 bleed"),
("Slash rips across face and collar. Vision swims.", "+12 hits, 5 stun, 7 bleed"),
("Head nearly severed.", "Instant death")
],
["D"] =
[
("Deep abdominal slice spills momentum and blood together.", "+8 hits, 3 stun, 5 bleed"),
("Sword tracks from shoulder to sternum. Collapse likely.", "+10 hits, 4 stun, 6 bleed"),
("Leg cut through muscle; foe falls hard.", "+12 hits, 5 stun, 8 bleed"),
("Upper arm nearly removed; weapon arm useless.", "+14 hits, 6 stun, 10 bleed"),
("Killing cut through the neck.", "Instant death")
],
["E"] =
[
("Massive slash opens chest and the foe staggers blindly.", "+10 hits, 4 stun, 6 bleed"),
("Spine-grazing cut destroys posture and control.", "+12 hits, 5 stun, 8 bleed"),
("Leg severed below the knee.", "+14 hits, 6 stun, 10 bleed"),
("Torso opened from clavicle to hip.", "+16 hits, 8 stun, 12 bleed"),
("Clean decapitation.", "Instant death")
]
}),
CreateCriticalTable(
slug: "puncture",
displayName: "Puncture Critical",
notes: "Starter subset seeded for missile and thrust attacks.",
matrix: new Dictionary<string, (string Description, string Affix)[]>
{
["A"] =
[
("Point slips into the shoulder.", "+2 hits, 1 bleed"),
("Stab bites into the upper arm.", "+4 hits, 1 stun, 2 bleed"),
("Short thrust catches the ribs.", "+5 hits, 2 stun, 2 bleed"),
("Point pierces the thigh. Movement slows.", "+7 hits, 2 stun, 3 bleed"),
("Exact thrust to the throat.", "Instant death")
],
["B"] =
[
("Puncture through the palm forces a drop check.", "+4 hits, 1 stun, 2 bleed"),
("Arrow buries in the flank.", "+6 hits, 2 stun, 3 bleed"),
("Deep thrust into the belly doubles the foe over.", "+8 hits, 3 stun, 4 bleed"),
("Point through the shoulder locks the arm.", "+10 hits, 4 stun, 5 bleed"),
("Eye socket struck cleanly.", "Instant death")
],
["C"] =
[
("Stab through the bicep ruins the next parry.", "+6 hits, 2 stun, 3 bleed"),
("Arrow punches through lung tissue.", "+8 hits, 3 stun, 5 bleed"),
("Thrust under the ribs steals breath and footing.", "+10 hits, 4 stun, 6 bleed"),
("Deep puncture pins the foe in place.", "+12 hits, 5 stun, 7 bleed"),
("Heart strike.", "Instant death")
],
["D"] =
[
("Weapon point tears through the abdomen.", "+8 hits, 3 stun, 5 bleed"),
("Arrow lodges near the spine; movement nearly gone.", "+10 hits, 4 stun, 6 bleed"),
("Lance-like thrust through the torso.", "+12 hits, 5 stun, 8 bleed"),
("Point enters the neck and exits behind the shoulder.", "+14 hits, 6 stun, 10 bleed"),
("Brain pierced.", "Instant death")
],
["E"] =
[
("Massive puncture opens the chest cavity.", "+10 hits, 4 stun, 6 bleed"),
("Shaft buries to the fletching; foe drops.", "+12 hits, 5 stun, 8 bleed"),
("Groin-to-spine thrust destroys the body line.", "+14 hits, 6 stun, 10 bleed"),
("Perfect impalement through the throat and neck.", "+16 hits, 8 stun, 12 bleed"),
("Instantly fatal puncture.", "Instant death")
]
})
];
}
private static CriticalTable CreateCriticalTable(
string slug,
string displayName,
string notes,
IReadOnlyDictionary<string, (string Description, string Affix)[]> matrix)
{
var table = new CriticalTable
{
Slug = slug,
DisplayName = displayName,
Family = "standard",
SourceDocument = "Seeded starter data",
Notes = notes
};
table.Columns =
[
new CriticalColumn { ColumnKey = "A", Label = "A", SortOrder = 1 },
new CriticalColumn { ColumnKey = "B", Label = "B", SortOrder = 2 },
new CriticalColumn { ColumnKey = "C", Label = "C", SortOrder = 3 },
new CriticalColumn { ColumnKey = "D", Label = "D", SortOrder = 4 },
new CriticalColumn { ColumnKey = "E", Label = "E", SortOrder = 5 }
];
table.RollBands =
[
new CriticalRollBand { Label = "01-20", MinRoll = 1, MaxRoll = 20, SortOrder = 1 },
new CriticalRollBand { Label = "21-40", MinRoll = 21, MaxRoll = 40, SortOrder = 2 },
new CriticalRollBand { Label = "41-60", MinRoll = 41, MaxRoll = 60, SortOrder = 3 },
new CriticalRollBand { Label = "61-80", MinRoll = 61, MaxRoll = 80, SortOrder = 4 },
new CriticalRollBand { Label = "81-100", MinRoll = 81, MaxRoll = 100, SortOrder = 5 }
];
var columnsByKey = table.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase);
var bandsByOrder = table.RollBands.ToDictionary(item => item.SortOrder);
foreach (var (columnKey, rows) in matrix)
{
for (var index = 0; index < rows.Length; index++)
{
var row = rows[index];
table.Results.Add(new CriticalResult
{
CriticalColumn = columnsByKey[columnKey],
CriticalRollBand = bandsByOrder[index + 1],
DescriptionText = row.Description,
RawAffixText = row.Affix,
RawCellText = $"{row.Description} {row.Affix}",
ParsedJson = "{}",
ParseStatus = "verified"
});
}
}
return table;
}
private static string BuildAttackNotation(int hits, string? severity, string criticalType)
{
return severity is null
? $"{hits} hits"
: $"{hits} hits, {severity.ToUpperInvariant()} {ToTitleCase(criticalType)} critical";
}
private static string ToTitleCase(string value) =>
string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries)
.Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..]));
}

View File

@@ -0,0 +1,10 @@
namespace RolemasterDb.App.Domain;
public sealed class ArmorType
{
public int Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int SortOrder { get; set; }
public List<AttackResult> AttackResults { get; set; } = [];
}

View File

@@ -0,0 +1,17 @@
namespace RolemasterDb.App.Domain;
public sealed class AttackResult
{
public int Id { get; set; }
public int AttackTableId { get; set; }
public int ArmorTypeId { get; set; }
public int AttackRollBandId { get; set; }
public int Hits { get; set; }
public string? CriticalType { get; set; }
public string? CriticalSeverity { get; set; }
public string RawNotation { get; set; } = string.Empty;
public string? Notes { get; set; }
public AttackTable AttackTable { get; set; } = null!;
public ArmorType ArmorType { get; set; } = null!;
public AttackRollBand AttackRollBand { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace RolemasterDb.App.Domain;
public sealed class AttackRollBand
{
public int Id { get; set; }
public int AttackTableId { get; set; }
public string Label { get; set; } = string.Empty;
public int MinRoll { get; set; }
public int? MaxRoll { get; set; }
public int SortOrder { get; set; }
public AttackTable AttackTable { get; set; } = null!;
public List<AttackResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,12 @@
namespace RolemasterDb.App.Domain;
public sealed class AttackTable
{
public int Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string AttackKind { get; set; } = string.Empty;
public string? Notes { get; set; }
public List<AttackRollBand> RollBands { get; set; } = [];
public List<AttackResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,13 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalColumn
{
public int Id { get; set; }
public int CriticalTableId { get; set; }
public string ColumnKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public string Role { get; set; } = "severity";
public int SortOrder { get; set; }
public CriticalTable CriticalTable { get; set; } = null!;
public List<CriticalResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,12 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalGroup
{
public int Id { get; set; }
public int CriticalTableId { get; set; }
public string GroupKey { get; set; } = string.Empty;
public string Label { get; set; } = string.Empty;
public int SortOrder { get; set; }
public CriticalTable CriticalTable { get; set; } = null!;
public List<CriticalResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalResult
{
public int Id { get; set; }
public int CriticalTableId { get; set; }
public int? CriticalGroupId { get; set; }
public int CriticalColumnId { get; set; }
public int CriticalRollBandId { get; set; }
public string RawCellText { get; set; } = string.Empty;
public string DescriptionText { get; set; } = string.Empty;
public string? RawAffixText { get; set; }
public string ParsedJson { get; set; } = "{}";
public string ParseStatus { get; set; } = "verified";
public CriticalTable CriticalTable { get; set; } = null!;
public CriticalGroup? CriticalGroup { get; set; }
public CriticalColumn CriticalColumn { get; set; } = null!;
public CriticalRollBand CriticalRollBand { get; set; } = null!;
}

View File

@@ -0,0 +1,13 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalRollBand
{
public int Id { get; set; }
public int CriticalTableId { get; set; }
public string Label { get; set; } = string.Empty;
public int MinRoll { get; set; }
public int? MaxRoll { get; set; }
public int SortOrder { get; set; }
public CriticalTable CriticalTable { get; set; } = null!;
public List<CriticalResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalTable
{
public int Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Family { get; set; } = "standard";
public string SourceDocument { get; set; } = string.Empty;
public string? Notes { get; set; }
public List<CriticalGroup> Groups { get; set; } = [];
public List<CriticalColumn> Columns { get; set; } = [];
public List<CriticalRollBand> RollBands { get; set; } = [];
public List<CriticalResult> Results { get; set; } = [];
}

View File

@@ -0,0 +1,50 @@
namespace RolemasterDb.App.Features;
public sealed record LookupOption(string Key, string Label);
public sealed record CriticalTableReference(
string Key,
string Label,
IReadOnlyList<LookupOption> Columns,
IReadOnlyList<LookupOption> Groups);
public sealed record LookupReferenceData(
IReadOnlyList<LookupOption> AttackTables,
IReadOnlyList<LookupOption> ArmorTypes,
IReadOnlyList<CriticalTableReference> CriticalTables);
public sealed record AttackLookupRequest(
string AttackTable,
string ArmorType,
int Roll,
int? CriticalRoll);
public sealed record CriticalLookupRequest(
string CriticalType,
string Column,
int Roll,
string? Group);
public sealed record CriticalLookupResponse(
string CriticalType,
string CriticalTableName,
string? Group,
string Column,
int Roll,
string RollBand,
string Description,
string? AffixText);
public sealed record AttackLookupResponse(
string AttackTable,
string AttackTableName,
string ArmorType,
string ArmorTypeLabel,
int Roll,
string RollBand,
int Hits,
string? CriticalType,
string? CriticalSeverity,
string RawNotation,
string? Notes,
CriticalLookupResponse? AutoCritical);

View File

@@ -0,0 +1,116 @@
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data;
namespace RolemasterDb.App.Features;
public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbContextFactory)
{
public async Task<LookupReferenceData> GetReferenceDataAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var attackTables = await dbContext.AttackTables
.AsNoTracking()
.OrderBy(item => item.DisplayName)
.Select(item => new LookupOption(item.Slug, item.DisplayName))
.ToListAsync(cancellationToken);
var armorTypes = await dbContext.ArmorTypes
.AsNoTracking()
.OrderBy(item => item.SortOrder)
.Select(item => new LookupOption(item.Code, item.Label))
.ToListAsync(cancellationToken);
var criticalTables = await dbContext.CriticalTables
.AsNoTracking()
.AsSplitQuery()
.Include(item => item.Columns)
.Include(item => item.Groups)
.OrderBy(item => item.DisplayName)
.ToListAsync(cancellationToken);
return new LookupReferenceData(
attackTables,
armorTypes,
criticalTables.Select(item => new CriticalTableReference(
item.Slug,
item.DisplayName,
item.Columns.OrderBy(column => column.SortOrder).Select(column => new LookupOption(column.ColumnKey, column.Label)).ToList(),
item.Groups.OrderBy(group => group.SortOrder).Select(group => new LookupOption(group.GroupKey, group.Label)).ToList()))
.ToList());
}
public async Task<AttackLookupResponse?> LookupAttackAsync(AttackLookupRequest request, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var attackTable = NormalizeSlug(request.AttackTable);
var armorType = request.ArmorType.Trim().ToUpperInvariant();
var attackResult = await dbContext.AttackResults
.AsNoTracking()
.Where(item =>
item.AttackTable.Slug == attackTable &&
item.ArmorType.Code == armorType &&
request.Roll >= item.AttackRollBand.MinRoll &&
(item.AttackRollBand.MaxRoll == null || request.Roll <= item.AttackRollBand.MaxRoll))
.Select(item => new AttackLookupResponse(
item.AttackTable.Slug,
item.AttackTable.DisplayName,
item.ArmorType.Code,
item.ArmorType.Label,
request.Roll,
item.AttackRollBand.Label,
item.Hits,
item.CriticalType,
item.CriticalSeverity,
item.RawNotation,
item.Notes,
null))
.SingleOrDefaultAsync(cancellationToken);
if (attackResult is null || attackResult.CriticalType is null || attackResult.CriticalSeverity is null || request.CriticalRoll is null)
{
return attackResult;
}
var autoCritical = await LookupCriticalAsync(
new CriticalLookupRequest(attackResult.CriticalType, attackResult.CriticalSeverity, request.CriticalRoll.Value, null),
cancellationToken);
return attackResult with { AutoCritical = autoCritical };
}
public async Task<CriticalLookupResponse?> LookupCriticalAsync(CriticalLookupRequest request, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var criticalType = NormalizeSlug(request.CriticalType);
var column = request.Column.Trim().ToUpperInvariant();
var group = string.IsNullOrWhiteSpace(request.Group) ? null : NormalizeSlug(request.Group);
return await dbContext.CriticalResults
.AsNoTracking()
.Where(item =>
item.CriticalTable.Slug == criticalType &&
item.CriticalColumn.ColumnKey == column &&
(group == null
? item.CriticalGroupId == null
: item.CriticalGroup != null && item.CriticalGroup.GroupKey == group) &&
request.Roll >= item.CriticalRollBand.MinRoll &&
(item.CriticalRollBand.MaxRoll == null || request.Roll <= item.CriticalRollBand.MaxRoll))
.Select(item => new CriticalLookupResponse(
item.CriticalTable.Slug,
item.CriticalTable.DisplayName,
item.CriticalGroup != null ? item.CriticalGroup.GroupKey : null,
item.CriticalColumn.ColumnKey,
request.Roll,
item.CriticalRollBand.Label,
item.DescriptionText,
item.RawAffixText))
.SingleOrDefaultAsync(cancellationToken);
}
private static string NormalizeSlug(string value) =>
value.Trim().Replace(' ', '_').ToLowerInvariant();
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Components;
using RolemasterDb.App.Data;
using RolemasterDb.App.Features;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("RolemasterDb") ?? "Data Source=rolemaster.db";
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddScoped<LookupService>();
var app = builder.Build();
await RolemasterDbInitializer.InitializeAsync(app.Services);
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseAntiforgery();
app.MapStaticAssets();
var api = app.MapGroup("/api");
api.MapGet("/reference-data", async (LookupService lookupService, CancellationToken cancellationToken) =>
Results.Ok(await lookupService.GetReferenceDataAsync(cancellationToken)));
api.MapPost("/lookup/attack", async (AttackLookupRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
{
var result = await lookupService.LookupAttackAsync(request, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
});
api.MapPost("/lookup/critical", async (CriticalLookupRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
{
var result = await lookupService.LookupCriticalAsync(request, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
});
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5184",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"RolemasterDb": "Data Source=rolemaster.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

Binary file not shown.

View File

@@ -0,0 +1,259 @@
:root {
--paper: #f7f1e4;
--paper-strong: #fff9ee;
--ink: #261a14;
--ink-soft: #5a4c41;
--accent: #8f5a2f;
--accent-strong: #b8793b;
--panel: rgba(255, 250, 240, 0.82);
--line: rgba(111, 87, 59, 0.18);
--shadow: 0 18px 40px rgba(41, 22, 11, 0.12);
}
html, body {
min-height: 100%;
background:
radial-gradient(circle at top, rgba(240, 223, 185, 0.55), transparent 28%),
linear-gradient(180deg, #eadbc0 0%, #f4ecda 38%, #e2d3b7 100%);
color: var(--ink);
font-family: Georgia, "Palatino Linotype", serif;
}
body {
margin: 0;
}
a, .btn-link {
color: var(--accent);
}
a:hover {
color: #6e4320;
}
button,
input,
select,
textarea {
font: inherit;
}
.page-title {
font-size: clamp(2.2rem, 4vw, 3.5rem);
line-height: 0.95;
margin: 0;
font-family: Cambria, Georgia, serif;
}
.lede {
margin: 0;
color: var(--ink-soft);
font-size: 1.05rem;
line-height: 1.7;
}
.dashboard-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.hero-panel,
.panel {
border: 1px solid var(--line);
border-radius: 24px;
background: var(--panel);
box-shadow: var(--shadow);
}
.hero-panel {
padding: 1.6rem;
margin-bottom: 1.25rem;
background:
linear-gradient(135deg, rgba(255, 251, 243, 0.94), rgba(244, 232, 203, 0.94)),
var(--panel);
}
.panel {
padding: 1.35rem;
}
.panel-title {
margin: 0 0 0.35rem;
font-size: 1.4rem;
}
.panel-copy,
.muted {
color: var(--ink-soft);
}
.lookup-form {
display: grid;
gap: 0.95rem;
}
.form-grid {
display: grid;
gap: 0.95rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.field-shell {
display: grid;
gap: 0.35rem;
}
.field-shell label {
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #75562f;
}
.input-shell {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(127, 96, 55, 0.2);
background: rgba(255, 252, 247, 0.92);
padding: 0.8rem 0.9rem;
color: var(--ink);
box-sizing: border-box;
}
.input-shell:focus {
outline: 2px solid rgba(184, 121, 59, 0.35);
border-color: rgba(184, 121, 59, 0.45);
}
.action-row {
display: flex;
gap: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.btn-ritual {
border: none;
border-radius: 999px;
padding: 0.8rem 1.15rem;
background: linear-gradient(135deg, var(--accent-strong), var(--accent));
color: #fff8ef;
letter-spacing: 0.04em;
box-shadow: 0 10px 18px rgba(143, 90, 47, 0.2);
}
.btn-ritual:hover {
background: linear-gradient(135deg, #c38a4d, #8f5a2f);
}
.tag-row {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.tag {
border-radius: 999px;
border: 1px solid rgba(143, 90, 47, 0.18);
padding: 0.4rem 0.7rem;
background: rgba(255, 250, 242, 0.84);
color: #5d4429;
font-size: 0.82rem;
}
.result-shell {
margin-top: 1rem;
display: grid;
gap: 0.85rem;
}
.result-card {
border-radius: 20px;
padding: 1rem;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(127, 96, 55, 0.14);
}
.result-card h3,
.result-card h4 {
margin-top: 0;
margin-bottom: 0.45rem;
}
.result-stats {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
margin-bottom: 0.8rem;
}
.stat-pill {
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: rgba(238, 223, 193, 0.65);
color: #5b4327;
font-size: 0.85rem;
}
.callout {
margin-top: 0.9rem;
padding: 0.85rem 0.95rem;
border-radius: 16px;
background: rgba(255, 247, 230, 0.76);
border: 1px solid rgba(184, 121, 59, 0.18);
color: #5b4327;
}
.error-text {
color: #8d2b1e;
}
.table-list {
display: grid;
gap: 0.8rem;
}
.table-list-item {
border-radius: 16px;
padding: 0.9rem 1rem;
background: rgba(255, 255, 255, 0.54);
border: 1px solid rgba(127, 96, 55, 0.12);
}
.table-list-item strong {
display: block;
}
.code-block {
margin: 0;
padding: 1rem;
border-radius: 16px;
background: #2a1d17;
color: #f9ecd2;
overflow-x: auto;
font-family: Consolas, "Courier New", monospace;
line-height: 1.55;
}
.api-grid {
display: grid;
gap: 1rem;
}
.blazor-error-boundary {
background: #7e2c22;
color: #fff7ee;
}
@media (max-width: 640.98px) {
.content-shell {
padding: 1rem;
}
.hero-panel,
.panel {
border-radius: 20px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long