first commit

This commit is contained in:
Zeeshaun
2026-04-20 18:15:08 +00:00
commit c98e158e26
111 changed files with 77565 additions and 0 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
.vs/
data/
*.user
*.suo
+14
View File
@@ -0,0 +1,14 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Fragalytics.Web/Fragalytics.Web.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
CMD ["dotnet", "Fragalytics.Web.dll"]
+30
View File
@@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fragalytics.Web", "src\Fragalytics.Web\Fragalytics.Web.csproj", "{6D2C3D22-A54E-4E1B-A50E-5B7E9B1B6E21}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fragalytics.Web.Tests", "tests\Fragalytics.Web.Tests\Fragalytics.Web.Tests.csproj", "{6AA0AD67-3CB0-446A-84A7-C7DDC8ABAA7F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fragalytics.RenderWorker", "src\Fragalytics.RenderWorker\Fragalytics.RenderWorker.csproj", "{B0A62F55-2B53-457A-B676-182C389DCC29}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{6D2C3D22-A54E-4E1B-A50E-5B7E9B1B6E21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D2C3D22-A54E-4E1B-A50E-5B7E9B1B6E21}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D2C3D22-A54E-4E1B-A50E-5B7E9B1B6E21}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D2C3D22-A54E-4E1B-A50E-5B7E9B1B6E21}.Release|Any CPU.Build.0 = Release|Any CPU
{6AA0AD67-3CB0-446A-84A7-C7DDC8ABAA7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6AA0AD67-3CB0-446A-84A7-C7DDC8ABAA7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6AA0AD67-3CB0-446A-84A7-C7DDC8ABAA7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6AA0AD67-3CB0-446A-84A7-C7DDC8ABAA7F}.Release|Any CPU.Build.0 = Release|Any CPU
{B0A62F55-2B53-457A-B676-182C389DCC29}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B0A62F55-2B53-457A-B676-182C389DCC29}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B0A62F55-2B53-457A-B676-182C389DCC29}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B0A62F55-2B53-457A-B676-182C389DCC29}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
</packageSources>
</configuration>
+99
View File
@@ -0,0 +1,99 @@
# Fragalytics MVP
Fragalytics is a self-hosted MVP for Counter-Strike 2 demo analysis and highlight generation built in C# on ASP.NET Core. You upload `.dem` files manually, the app stores them, parses them in a background worker, scores highlight moments with explainable rules, persists the results in a local JSON-backed store, and creates clip job records for future rendering.
## Why this C# stack
- Web app: ASP.NET Core Razor Pages. It keeps the UI simple, local, and maintainable without adding a separate frontend build.
- Background work: `BackgroundService`. Good enough for a single-node MVP and easy to replace later with a queue worker.
- Persistence: file-backed JSON store. It avoids external package dependencies in this environment while still giving durable local storage and a clean repository boundary.
- Parsing: parser adapter abstraction. The app can run today with the sample adapter and can be pointed at an external parser process later.
- Scoring: explicit service layer with versioned rules and explainable output.
## What is implemented
- Manual `.dem` upload
- Background demo parsing
- Persistent storage
- Models for demos, matches, rounds, players, events, highlights, scoring profiles, and clip jobs
- Explainable highlight scoring
- Highlight tags: `multikill`, `headshot`, `opening_kill`, `clutch`, `1vX`, `wallbang`, `smoke_kill`, `round_win`
- Clip job records with `start_tick`, `end_tick`, `status`, `renderer`, and payload
- Web UI for demos, match details, highlights, and clip jobs
- Rescoring when scoring rules change
- Parser adapter abstraction
- Future render-worker abstraction
- Docker setup
- Tests for scoring and basic ingest flow
## Run locally
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 dotnet run --project src/Fragalytics.Web
```
Open `http://localhost:5000` or the URL printed by ASP.NET Core.
## Run the render worker
The render worker is a separate process:
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 dotnet run --project src/Fragalytics.RenderWorker
```
Right now it uses the `sample-copy` backend. That backend does not render from a `.dem`; it claims pending clip jobs and attaches a configured sample `.mp4` or `.webm` file so the end-to-end worker flow is in place.
## Docker
```bash
docker compose up --build
```
Open `http://localhost:8080`.
## Configuration
Main settings live in [appsettings.json](/home/hz/source/fragalytics/src/Fragalytics.Web/appsettings.json).
- `Fragalytics:DataDirectory`: persistent app state root
- `Fragalytics:UploadDirectory`: uploaded demo path
- `Fragalytics:WorkerPollSeconds`: polling interval for the hosted worker
- `Fragalytics:Parser:Mode`: `sample` or `external`
- `Fragalytics:Parser:ExternalCommand`: executable for a real parser sidecar
- `Fragalytics:Parser:ExternalArguments`: argument template, with `{demoPath}` placeholder
- `Fragalytics:RenderWorker:Backend`: render backend name, currently `sample-copy`
- `Fragalytics:RenderWorker:SampleClipPath`: source media file used by the stub backend
- `Fragalytics:RenderWorker:TempDirectory`: worker scratch directory
- `Fragalytics:RenderWorker:PollSeconds`: clip-job polling interval
## Scoring
Scoring rules live in [scoring-rules.json](/home/hz/source/fragalytics/src/Fragalytics.Web/scoring-rules.json). Change the weights or threshold, then use `Rescore All Matches` from the home page. The app stores a hashed scoring version on each highlight so rule changes are traceable.
## Rendering Roadmap
The app can now play uploaded clip files, but it still does not render video directly from CS2 demos. The step-by-step implementation plan for real demo-to-video rendering is documented in [docs/rendering-from-cs2-demo.md](/home/hz/source/fragalytics/docs/rendering-from-cs2-demo.md).
## Assumptions
- This MVP is kill-centric. It does not yet score utility-only, positioning-only, or movement-only moments.
- The default parser is the deterministic sample adapter so the app is runnable offline and testable in this environment.
- Real CS2 parsing is expected to come from an external parser process wired through `IDemoParserAdapter`. That keeps the ASP.NET Core app stable even if the parsing implementation changes.
- The persistent store is intentionally simple for a local MVP. If this moves beyond single-node use, replace the store with SQLite or Postgres behind the same service boundary.
- Rendering is intentionally not implemented. Clip jobs are created as durable work intents for a future render worker.
Current status: the separate render-worker scaffold exists, but the only backend today is a stub that copies a known video file instead of driving CS2.
## Tests
```bash
DOTNET_CLI_HOME=/tmp/dotnet-home DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 dotnet run --project tests/Fragalytics.Web.Tests
```
## Extension path
- Replace the JSON store with SQLite or Postgres
- Move background processing into a separate worker process
- Add a real parser sidecar that emits the normalized `ParsedDemo` JSON contract
- Replace the `sample-copy` render backend with real CS2-driven capture
- Add richer event ingestion and rule editing in the UI
+12
View File
@@ -0,0 +1,12 @@
services:
web:
build: .
ports:
- "8080:8080"
environment:
Fragalytics__DataDirectory: /app/data
Fragalytics__UploadDirectory: /app/data/uploads
Fragalytics__Parser__Mode: sample
Fragalytics__AutoStartWorker: "true"
volumes:
- ./data:/app/data
+332
View File
@@ -0,0 +1,332 @@
# Rendering Clips From CS2 Demos
This document explains what still needs to happen for Fragalytics to render playable video clips directly from uploaded CS2 `.dem` files.
## Current State
What already works:
- Demo uploads
- Background parsing
- Highlight scoring
- Clip job creation with `start_tick` and `end_tick`
- Manual clip upload and in-page playback for `.mp4` and `.webm`
What does not exist yet:
- Automatic demo-to-video rendering
- A separate render worker process
- A real CS2 capture backend
- Camera automation
- Encoding pipeline
## Important Constraint
A `.dem` file is not video. It is replay data. To get a playable clip, the system must run Counter-Strike 2, load the demo, seek to the correct tick range, play the segment in-engine, capture the frames and audio, encode the output, and then attach the media file back to the clip job.
## Step-By-Step Plan
### 1. Define the render job contract
Goal:
Make clip jobs specific enough that an external worker can render them without guessing.
Needed work:
- Expand the clip job payload to include:
- `demoId`
- `matchId`
- `highlightId`
- `startTick`
- `endTick`
- `cameraMode`
- `outputFormat`
- `resolution`
- `fps`
- Decide which fields are required versus optional defaults
- Version the payload format so future workers can evolve safely
Recommended MVP default:
- `cameraMode = killer_pov`
- `outputFormat = mp4`
- `resolution = 1920x1080`
- `fps = 60`
### 2. Split rendering into a dedicated worker
Goal:
Keep the web app focused on uploads, scoring, storage, and playback while a separate process handles slow rendering work.
Needed work:
- Create a new render-worker project or service
- Make it poll for clip jobs with `status = pending`
- Add job state transitions:
- `pending`
- `rendering`
- `rendered`
- `failed`
- Add failure details to the clip job payload or model
- Ensure only one worker claims a job at a time
Recommended MVP default:
- Single worker process
- One render at a time
- Polling loop instead of a queue
### 3. Add a render backend abstraction
Goal:
Avoid hard-coding the first rendering strategy into the worker.
Needed work:
- Replace the current enqueue-only abstraction with a backend contract such as:
- `RenderAsync(clipJob, outputPath, cancellationToken)`
- Support backend selection by configuration
- Keep output handling generic so different capture approaches can write the final file
Recommended backend interface responsibilities:
- Prepare commands/scripts
- Launch capture
- Wait for completion
- Return output file metadata
### 4. Choose the real CS2 capture approach
Goal:
Pick one reliable rendering path instead of trying to support every option.
Practical options:
- Script CS2 directly with launch arguments and console commands
- Use a capture toolchain that drives CS2 and records output
- Use FFmpeg only for final encoding or post-processing, not for reading `.dem` files directly
Reality:
- FFmpeg alone cannot render a `.dem`
- The worker needs a machine with CS2 installed
- The worker usually needs desktop or GPU access to capture frames reliably
Recommended MVP choice:
- A dedicated Windows render machine
- CS2 installed and configured for automated demo playback
- A scripted capture path that outputs a raw or directly encoded video file
### 5. Implement deterministic demo playback control
Goal:
Make the worker reliably reproduce the requested clip boundaries.
Needed work:
- Open the correct demo file
- Load the replay inside CS2
- Seek near the `startTick`
- Stabilize playback before capture starts
- Capture until `endTick`
- Stop cleanly and close or recycle the game session
Risks to handle:
- Seeking may not land exactly on the intended frame
- HUD and camera state may need warm-up time
- Demo playback can behave differently across environments
Recommended MVP default:
- Add configurable pre-roll before `startTick`
- Trim the output afterward if needed
### 6. Choose the first camera mode
Goal:
Start with one camera behavior that is simple and defensible.
Possible camera modes:
- `killer_pov`
- `victim_pov`
- `spec_follow`
- `static_observer`
- `freecam_cinematic`
Recommended MVP default:
- `killer_pov`
Why:
- It aligns directly with scored kill highlights
- It is easier to reason about than cinematic camera logic
- It avoids building a director system too early
### 7. Capture or encode audio and video
Goal:
Produce a playable media file in a standard format.
Needed work:
- Decide whether capture outputs:
- directly encoded `.mp4`
- intermediate lossless file plus final encode
- Set consistent output settings:
- codec
- bitrate or CRF
- resolution
- framerate
- Decide how audio will be handled:
- game audio on
- game audio off
- no voice
- no music
Recommended MVP default:
- H.264 `.mp4`
- 1080p
- 60 fps
- gameplay audio only
### 8. Attach the rendered file back to Fragalytics
Goal:
Reuse the playback flow that already exists in the web app.
What already exists:
- Clip asset storage
- Clip-job-to-media attachment
- In-page HTML5 playback
Needed work:
- Let the render worker call the same clip-asset attach path used by manual uploads
- Mark the clip job as `rendered`
- Store file size, content type, and asset path
Recommended implementation:
- Expose an internal service or API endpoint that accepts:
- `clipJobId`
- rendered file stream
- file name
- content type
### 9. Add retries, timeouts, and failure logging
Goal:
Prevent broken jobs from blocking the worker forever.
Needed work:
- Add a render timeout per job
- Record failure reason
- Limit retries
- Preserve logs for:
- launched commands
- process exit codes
- capture duration
- output path
Recommended MVP default:
- One retry
- Clear failure message on the clip job
- Log files per render attempt
### 10. Add machine-level prerequisites
Goal:
Document what the render machine must have installed and configured.
Needed environment:
- Counter-Strike 2 installed
- Steam installed and logged in if required by the playback path
- GPU drivers
- Capture and encoding tooling
- Enough disk space for temporary video files
- A stable location for demo files and rendered output
Recommended operations setup:
- Separate render host from the web host
- Shared storage or an explicit file transfer step
- One dedicated account for rendering
## Suggested MVP Milestones
### Milestone 1: Worker skeleton
Deliverables:
- Render worker project
- Pending job polling
- Status transitions
- Stub backend that writes logs but no video
### Milestone 2: Automatic file attachment
Deliverables:
- Worker can attach a pre-rendered local file to a clip job
- No CS2 automation yet
- End-to-end job completion path verified
### Milestone 3: First real CS2 render
Deliverables:
- One camera mode
- One machine
- One output format
- One clip rendered automatically from a real `.dem`
### Milestone 4: Production hardening
Deliverables:
- Retry logic
- Timeouts
- Better logging
- Configurable render presets
- Concurrency controls
## Recommended First Implementation Order
If you want this built pragmatically, the order should be:
1. Add a dedicated render worker project.
2. Expand the clip job payload to fully describe a render request.
3. Add an internal attachment API or shared service so the worker can save finished media.
4. Implement a stub backend that completes jobs using a known local video file.
5. Replace the stub backend with a real CS2 capture backend on one dedicated machine.
6. Add retries, logs, and operational safeguards after the first real render works.
## What Not To Do First
Avoid these early mistakes:
- Do not start with multiple camera modes.
- Do not start with distributed workers.
- Do not treat FFmpeg as if it can render demos directly.
- Do not overbuild orchestration before one clip can render successfully end to end.
## Definition Of Done For Real Rendering
This feature is truly working when:
- A user uploads a `.dem`
- The app creates highlight clip jobs
- The render worker picks a pending job automatically
- CS2 playback is driven to the requested tick range
- A video file is produced without manual intervention
- The file is attached back to the job
- The clip becomes playable in the Fragalytics UI
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fragalytics.Web\Fragalytics.Web.csproj" />
</ItemGroup>
</Project>
+26
View File
@@ -0,0 +1,26 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Services;
using Fragalytics.RenderWorker.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
var builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddJsonFile("appsettings.json", optional: true);
builder.Services.Configure<FragalyticsOptions>(builder.Configuration.GetSection("Fragalytics"));
builder.Services.AddSingleton<JsonAppStore>();
builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<IClipRenderBackend>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
return options.RenderWorker.Backend.Equals("sample-copy", StringComparison.OrdinalIgnoreCase)
? new SampleCopyRenderBackend(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>())
: throw new InvalidOperationException($"Unknown render backend '{options.RenderWorker.Backend}'.");
});
builder.Services.AddSingleton<RenderCoordinator>();
builder.Services.AddHostedService<RenderWorkerHostedService>();
await builder.Build().RunAsync();
@@ -0,0 +1,16 @@
using Fragalytics.Web.Models;
namespace Fragalytics.RenderWorker.Services;
public interface IClipRenderBackend
{
string Name { get; }
Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken);
}
public sealed class RenderedClipResult
{
public string FilePath { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string ContentType { get; set; } = "video/mp4";
}
@@ -0,0 +1,116 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Fragalytics.Web.Services;
using Microsoft.Extensions.Options;
namespace Fragalytics.RenderWorker.Services;
public sealed class RenderCoordinator
{
private readonly JsonAppStore _store;
private readonly ClipMediaService _clipMediaService;
private readonly IClipRenderBackend _backend;
private readonly FragalyticsOptions _options;
public RenderCoordinator(
JsonAppStore store,
ClipMediaService clipMediaService,
IClipRenderBackend backend,
IOptions<FragalyticsOptions> options)
{
_store = store;
_clipMediaService = clipMediaService;
_backend = backend;
_options = options.Value;
}
public async Task<int> ProcessNextAsync(CancellationToken cancellationToken)
{
var claimedJob = await _store.UpdateAsync(state =>
{
var next = state.ClipJobs
.Where(job => string.Equals(job.Status, "pending", StringComparison.OrdinalIgnoreCase))
.OrderBy(job => job.CreatedAtUtc)
.FirstOrDefault();
if (next is null)
{
return null;
}
next.Status = "rendering";
next.Renderer = _backend.Name;
next.ErrorMessage = null;
next.StartedAtUtc = DateTime.UtcNow;
next.Payload["backend"] = _backend.Name;
return new ClaimedClipJob
{
ClipJobId = next.Id,
FileNameHint = $"highlight_{next.HighlightId}.mp4"
};
}, cancellationToken);
if (claimedJob is null)
{
return 0;
}
try
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var fullJob = state.ClipJobs.First(job => job.Id == claimedJob.ClipJobId);
var result = await _backend.RenderAsync(fullJob, cancellationToken);
await using (var stream = File.OpenRead(result.FilePath))
{
await _clipMediaService.AttachClipAssetAsync(
fullJob.Id,
string.IsNullOrWhiteSpace(result.FileName) ? claimedJob.FileNameHint : result.FileName,
result.ContentType,
stream,
cancellationToken);
}
if (File.Exists(result.FilePath))
{
File.Delete(result.FilePath);
}
await _store.UpdateAsync(stateUpdate =>
{
var job = stateUpdate.ClipJobs.First(item => item.Id == fullJob.Id);
job.Renderer = _backend.Name;
job.ErrorMessage = null;
job.CompletedAtUtc = DateTime.UtcNow;
return 0;
}, cancellationToken);
return 1;
}
catch (Exception exception)
{
await _store.UpdateAsync(state =>
{
var job = state.ClipJobs.First(item => item.Id == claimedJob.ClipJobId);
job.Status = "failed";
job.ErrorMessage = exception.Message;
job.CompletedAtUtc = DateTime.UtcNow;
job.Payload["failure"] = exception.Message;
return 0;
}, cancellationToken);
return 0;
}
}
public TimeSpan PollInterval => TimeSpan.FromSeconds(_options.RenderWorker.PollSeconds);
public bool RunContinuously => _options.RenderWorker.RunContinuously;
}
public sealed class ClaimedClipJob
{
public int ClipJobId { get; set; }
public string FileNameHint { get; set; } = string.Empty;
}
@@ -0,0 +1,28 @@
using Microsoft.Extensions.Hosting;
namespace Fragalytics.RenderWorker.Services;
public sealed class RenderWorkerHostedService : BackgroundService
{
private readonly RenderCoordinator _coordinator;
public RenderWorkerHostedService(RenderCoordinator coordinator)
{
_coordinator = coordinator;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_coordinator.RunContinuously)
{
await _coordinator.ProcessNextAsync(stoppingToken);
return;
}
while (!stoppingToken.IsCancellationRequested)
{
await _coordinator.ProcessNextAsync(stoppingToken);
await Task.Delay(_coordinator.PollInterval, stoppingToken);
}
}
}
@@ -0,0 +1,48 @@
using Fragalytics.Web;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.RenderWorker.Services;
public sealed class SampleCopyRenderBackend : IClipRenderBackend
{
private readonly FragalyticsOptions _options;
public SampleCopyRenderBackend(IOptions<FragalyticsOptions> options)
{
_options = options.Value;
}
public string Name => "sample-copy";
public async Task<RenderedClipResult> RenderAsync(ClipJobRecord clipJob, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.RenderWorker.SampleClipPath))
{
throw new InvalidOperationException("Fragalytics:RenderWorker:SampleClipPath is not configured.");
}
var sampleClipPath = Path.GetFullPath(_options.RenderWorker.SampleClipPath);
if (!File.Exists(sampleClipPath))
{
throw new FileNotFoundException("Configured sample clip was not found.", sampleClipPath);
}
var tempDirectory = Path.GetFullPath(_options.RenderWorker.TempDirectory);
Directory.CreateDirectory(tempDirectory);
var extension = Path.GetExtension(sampleClipPath);
var outputPath = Path.Combine(tempDirectory, $"clipjob_{clipJob.Id}_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}{extension}");
await using var input = File.OpenRead(sampleClipPath);
await using var output = File.Create(outputPath);
await input.CopyToAsync(output, cancellationToken);
return new RenderedClipResult
{
FilePath = outputPath,
FileName = Path.GetFileName(sampleClipPath),
ContentType = extension.Equals(".webm", StringComparison.OrdinalIgnoreCase) ? "video/webm" : "video/mp4"
};
}
}
@@ -0,0 +1,13 @@
{
"Fragalytics": {
"DataDirectory": "data",
"UploadDirectory": "data/uploads",
"RenderWorker": {
"PollSeconds": 3,
"Backend": "sample-copy",
"SampleClipPath": "",
"TempDirectory": "data/render-temp",
"RunContinuously": true
}
}
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+27
View File
@@ -0,0 +1,27 @@
namespace Fragalytics.Web;
public sealed class FragalyticsOptions
{
public string DataDirectory { get; set; } = "data";
public string UploadDirectory { get; set; } = "data/uploads";
public int WorkerPollSeconds { get; set; } = 2;
public bool AutoStartWorker { get; set; } = true;
public ParserOptions Parser { get; set; } = new();
public RenderWorkerOptions RenderWorker { get; set; } = new();
}
public sealed class ParserOptions
{
public string Mode { get; set; } = "sample";
public string? ExternalCommand { get; set; }
public string? ExternalArguments { get; set; }
}
public sealed class RenderWorkerOptions
{
public int PollSeconds { get; set; } = 3;
public string Backend { get; set; } = "sample-copy";
public string? SampleClipPath { get; set; }
public string TempDirectory { get; set; } = "data/render-temp";
public bool RunContinuously { get; set; } = true;
}
@@ -0,0 +1,76 @@
using System.Text.Json;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Infrastructure;
public sealed class JsonAppStore
{
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly string _storePath;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public JsonAppStore(IOptions<FragalyticsOptions> options)
{
var root = Path.GetFullPath(options.Value.DataDirectory);
Directory.CreateDirectory(root);
_storePath = Path.Combine(root, "store.json");
}
public async Task<AppState> GetSnapshotAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
var state = await LoadUnsafeAsync(cancellationToken);
return DeepClone(state);
}
finally
{
_gate.Release();
}
}
public async Task<T> UpdateAsync<T>(Func<AppState, T> update, CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken);
try
{
var state = await LoadUnsafeAsync(cancellationToken);
var result = update(state);
await SaveUnsafeAsync(state, cancellationToken);
return result;
}
finally
{
_gate.Release();
}
}
private async Task<AppState> LoadUnsafeAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_storePath))
{
return new AppState();
}
await using var stream = File.OpenRead(_storePath);
return await JsonSerializer.DeserializeAsync<AppState>(stream, _jsonOptions, cancellationToken)
?? new AppState();
}
private async Task SaveUnsafeAsync(AppState state, CancellationToken cancellationToken)
{
await using var stream = File.Create(_storePath);
await JsonSerializer.SerializeAsync(stream, state, _jsonOptions, cancellationToken);
}
private AppState DeepClone(AppState state)
{
var json = JsonSerializer.Serialize(state, _jsonOptions);
return JsonSerializer.Deserialize<AppState>(json, _jsonOptions) ?? new AppState();
}
}
+170
View File
@@ -0,0 +1,170 @@
namespace Fragalytics.Web.Models;
public sealed class AppState
{
public int NextDemoId { get; set; } = 1;
public int NextMatchId { get; set; } = 1;
public int NextPlayerId { get; set; } = 1;
public int NextRoundId { get; set; } = 1;
public int NextEventId { get; set; } = 1;
public int NextHighlightId { get; set; } = 1;
public int NextClipJobId { get; set; } = 1;
public int NextClipAssetId { get; set; } = 1;
public int NextScoringProfileId { get; set; } = 1;
public List<DemoRecord> Demos { get; set; } = [];
public List<MatchRecord> Matches { get; set; } = [];
public List<PlayerRecord> Players { get; set; } = [];
public List<RoundRecord> Rounds { get; set; } = [];
public List<EventRecord> Events { get; set; } = [];
public List<HighlightRecord> Highlights { get; set; } = [];
public List<ClipJobRecord> ClipJobs { get; set; } = [];
public List<ClipAssetRecord> ClipAssets { get; set; } = [];
public List<ScoringProfileRecord> ScoringProfiles { get; set; } = [];
}
public sealed class DemoRecord
{
public int Id { get; set; }
public string OriginalFileName { get; set; } = string.Empty;
public string StoredPath { get; set; } = string.Empty;
public string ParserBackend { get; set; } = string.Empty;
public string Status { get; set; } = "uploaded";
public string? ErrorMessage { get; set; }
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? ProcessedAtUtc { get; set; }
public int? MatchId { get; set; }
}
public sealed class MatchRecord
{
public int Id { get; set; }
public int DemoId { get; set; }
public string? MapName { get; set; }
public string? ServerName { get; set; }
public int? TickRate { get; set; }
public int TotalRounds { get; set; }
public string? TeamCtName { get; set; }
public string? TeamTName { get; set; }
public DateTime ParsedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class PlayerRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public string SteamId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TeamName { get; set; }
public string? Side { get; set; }
}
public sealed class RoundRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int Number { get; set; }
public int? StartTick { get; set; }
public int? EndTick { get; set; }
public string? WinnerSide { get; set; }
public string? WinnerTeamName { get; set; }
public string? Reason { get; set; }
}
public sealed class EventRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int? RoundId { get; set; }
public string EventType { get; set; } = "kill";
public int Tick { get; set; }
public double? Second { get; set; }
public int? ActorPlayerId { get; set; }
public int? TargetPlayerId { get; set; }
public string? Weapon { get; set; }
public bool IsHeadshot { get; set; }
public bool IsWallbang { get; set; }
public bool IsSmokeKill { get; set; }
public int? KillerAliveCount { get; set; }
public int? EnemyAliveCount { get; set; }
public string? KillerNameSnapshot { get; set; }
public string? VictimNameSnapshot { get; set; }
}
public sealed class HighlightRecord
{
public int Id { get; set; }
public int MatchId { get; set; }
public int? RoundId { get; set; }
public int SourceEventId { get; set; }
public double Score { get; set; }
public List<string> Tags { get; set; } = [];
public List<ScoreExplanationRecord> Explanation { get; set; } = [];
public string ScoringVersion { get; set; } = string.Empty;
public int StartTick { get; set; }
public int EndTick { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class ScoreExplanationRecord
{
public string Reason { get; set; } = string.Empty;
public double Value { get; set; }
public string Detail { get; set; } = string.Empty;
}
public sealed class ClipJobRecord
{
public int Id { get; set; }
public int HighlightId { get; set; }
public string Renderer { get; set; } = "future-renderer";
public string Status { get; set; } = "pending";
public int StartTick { get; set; }
public int EndTick { get; set; }
public Dictionary<string, string> Payload { get; set; } = [];
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int? ClipAssetId { get; set; }
public string? ErrorMessage { get; set; }
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
}
public sealed class ClipAssetRecord
{
public int Id { get; set; }
public int ClipJobId { get; set; }
public string FileName { get; set; } = string.Empty;
public string StoredPath { get; set; } = string.Empty;
public string ContentType { get; set; } = "video/mp4";
public long FileSizeBytes { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}
public sealed class ScoringProfileRecord
{
public int Id { get; set; }
public string Name { get; set; } = "default";
public string Version { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public HighlightScoringRules Rules { get; set; } = new();
}
public sealed class HighlightScoringRules
{
public string Name { get; set; } = "default";
public Dictionary<string, double> Weights { get; set; } = new();
public ClipWindowRules Clip { get; set; } = new();
public ScoreThresholdRules Thresholds { get; set; } = new();
}
public sealed class ClipWindowRules
{
public int PreTicks { get; set; } = 160;
public int PostTicks { get; set; } = 96;
}
public sealed class ScoreThresholdRules
{
public double HighlightScoreMin { get; set; } = 18;
}
+50
View File
@@ -0,0 +1,50 @@
namespace Fragalytics.Web.Models;
public sealed class ParsedDemo
{
public string? MapName { get; set; }
public string? ServerName { get; set; }
public int? TickRate { get; set; }
public string? TeamCtName { get; set; }
public string? TeamTName { get; set; }
public List<ParsedPlayer> Players { get; set; } = [];
public List<ParsedRound> Rounds { get; set; } = [];
public List<ParsedKillEvent> KillEvents { get; set; } = [];
}
public sealed class ParsedPlayer
{
public string SteamId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? TeamName { get; set; }
public string? Side { get; set; }
}
public sealed class ParsedRound
{
public int Number { get; set; }
public int? StartTick { get; set; }
public int? EndTick { get; set; }
public string? WinnerSide { get; set; }
public string? WinnerTeamName { get; set; }
public string? Reason { get; set; }
}
public sealed class ParsedKillEvent
{
public int Tick { get; set; }
public double? Second { get; set; }
public int RoundNumber { get; set; }
public string? KillerSteamId { get; set; }
public string? KillerName { get; set; }
public string? KillerTeam { get; set; }
public string? VictimSteamId { get; set; }
public string? VictimName { get; set; }
public string? VictimTeam { get; set; }
public string? Weapon { get; set; }
public bool IsHeadshot { get; set; }
public bool IsWallbang { get; set; }
public bool IsSmokeKill { get; set; }
public int? KillerAliveCount { get; set; }
public int? EnemyAliveCount { get; set; }
}
@@ -0,0 +1,51 @@
@page "/clip-jobs"
@model Fragalytics.Web.Pages.ClipJobs.IndexModel
@{
ViewData["Title"] = "Clip Jobs";
}
<section class="card">
<h1>Clip Jobs</h1>
<p class="muted">You can manually attach a rendered `.mp4` or `.webm` file to any clip job. Once attached, the clip is playable in the page and on related highlight views.</p>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Renderer</th><th>Ticks</th><th>Playback</th><th>Upload</th><th>Payload</th></tr></thead>
<tbody>
@if (Model.ClipJobs.Count == 0)
{
<tr><td colspan="7" class="muted">No clip jobs created yet.</td></tr>
}
else
{
@foreach (var row in Model.ClipJobs)
{
<tr>
<td>#@row.ClipJob.Id</td>
<td><span class="pill [email protected]">@row.ClipJob.Status</span></td>
<td>@row.ClipJob.Renderer</td>
<td>@row.ClipJob.StartTick - @row.ClipJob.EndTick</td>
<td>
@if (row.ClipAsset is null)
{
<span class="muted">Not available</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
<td>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadClip">
<input type="hidden" name="clipJobId" value="@row.ClipJob.Id" />
<input type="file" name="clipUpload" accept=".mp4,.webm" required />
<button type="submit" class="secondary">Attach Clip</button>
</form>
</td>
<td><code>@string.Join(", ", row.ClipJob.Payload.Select(item => $"{item.Key}={item.Value}"))</code></td>
</tr>
}
}
</tbody>
</table>
</section>
@@ -0,0 +1,48 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.ClipJobs;
public sealed class IndexModel : PageModel
{
private readonly QueryService _queryService;
private readonly ClipMediaService _clipMediaService;
public IndexModel(QueryService queryService, ClipMediaService clipMediaService)
{
_queryService = queryService;
_clipMediaService = clipMediaService;
}
public List<ClipJobWithHighlightViewModel> ClipJobs { get; private set; } = [];
public async Task OnGetAsync(CancellationToken cancellationToken)
{
ClipJobs = await _queryService.GetClipJobsAsync(cancellationToken);
}
public async Task<IActionResult> OnPostUploadClipAsync(int clipJobId, IFormFile? clipUpload, CancellationToken cancellationToken)
{
if (clipUpload is null || clipUpload.Length == 0)
{
return RedirectToPage();
}
await using var stream = clipUpload.OpenReadStream();
await _clipMediaService.AttachClipAssetAsync(clipJobId, clipUpload.FileName, clipUpload.ContentType, stream, cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnGetMediaAsync(int clipJobId, CancellationToken cancellationToken)
{
var asset = await _queryService.GetClipAssetAsync(clipJobId, cancellationToken);
if (asset is null)
{
return NotFound();
}
var stream = System.IO.File.OpenRead(asset.Asset.StoredPath);
return File(stream, asset.ContentType);
}
}
@@ -0,0 +1,88 @@
@page "/demos/{id:int}"
@model Fragalytics.Web.Pages.Demos.DetailsModel
@{
ViewData["Title"] = $"Demo #{Model.ViewModel?.Demo.Id}";
}
@if (Model.ViewModel is null)
{
<section class="card"><h1>Demo not found</h1></section>
}
else
{
<section class="card">
<div class="section-head">
<div>
<h1>Demo #@Model.ViewModel.Demo.Id</h1>
<p class="muted">@Model.ViewModel.Demo.OriginalFileName</p>
</div>
<span class="pill [email protected]">@Model.ViewModel.Demo.Status</span>
</div>
<dl class="meta-grid">
<div><dt>Parser</dt><dd>@Model.ViewModel.Demo.ParserBackend</dd></div>
<div><dt>Uploaded</dt><dd>@Model.ViewModel.Demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</dd></div>
<div><dt>Stored Path</dt><dd>@Model.ViewModel.Demo.StoredPath</dd></div>
@if (!string.IsNullOrWhiteSpace(Model.ViewModel.Demo.ErrorMessage))
{
<div><dt>Error</dt><dd>@Model.ViewModel.Demo.ErrorMessage</dd></div>
}
</dl>
</section>
@if (Model.ViewModel.Match is not null)
{
<section class="card">
<div class="section-head">
<div>
<h2>Parsed Match</h2>
<p class="muted">@Model.ViewModel.Match.MapName</p>
</div>
<a class="button-link" asp-page="/Matches/Details" asp-route-id="@Model.ViewModel.Match.Id">Open Match Details</a>
</div>
<dl class="meta-grid">
<div><dt>Rounds</dt><dd>@Model.ViewModel.Match.TotalRounds</dd></div>
<div><dt>Highlights</dt><dd>@Model.ViewModel.Highlights.Count</dd></div>
<div><dt>Tick Rate</dt><dd>@(Model.ViewModel.Match.TickRate?.ToString() ?? "Unknown")</dd></div>
<div><dt>Server</dt><dd>@(Model.ViewModel.Match.ServerName ?? "Unknown")</dd></div>
</dl>
</section>
<section class="card">
<h2>Top Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Clip</th><th>Playback</th></tr></thead>
<tbody>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="6" class="muted">No highlights scored above threshold.</td></tr>
}
else
{
@foreach (var row in Model.ViewModel.Highlights.Take(10))
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot)</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>@row.Highlight.StartTick - @row.Highlight.EndTick</td>
<td>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
}
}
</tbody>
</table>
</section>
}
}
@@ -0,0 +1,23 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.Demos;
public sealed class DetailsModel : PageModel
{
private readonly QueryService _queryService;
public DetailsModel(QueryService queryService)
{
_queryService = queryService;
}
public DemoDetailsViewModel? ViewModel { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken cancellationToken)
{
ViewModel = await _queryService.GetDemoDetailsAsync(id, cancellationToken);
return ViewModel is null ? NotFound() : Page();
}
}
+85
View File
@@ -0,0 +1,85 @@
@page
@model Fragalytics.Web.Pages.IndexModel
@{
ViewData["Title"] = "Demos";
}
<section class="hero card">
<div>
<h1>Upload a CS2 demo</h1>
<p class="muted">Uploads are stored locally, parsed in the background, scored into explainable highlights, and converted into clip job records for a future render worker.</p>
</div>
<form class="upload-form" method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<input type="file" asp-for="Upload" accept=".dem" required />
<button type="submit">Upload Demo</button>
</form>
</section>
<section class="stats-grid">
<article class="card"><h2>@Model.Dashboard.DemoCount</h2><p>Demos</p></article>
<article class="card"><h2>@Model.Dashboard.MatchCount</h2><p>Matches</p></article>
<article class="card"><h2>@Model.Dashboard.HighlightCount</h2><p>Highlights</p></article>
<article class="card"><h2>@Model.Dashboard.ClipJobCount</h2><p>Clip Jobs</p></article>
</section>
<section class="split">
<div class="card">
<div class="section-head">
<h2>Recent Demos</h2>
<form method="post" asp-page-handler="RunWorker">
<button type="submit" class="secondary">Run Worker Once</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>File</th><th>Status</th><th>Uploaded</th></tr></thead>
<tbody>
@if (Model.Dashboard.Demos.Count == 0)
{
<tr><td colspan="4" class="muted">No demos uploaded yet.</td></tr>
}
else
{
@foreach (var demo in Model.Dashboard.Demos)
{
<tr>
<td><a asp-page="/Demos/Details" asp-route-id="@demo.Id">#@demo.Id</a></td>
<td>@demo.OriginalFileName</td>
<td><span class="pill [email protected]">@demo.Status</span></td>
<td>@demo.UploadedAtUtc.ToString("yyyy-MM-dd HH:mm:ss") UTC</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="card">
<div class="section-head">
<h2>Recent Clip Jobs</h2>
<form method="post" asp-page-handler="Rescore">
<button type="submit" class="secondary">Rescore All Matches</button>
</form>
</div>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Ticks</th><th>Renderer</th></tr></thead>
<tbody>
@if (Model.Dashboard.ClipJobs.Count == 0)
{
<tr><td colspan="4" class="muted">No clip jobs yet.</td></tr>
}
else
{
@foreach (var job in Model.Dashboard.ClipJobs)
{
<tr>
<td><a asp-page="/ClipJobs/Index">#@job.Id</a></td>
<td><span class="pill [email protected]">@job.Status</span></td>
<td>@job.StartTick - @job.EndTick</td>
<td>@job.Renderer</td>
</tr>
}
}
</tbody>
</table>
</div>
</section>
+51
View File
@@ -0,0 +1,51 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages;
public sealed class IndexModel : PageModel
{
private readonly QueryService _queryService;
private readonly DemoIngestService _demoIngestService;
public IndexModel(QueryService queryService, DemoIngestService demoIngestService)
{
_queryService = queryService;
_demoIngestService = demoIngestService;
}
[BindProperty]
public IFormFile? Upload { get; set; }
public DashboardViewModel Dashboard { get; private set; } = new();
public async Task OnGetAsync(CancellationToken cancellationToken)
{
Dashboard = await _queryService.GetDashboardAsync(cancellationToken);
}
public async Task<IActionResult> OnPostUploadAsync(CancellationToken cancellationToken)
{
if (Upload is null || string.IsNullOrWhiteSpace(Upload.FileName) || !Upload.FileName.EndsWith(".dem", StringComparison.OrdinalIgnoreCase))
{
return RedirectToPage();
}
await using var stream = Upload.OpenReadStream();
var id = await _demoIngestService.SaveUploadAsync(Upload.FileName, stream, cancellationToken);
return RedirectToPage("/Demos/Details", new { id });
}
public async Task<IActionResult> OnPostRunWorkerAsync(CancellationToken cancellationToken)
{
await _demoIngestService.ProcessNextAsync(cancellationToken);
return RedirectToPage();
}
public async Task<IActionResult> OnPostRescoreAsync(CancellationToken cancellationToken)
{
await _demoIngestService.RescoreAllAsync(cancellationToken);
return RedirectToPage();
}
}
@@ -0,0 +1,118 @@
@page "/matches/{id:int}"
@model Fragalytics.Web.Pages.Matches.DetailsModel
@{
ViewData["Title"] = $"Match #{Model.ViewModel?.Match.Id}";
}
@if (Model.ViewModel is null)
{
<section class="card"><h1>Match not found</h1></section>
}
else
{
<section class="card">
<div class="section-head">
<div>
<h1>Match #@Model.ViewModel.Match.Id</h1>
<p class="muted">@Model.ViewModel.Match.MapName on @(Model.ViewModel.Match.ServerName ?? "Unknown server")</p>
</div>
<a class="button-link" asp-page="/Demos/Details" asp-route-id="@Model.ViewModel.Match.DemoId">Back to Demo</a>
</div>
<dl class="meta-grid">
<div><dt>Rounds</dt><dd>@Model.ViewModel.Match.TotalRounds</dd></div>
<div><dt>Players</dt><dd>@Model.ViewModel.Players.Count</dd></div>
<div><dt>Highlights</dt><dd>@Model.ViewModel.Highlights.Count</dd></div>
<div><dt>Tick Rate</dt><dd>@(Model.ViewModel.Match.TickRate?.ToString() ?? "Unknown")</dd></div>
</dl>
</section>
<section class="split">
<div class="card">
<h2>Players</h2>
<table>
<thead><tr><th>Name</th><th>Steam ID</th><th>Team</th><th>Side</th></tr></thead>
<tbody>
@foreach (var player in Model.ViewModel.Players)
{
<tr>
<td>@player.Name</td>
<td>@player.SteamId</td>
<td>@(player.TeamName ?? "-")</td>
<td>@(player.Side ?? "-")</td>
</tr>
}
</tbody>
</table>
</div>
<div class="card">
<h2>Rounds</h2>
<table>
<thead><tr><th>#</th><th>Winner</th><th>Reason</th><th>Ticks</th></tr></thead>
<tbody>
@foreach (var round in Model.ViewModel.Rounds)
{
<tr>
<td>@round.Number</td>
<td>@(round.WinnerSide ?? "-")</td>
<td>@(round.Reason ?? "-")</td>
<td>@(round.StartTick ?? 0) - @(round.EndTick ?? 0)</td>
</tr>
}
</tbody>
</table>
</div>
</section>
<section class="card">
<h2>Highlights</h2>
<table>
<thead><tr><th>Score</th><th>Round</th><th>Moment</th><th>Tags</th><th>Why</th><th>Clip Job</th><th>Playback</th></tr></thead>
<tbody>
@if (Model.ViewModel.Highlights.Count == 0)
{
<tr><td colspan="7" class="muted">No highlights available.</td></tr>
}
else
{
@foreach (var row in Model.ViewModel.Highlights)
{
<tr>
<td>@row.Highlight.Score.ToString("0.0")</td>
<td>@(row.Round?.Number.ToString() ?? "-")</td>
<td>@(row.Actor?.Name ?? row.Event.KillerNameSnapshot) eliminated @(row.Target?.Name ?? row.Event.VictimNameSnapshot) at tick @row.Event.Tick</td>
<td>@string.Join(", ", row.Highlight.Tags)</td>
<td>
@foreach (var item in row.Highlight.Explanation)
{
<div>@item.Reason ([email protected])</div>
}
</td>
<td>
@if (row.ClipJob is null)
{
<text>-</text>
}
else
{
<text>#@row.ClipJob.Id @row.ClipJob.Status<br />@row.ClipJob.StartTick - @row.ClipJob.EndTick</text>
}
</td>
<td>
@if (row.ClipAsset is null || row.ClipJob is null)
{
<span class="muted">None</span>
}
else
{
<video class="clip-player" controls preload="metadata">
<source src="/clip-jobs?handler=Media&amp;[email protected]" type="@row.ClipAsset.ContentType" />
</video>
}
</td>
</tr>
}
}
</tbody>
</table>
</section>
}
@@ -0,0 +1,23 @@
using Fragalytics.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace Fragalytics.Web.Pages.Matches;
public sealed class DetailsModel : PageModel
{
private readonly QueryService _queryService;
public DetailsModel(QueryService queryService)
{
_queryService = queryService;
}
public MatchDetailsViewModel? ViewModel { get; private set; }
public async Task<IActionResult> OnGetAsync(int id, CancellationToken cancellationToken)
{
ViewModel = await _queryService.GetMatchDetailsAsync(id, cancellationToken);
return ViewModel is null ? NotFound() : Page();
}
}
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - Fragalytics</title>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>
<header class="site-header">
<div>
<a class="brand" asp-page="/Index">Fragalytics</a>
<p class="muted">ASP.NET Core MVP for self-hosted CS2 demo analysis</p>
</div>
<nav class="nav">
<a asp-page="/Index">Demos</a>
<a asp-page="/ClipJobs/Index">Clip Jobs</a>
</nav>
</header>
<main class="page">
@RenderBody()
</main>
</body>
</html>
@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
@@ -0,0 +1,3 @@
@using Fragalytics.Web
@namespace Fragalytics.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}
+44
View File
@@ -0,0 +1,44 @@
using Fragalytics.Web;
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Services;
using Fragalytics.Web.Services.Parser;
using Fragalytics.Web.Services.Render;
using Fragalytics.Web.Services.Scoring;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<FragalyticsOptions>(builder.Configuration.GetSection("Fragalytics"));
builder.Services.AddRazorPages();
builder.Services.AddSingleton<JsonAppStore>();
builder.Services.AddSingleton<ScoringRulesProvider>();
builder.Services.AddSingleton<IRenderWorkerAdapter, NoOpRenderWorkerAdapter>();
builder.Services.AddSingleton<IDemoParserAdapter>(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>().Value;
return string.Equals(options.Parser.Mode, "external", StringComparison.OrdinalIgnoreCase)
? new ExternalProcessParserAdapter(serviceProvider.GetRequiredService<IOptions<FragalyticsOptions>>())
: new SampleDemoParserAdapter();
});
builder.Services.AddSingleton<MatchScoringService>();
builder.Services.AddSingleton<DemoIngestService>();
builder.Services.AddSingleton<ClipMediaService>();
builder.Services.AddSingleton<QueryService>();
builder.Services.AddHostedService<DemoProcessingWorker>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseStaticFiles();
app.UseRouting();
app.MapRazorPages();
app.Run();
public partial class Program
{
}
@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:0",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,91 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class ClipMediaService
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4",
".webm"
};
private readonly JsonAppStore _store;
private readonly FragalyticsOptions _options;
public ClipMediaService(JsonAppStore store, IOptions<FragalyticsOptions> options)
{
_store = store;
_options = options.Value;
}
public async Task<int> AttachClipAssetAsync(
int clipJobId,
string fileName,
string? contentType,
Stream input,
CancellationToken cancellationToken)
{
var extension = Path.GetExtension(fileName);
if (!AllowedExtensions.Contains(extension))
{
throw new InvalidOperationException("Only .mp4 and .webm clip uploads are supported.");
}
var clipsDirectory = Path.Combine(Path.GetFullPath(_options.DataDirectory), "clips");
Directory.CreateDirectory(clipsDirectory);
var safeFileName = Path.GetFileName(fileName);
var storedName = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{clipJobId}{extension}";
var storedPath = Path.Combine(clipsDirectory, storedName);
long fileSize;
await using (var output = File.Create(storedPath))
{
await input.CopyToAsync(output, cancellationToken);
fileSize = output.Length;
}
return await _store.UpdateAsync(state =>
{
var clipJob = state.ClipJobs.FirstOrDefault(item => item.Id == clipJobId)
?? throw new InvalidOperationException("Clip job not found.");
if (clipJob.ClipAssetId is int existingAssetId)
{
var existingAsset = state.ClipAssets.FirstOrDefault(item => item.Id == existingAssetId);
if (existingAsset is not null && File.Exists(existingAsset.StoredPath))
{
File.Delete(existingAsset.StoredPath);
}
state.ClipAssets.RemoveAll(item => item.Id == existingAssetId);
}
var asset = new ClipAssetRecord
{
Id = state.NextClipAssetId++,
ClipJobId = clipJobId,
FileName = safeFileName,
StoredPath = storedPath,
ContentType = string.IsNullOrWhiteSpace(contentType) ? InferContentType(extension) : contentType,
FileSizeBytes = fileSize
};
state.ClipAssets.Add(asset);
clipJob.ClipAssetId = asset.Id;
clipJob.Status = "rendered";
clipJob.ErrorMessage = null;
clipJob.CompletedAtUtc = DateTime.UtcNow;
clipJob.Payload["playback"] = "available";
return asset.Id;
}, cancellationToken);
}
private static string InferContentType(string extension) => extension.ToLowerInvariant() switch
{
".webm" => "video/webm",
_ => "video/mp4"
};
}
@@ -0,0 +1,229 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Fragalytics.Web.Services.Parser;
using Fragalytics.Web.Services.Scoring;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class DemoIngestService
{
private readonly JsonAppStore _store;
private readonly FragalyticsOptions _options;
private readonly IDemoParserAdapter _parserAdapter;
private readonly MatchScoringService _matchScoringService;
public DemoIngestService(
JsonAppStore store,
IOptions<FragalyticsOptions> options,
IDemoParserAdapter parserAdapter,
MatchScoringService matchScoringService)
{
_store = store;
_options = options.Value;
_parserAdapter = parserAdapter;
_matchScoringService = matchScoringService;
}
public async Task<int> SaveUploadAsync(string fileName, Stream input, CancellationToken cancellationToken)
{
var safeName = Path.GetFileName(fileName);
var uploadDirectory = Path.GetFullPath(_options.UploadDirectory);
Directory.CreateDirectory(uploadDirectory);
var storedName = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{safeName}";
var storedPath = Path.Combine(uploadDirectory, storedName);
await using (var output = File.Create(storedPath))
{
await input.CopyToAsync(output, cancellationToken);
}
return await _store.UpdateAsync(state =>
{
var demo = new DemoRecord
{
Id = state.NextDemoId++,
OriginalFileName = safeName,
StoredPath = storedPath,
ParserBackend = _parserAdapter.Name,
Status = "uploaded",
UploadedAtUtc = DateTime.UtcNow
};
state.Demos.Add(demo);
return demo.Id;
}, cancellationToken);
}
public async Task<int> ProcessNextAsync(CancellationToken cancellationToken)
{
var nextDemo = await _store.UpdateAsync(state =>
{
var demo = state.Demos
.Where(candidate => candidate.Status is "uploaded" or "queued")
.OrderBy(candidate => candidate.UploadedAtUtc)
.FirstOrDefault();
if (demo is null)
{
return null;
}
demo.Status = "processing";
demo.ErrorMessage = null;
return new DemoRecord
{
Id = demo.Id,
OriginalFileName = demo.OriginalFileName,
StoredPath = demo.StoredPath,
ParserBackend = demo.ParserBackend
};
}, cancellationToken);
if (nextDemo is null)
{
return 0;
}
try
{
var parsed = await _parserAdapter.ParseAsync(nextDemo.StoredPath, cancellationToken);
await _store.UpdateAsync(state =>
{
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
DeleteExistingMatchGraph(state, demo.MatchId);
var match = new MatchRecord
{
Id = state.NextMatchId++,
DemoId = demo.Id,
MapName = parsed.MapName,
ServerName = parsed.ServerName,
TickRate = parsed.TickRate,
TotalRounds = parsed.Rounds.Count,
TeamCtName = parsed.TeamCtName,
TeamTName = parsed.TeamTName
};
state.Matches.Add(match);
demo.MatchId = match.Id;
var playersBySteam = new Dictionary<string, PlayerRecord>(StringComparer.Ordinal);
foreach (var parsedPlayer in parsed.Players)
{
var player = new PlayerRecord
{
Id = state.NextPlayerId++,
MatchId = match.Id,
SteamId = parsedPlayer.SteamId,
Name = parsedPlayer.Name,
TeamName = parsedPlayer.TeamName,
Side = parsedPlayer.Side
};
state.Players.Add(player);
playersBySteam[player.SteamId] = player;
}
var roundsByNumber = new Dictionary<int, RoundRecord>();
foreach (var parsedRound in parsed.Rounds)
{
var round = new RoundRecord
{
Id = state.NextRoundId++,
MatchId = match.Id,
Number = parsedRound.Number,
StartTick = parsedRound.StartTick,
EndTick = parsedRound.EndTick,
WinnerSide = parsedRound.WinnerSide,
WinnerTeamName = parsedRound.WinnerTeamName,
Reason = parsedRound.Reason
};
state.Rounds.Add(round);
roundsByNumber[round.Number] = round;
}
foreach (var parsedKill in parsed.KillEvents)
{
playersBySteam.TryGetValue(parsedKill.KillerSteamId ?? string.Empty, out var killer);
playersBySteam.TryGetValue(parsedKill.VictimSteamId ?? string.Empty, out var victim);
roundsByNumber.TryGetValue(parsedKill.RoundNumber, out var round);
state.Events.Add(new EventRecord
{
Id = state.NextEventId++,
MatchId = match.Id,
RoundId = round?.Id,
EventType = "kill",
Tick = parsedKill.Tick,
Second = parsedKill.Second,
ActorPlayerId = killer?.Id,
TargetPlayerId = victim?.Id,
Weapon = parsedKill.Weapon,
IsHeadshot = parsedKill.IsHeadshot,
IsWallbang = parsedKill.IsWallbang,
IsSmokeKill = parsedKill.IsSmokeKill,
KillerAliveCount = parsedKill.KillerAliveCount,
EnemyAliveCount = parsedKill.EnemyAliveCount,
KillerNameSnapshot = parsedKill.KillerName,
VictimNameSnapshot = parsedKill.VictimName
});
}
_matchScoringService.RescoreMatch(state, match);
demo.Status = "parsed";
demo.ErrorMessage = null;
demo.ProcessedAtUtc = DateTime.UtcNow;
return 1;
}, cancellationToken);
return 1;
}
catch (Exception exception)
{
await _store.UpdateAsync(state =>
{
var demo = state.Demos.First(item => item.Id == nextDemo.Id);
demo.Status = "failed";
demo.ErrorMessage = exception.Message;
demo.ProcessedAtUtc = DateTime.UtcNow;
return 0;
}, cancellationToken);
return 0;
}
}
public async Task<int> RescoreAllAsync(CancellationToken cancellationToken)
{
return await _store.UpdateAsync(state =>
{
var matches = state.Matches.ToList();
var rescored = 0;
foreach (var match in matches)
{
rescored += _matchScoringService.RescoreMatch(state, match);
}
return rescored;
}, cancellationToken);
}
private static void DeleteExistingMatchGraph(AppState state, int? matchId)
{
if (matchId is null)
{
return;
}
var highlightIds = state.Highlights.Where(item => item.MatchId == matchId).Select(item => item.Id).ToHashSet();
var clipAssetIds = state.ClipJobs.Where(item => highlightIds.Contains(item.HighlightId) && item.ClipAssetId.HasValue)
.Select(item => item.ClipAssetId!.Value)
.ToHashSet();
state.ClipJobs.RemoveAll(item => highlightIds.Contains(item.HighlightId));
state.ClipAssets.RemoveAll(item => clipAssetIds.Contains(item.Id));
state.Highlights.RemoveAll(item => item.MatchId == matchId);
state.Events.RemoveAll(item => item.MatchId == matchId);
state.Rounds.RemoveAll(item => item.MatchId == matchId);
state.Players.RemoveAll(item => item.MatchId == matchId);
state.Matches.RemoveAll(item => item.Id == matchId);
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services;
public sealed class DemoProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<FragalyticsOptions> _options;
public DemoProcessingWorker(IServiceScopeFactory scopeFactory, IOptions<FragalyticsOptions> options)
{
_scopeFactory = scopeFactory;
_options = options;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Value.AutoStartWorker)
{
return;
}
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var ingestService = scope.ServiceProvider.GetRequiredService<DemoIngestService>();
await ingestService.ProcessNextAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(_options.Value.WorkerPollSeconds), stoppingToken);
}
}
}
@@ -0,0 +1,54 @@
using System.Diagnostics;
using System.Text.Json;
using Fragalytics.Web.Models;
using Microsoft.Extensions.Options;
namespace Fragalytics.Web.Services.Parser;
public sealed class ExternalProcessParserAdapter : IDemoParserAdapter
{
private readonly ParserOptions _options;
public ExternalProcessParserAdapter(IOptions<FragalyticsOptions> options)
{
_options = options.Value.Parser;
}
public string Name => "external-process";
public async Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_options.ExternalCommand))
{
throw new InvalidOperationException("Parser:ExternalCommand is not configured.");
}
var argumentsTemplate = _options.ExternalArguments ?? "\"{demoPath}\"";
var arguments = argumentsTemplate.Replace("{demoPath}", demoPath, StringComparison.Ordinal);
var startInfo = new ProcessStartInfo
{
FileName = _options.ExternalCommand,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Failed to start external parser process.");
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"External parser failed: {error}");
}
return JsonSerializer.Deserialize<ParsedDemo>(output, new JsonSerializerOptions(JsonSerializerDefaults.Web))
?? throw new InvalidOperationException("External parser returned invalid JSON.");
}
}
@@ -0,0 +1,9 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Parser;
public interface IDemoParserAdapter
{
string Name { get; }
Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken);
}
@@ -0,0 +1,45 @@
using System.Security.Cryptography;
using System.Text;
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Parser;
public sealed class SampleDemoParserAdapter : IDemoParserAdapter
{
public string Name => "sample";
public async Task<ParsedDemo> ParseAsync(string demoPath, CancellationToken cancellationToken)
{
var bytes = await File.ReadAllBytesAsync(demoPath, cancellationToken);
var digest = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()[..8];
return new ParsedDemo
{
MapName = $"sample_{digest}",
ServerName = "Local Sample Parser",
TickRate = 64,
TeamCtName = "Counter-Terrorists",
TeamTName = "Terrorists",
Players =
[
new() { SteamId = "1001", Name = "Alpha", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1002", Name = "Bravo", TeamName = "Counter-Terrorists", Side = "CT" },
new() { SteamId = "1003", Name = "Charlie", TeamName = "Terrorists", Side = "T" },
new() { SteamId = "1004", Name = "Delta", TeamName = "Terrorists", Side = "T" },
new() { SteamId = "1005", Name = "Echo", TeamName = "Terrorists", Side = "T" }
],
Rounds =
[
new() { Number = 1, StartTick = 0, EndTick = 850, WinnerSide = "CT", WinnerTeamName = "Counter-Terrorists", Reason = "elimination" },
new() { Number = 2, StartTick = 851, EndTick = 1720, WinnerSide = "T", WinnerTeamName = "Terrorists", Reason = "bomb" }
],
KillEvents =
[
new() { Tick = 120, Second = 8.6, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1003", VictimName = "Charlie", VictimTeam = "T", Weapon = "m4a1", IsHeadshot = true, KillerAliveCount = 2, EnemyAliveCount = 3 },
new() { Tick = 360, Second = 28.2, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1004", VictimName = "Delta", VictimTeam = "T", Weapon = "m4a1", IsWallbang = true, KillerAliveCount = 1, EnemyAliveCount = 2 },
new() { Tick = 430, Second = 33.7, RoundNumber = 1, KillerSteamId = "1001", KillerName = "Alpha", KillerTeam = "CT", VictimSteamId = "1005", VictimName = "Echo", VictimTeam = "T", Weapon = "m4a1", KillerAliveCount = 1, EnemyAliveCount = 1 },
new() { Tick = 980, Second = 7.1, RoundNumber = 2, KillerSteamId = "1004", KillerName = "Delta", KillerTeam = "T", VictimSteamId = "1002", VictimName = "Bravo", VictimTeam = "CT", Weapon = "ak47", IsSmokeKill = true, KillerAliveCount = 3, EnemyAliveCount = 2 }
]
};
}
}
@@ -0,0 +1,215 @@
using Fragalytics.Web.Infrastructure;
using Fragalytics.Web.Models;
using Microsoft.AspNetCore.StaticFiles;
namespace Fragalytics.Web.Services;
public sealed class QueryService
{
private readonly JsonAppStore _store;
public QueryService(JsonAppStore store)
{
_store = store;
}
public Task<AppState> GetSnapshotAsync(CancellationToken cancellationToken = default) => _store.GetSnapshotAsync(cancellationToken);
public async Task<DashboardViewModel> GetDashboardAsync(CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
return new DashboardViewModel
{
DemoCount = state.Demos.Count,
MatchCount = state.Matches.Count,
HighlightCount = state.Highlights.Count,
ClipJobCount = state.ClipJobs.Count,
Demos = state.Demos.OrderByDescending(item => item.UploadedAtUtc).Take(20).ToList(),
ClipJobs = state.ClipJobs.OrderByDescending(item => item.CreatedAtUtc).Take(12).ToList()
};
}
public async Task<DemoDetailsViewModel?> GetDemoDetailsAsync(int id, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var demo = state.Demos.FirstOrDefault(item => item.Id == id);
if (demo is null)
{
return null;
}
MatchRecord? match = null;
List<HighlightDetailsRow> highlights = [];
if (demo.MatchId is int matchId)
{
match = state.Matches.FirstOrDefault(item => item.Id == matchId);
highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList();
}
return new DemoDetailsViewModel
{
Demo = demo,
Match = match,
Highlights = highlights
};
}
public async Task<MatchDetailsViewModel?> GetMatchDetailsAsync(int matchId, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var match = state.Matches.FirstOrDefault(item => item.Id == matchId);
if (match is null)
{
return null;
}
return new MatchDetailsViewModel
{
Match = match,
Players = state.Players.Where(item => item.MatchId == matchId).OrderBy(item => item.Name).ToList(),
Rounds = state.Rounds.Where(item => item.MatchId == matchId).OrderBy(item => item.Number).ToList(),
Highlights = BuildHighlightRows(state, matchId).OrderByDescending(item => item.Highlight.Score).ToList()
};
}
public async Task<List<ClipJobWithHighlightViewModel>> GetClipJobsAsync(CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
return state.ClipJobs
.OrderByDescending(item => item.CreatedAtUtc)
.Select(job => new ClipJobWithHighlightViewModel
{
ClipJob = job,
Highlight = state.Highlights.FirstOrDefault(highlight => highlight.Id == job.HighlightId),
ClipAsset = state.ClipAssets.FirstOrDefault(asset => asset.Id == job.ClipAssetId)
})
.ToList();
}
public async Task<ClipAssetDownloadViewModel?> GetClipAssetAsync(int clipJobId, CancellationToken cancellationToken = default)
{
var state = await _store.GetSnapshotAsync(cancellationToken);
var clipJob = state.ClipJobs.FirstOrDefault(item => item.Id == clipJobId);
if (clipJob?.ClipAssetId is not int assetId)
{
return null;
}
var asset = state.ClipAssets.FirstOrDefault(item => item.Id == assetId);
if (asset is null || !File.Exists(asset.StoredPath))
{
return null;
}
var provider = new FileExtensionContentTypeProvider();
var contentType = asset.ContentType;
if (string.IsNullOrWhiteSpace(contentType) && !provider.TryGetContentType(asset.FileName, out contentType))
{
contentType = "application/octet-stream";
}
return new ClipAssetDownloadViewModel
{
Asset = asset,
ContentType = contentType
};
}
private static IEnumerable<HighlightDetailsRow> BuildHighlightRows(AppState state, int matchId)
{
var playersById = state.Players.Where(player => player.MatchId == matchId).ToDictionary(player => player.Id);
var roundsById = state.Rounds.Where(round => round.MatchId == matchId).ToDictionary(round => round.Id);
var eventsById = state.Events.Where(@event => @event.MatchId == matchId).ToDictionary(@event => @event.Id);
var clipJobsByHighlightId = state.ClipJobs.ToDictionary(job => job.HighlightId);
var clipAssetsById = state.ClipAssets.ToDictionary(asset => asset.Id);
foreach (var highlight in state.Highlights.Where(item => item.MatchId == matchId))
{
eventsById.TryGetValue(highlight.SourceEventId, out var sourceEvent);
if (sourceEvent is null)
{
continue;
}
PlayerRecord? actor = null;
PlayerRecord? target = null;
RoundRecord? round = null;
if (sourceEvent.ActorPlayerId is int actorId)
{
playersById.TryGetValue(actorId, out actor);
}
if (sourceEvent.TargetPlayerId is int targetId)
{
playersById.TryGetValue(targetId, out target);
}
if (sourceEvent.RoundId is int roundId)
{
roundsById.TryGetValue(roundId, out round);
}
yield return new HighlightDetailsRow
{
Highlight = highlight,
Event = sourceEvent,
Actor = actor,
Target = target,
Round = round,
ClipJob = clipJobsByHighlightId.GetValueOrDefault(highlight.Id),
ClipAsset = clipJobsByHighlightId.TryGetValue(highlight.Id, out var clipJob) && clipJob.ClipAssetId is int assetId
? clipAssetsById.GetValueOrDefault(assetId)
: null
};
}
}
}
public sealed class DashboardViewModel
{
public int DemoCount { get; set; }
public int MatchCount { get; set; }
public int HighlightCount { get; set; }
public int ClipJobCount { get; set; }
public List<DemoRecord> Demos { get; set; } = [];
public List<ClipJobRecord> ClipJobs { get; set; } = [];
}
public sealed class DemoDetailsViewModel
{
public DemoRecord Demo { get; set; } = new();
public MatchRecord? Match { get; set; }
public List<HighlightDetailsRow> Highlights { get; set; } = [];
}
public sealed class MatchDetailsViewModel
{
public MatchRecord Match { get; set; } = new();
public List<PlayerRecord> Players { get; set; } = [];
public List<RoundRecord> Rounds { get; set; } = [];
public List<HighlightDetailsRow> Highlights { get; set; } = [];
}
public sealed class HighlightDetailsRow
{
public HighlightRecord Highlight { get; set; } = new();
public EventRecord Event { get; set; } = new();
public PlayerRecord? Actor { get; set; }
public PlayerRecord? Target { get; set; }
public RoundRecord? Round { get; set; }
public ClipJobRecord? ClipJob { get; set; }
public ClipAssetRecord? ClipAsset { get; set; }
}
public sealed class ClipJobWithHighlightViewModel
{
public ClipJobRecord ClipJob { get; set; } = new();
public HighlightRecord? Highlight { get; set; }
public ClipAssetRecord? ClipAsset { get; set; }
}
public sealed class ClipAssetDownloadViewModel
{
public ClipAssetRecord Asset { get; set; } = new();
public string ContentType { get; set; } = "application/octet-stream";
}
@@ -0,0 +1,9 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Render;
public interface IRenderWorkerAdapter
{
string Name { get; }
void Enqueue(ClipJobRecord clipJob);
}
@@ -0,0 +1,13 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Render;
public sealed class NoOpRenderWorkerAdapter : IRenderWorkerAdapter
{
public string Name => "future-renderer";
public void Enqueue(ClipJobRecord clipJob)
{
clipJob.Payload["note"] = "MVP placeholder. Rendering is not implemented yet.";
}
}
@@ -0,0 +1,129 @@
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Scoring;
public sealed class HighlightScorer
{
private readonly HighlightScoringRules _rules;
public HighlightScorer(HighlightScoringRules rules)
{
_rules = rules;
}
public HighlightScoreResult ScoreKill(
EventRecord sourceEvent,
RoundRecord? round,
IReadOnlyList<EventRecord> roundKills,
IReadOnlyDictionary<int, PlayerRecord> playersById,
int killIndex)
{
var score = 0d;
var tags = new List<string>();
var explanation = new List<ScoreExplanationRecord>();
void Add(string key, double value, string? tag = null, string? detail = null)
{
score += value;
if (!string.IsNullOrWhiteSpace(tag) && !tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
{
tags.Add(tag);
}
explanation.Add(new ScoreExplanationRecord
{
Reason = key,
Value = value,
Detail = detail ?? key.Replace('_', ' ')
});
}
Add("base_kill", Weight("base_kill"));
if (sourceEvent.IsHeadshot)
{
Add("headshot", Weight("headshot"), "headshot");
}
if (sourceEvent.IsWallbang)
{
Add("wallbang", Weight("wallbang"), "wallbang");
}
if (sourceEvent.IsSmokeKill)
{
Add("smoke_kill", Weight("smoke_kill"), "smoke_kill");
}
if (killIndex == 0)
{
Add("opening_kill", Weight("opening_kill"), "opening_kill");
}
var killerKillsThisRound = roundKills
.Take(killIndex + 1)
.Count(candidate => candidate.ActorPlayerId == sourceEvent.ActorPlayerId);
if (killerKillsThisRound >= 2)
{
var multiKey = $"multikill_{Math.Min(5, killerKillsThisRound)}";
if (_rules.Weights.ContainsKey(multiKey))
{
Add(multiKey, Weight(multiKey), "multikill", $"{killerKillsThisRound} kills in the round");
}
}
if (sourceEvent.KillerAliveCount == 1 && sourceEvent.EnemyAliveCount is >= 2)
{
var clutchKey = $"clutch_1v{Math.Min(5, sourceEvent.EnemyAliveCount.Value)}";
if (_rules.Weights.ContainsKey(clutchKey))
{
Add(clutchKey, Weight(clutchKey), "clutch", $"Kill happened in a 1v{sourceEvent.EnemyAliveCount}");
if (!tags.Contains("1vX", StringComparer.OrdinalIgnoreCase))
{
tags.Add("1vX");
}
}
}
if (sourceEvent.EnemyAliveCount == 1)
{
Add("impact_last_enemy", Weight("impact_last_enemy"), "last_enemy", "Kill removed the last visible opponent");
}
if (sourceEvent.Second is >= 35)
{
Add("impact_low_time", Weight("impact_low_time"), detail: "Late-round frag");
}
if (sourceEvent.ActorPlayerId is int actorId &&
playersById.TryGetValue(actorId, out var actor) &&
!string.IsNullOrWhiteSpace(actor.Side) &&
string.Equals(actor.Side, round?.WinnerSide, StringComparison.OrdinalIgnoreCase))
{
Add("round_win", Weight("round_win"), "round_win");
}
return new HighlightScoreResult
{
Score = score,
Tags = tags,
Explanation = explanation,
StartTick = Math.Max(0, sourceEvent.Tick - _rules.Clip.PreTicks),
EndTick = sourceEvent.Tick + _rules.Clip.PostTicks
};
}
public double MinimumScore => _rules.Thresholds.HighlightScoreMin;
private double Weight(string key) => _rules.Weights.TryGetValue(key, out var value) ? value : 0d;
}
public sealed class HighlightScoreResult
{
public double Score { get; set; }
public List<string> Tags { get; set; } = [];
public List<ScoreExplanationRecord> Explanation { get; set; } = [];
public int StartTick { get; set; }
public int EndTick { get; set; }
}
@@ -0,0 +1,125 @@
using Fragalytics.Web.Models;
using Fragalytics.Web.Services.Render;
namespace Fragalytics.Web.Services.Scoring;
public sealed class MatchScoringService
{
private readonly ScoringRulesProvider _rulesProvider;
private readonly IRenderWorkerAdapter _renderWorker;
public MatchScoringService(ScoringRulesProvider rulesProvider, IRenderWorkerAdapter renderWorker)
{
_rulesProvider = rulesProvider;
_renderWorker = renderWorker;
}
public int RescoreMatch(AppState state, MatchRecord match)
{
var rules = _rulesProvider.Load();
var version = _rulesProvider.ComputeVersion(rules);
foreach (var profile in state.ScoringProfiles)
{
profile.IsActive = false;
}
var scoringProfile = state.ScoringProfiles.FirstOrDefault(profile => profile.Version == version);
if (scoringProfile is null)
{
scoringProfile = new ScoringProfileRecord
{
Id = state.NextScoringProfileId++,
Name = rules.Name,
Version = version,
Rules = rules,
IsActive = true
};
state.ScoringProfiles.Add(scoringProfile);
}
else
{
scoringProfile.IsActive = true;
scoringProfile.Rules = rules;
}
var existingHighlightIds = state.Highlights.Where(h => h.MatchId == match.Id).Select(h => h.Id).ToHashSet();
var existingClipAssetIds = state.ClipJobs
.Where(job => existingHighlightIds.Contains(job.HighlightId) && job.ClipAssetId.HasValue)
.Select(job => job.ClipAssetId!.Value)
.ToHashSet();
state.ClipJobs.RemoveAll(job => existingHighlightIds.Contains(job.HighlightId));
state.ClipAssets.RemoveAll(asset => existingClipAssetIds.Contains(asset.Id));
state.Highlights.RemoveAll(highlight => highlight.MatchId == match.Id);
var scorer = new HighlightScorer(rules);
var playersById = state.Players.Where(player => player.MatchId == match.Id).ToDictionary(player => player.Id);
var roundsById = state.Rounds.Where(round => round.MatchId == match.Id).ToDictionary(round => round.Id);
var killEvents = state.Events
.Where(@event => @event.MatchId == match.Id && string.Equals(@event.EventType, "kill", StringComparison.OrdinalIgnoreCase))
.OrderBy(@event => @event.RoundId)
.ThenBy(@event => @event.Tick)
.ToList();
var grouped = killEvents.GroupBy(@event => @event.RoundId).ToList();
var totalHighlights = 0;
foreach (var group in grouped)
{
var roundKills = group.ToList();
for (var index = 0; index < roundKills.Count; index++)
{
var candidate = roundKills[index];
roundsById.TryGetValue(candidate.RoundId ?? 0, out var round);
var result = scorer.ScoreKill(candidate, round, roundKills, playersById, index);
if (result.Score < scorer.MinimumScore)
{
continue;
}
var highlight = new HighlightRecord
{
Id = state.NextHighlightId++,
MatchId = match.Id,
RoundId = candidate.RoundId,
SourceEventId = candidate.Id,
Score = result.Score,
Tags = result.Tags,
Explanation = result.Explanation,
ScoringVersion = version,
StartTick = result.StartTick,
EndTick = result.EndTick
};
state.Highlights.Add(highlight);
var clipJob = new ClipJobRecord
{
Id = state.NextClipJobId++,
HighlightId = highlight.Id,
Renderer = _renderWorker.Name,
Status = "pending",
StartTick = highlight.StartTick,
EndTick = highlight.EndTick,
Payload = new Dictionary<string, string>
{
["sourceEventId"] = candidate.Id.ToString(),
["matchId"] = match.Id.ToString(),
["highlightId"] = highlight.Id.ToString(),
["startTick"] = highlight.StartTick.ToString(),
["endTick"] = highlight.EndTick.ToString(),
["cameraMode"] = "killer_pov",
["outputFormat"] = "mp4",
["resolution"] = "1920x1080",
["fps"] = "60"
}
};
_renderWorker.Enqueue(clipJob);
state.ClipJobs.Add(clipJob);
totalHighlights++;
}
}
return totalHighlights;
}
}
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Fragalytics.Web.Models;
namespace Fragalytics.Web.Services.Scoring;
public sealed class ScoringRulesProvider
{
private readonly IWebHostEnvironment _environment;
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public ScoringRulesProvider(IWebHostEnvironment environment)
{
_environment = environment;
}
public HighlightScoringRules Load()
{
var path = Path.Combine(_environment.ContentRootPath, "scoring-rules.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<HighlightScoringRules>(json, _jsonOptions)
?? new HighlightScoringRules();
}
public string ComputeVersion(HighlightScoringRules rules)
{
var payload = JsonSerializer.Serialize(rules, _jsonOptions);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant()[..12];
}
}
@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Fragalytics": {
"DataDirectory": "data",
"UploadDirectory": "data/uploads",
"WorkerPollSeconds": 2,
"AutoStartWorker": true,
"Parser": {
"Mode": "sample",
"ExternalCommand": "",
"ExternalArguments": "\"{demoPath}\""
},
"RenderWorker": {
"PollSeconds": 3,
"Backend": "sample-copy",
"SampleClipPath": "",
"TempDirectory": "data/render-temp",
"RunContinuously": true
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "default",
"weights": {
"base_kill": 10,
"headshot": 8,
"opening_kill": 12,
"wallbang": 10,
"smoke_kill": 10,
"round_win": 6,
"multikill_2": 8,
"multikill_3": 18,
"multikill_4": 30,
"multikill_5": 45,
"clutch_1v2": 18,
"clutch_1v3": 28,
"clutch_1v4": 40,
"clutch_1v5": 55,
"impact_last_enemy": 12,
"impact_low_time": 4
},
"clip": {
"preTicks": 160,
"postTicks": 96
},
"thresholds": {
"highlightScoreMin": 18
}
}
+162
View File
@@ -0,0 +1,162 @@
:root {
--bg: #f4f1ea;
--panel: #fffdf8;
--ink: #1f2421;
--muted: #5a645e;
--accent: #b6482f;
--accent-2: #24584f;
--line: #d9d0c3;
--soft: #efe8de;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(182, 72, 47, 0.12), transparent 24rem),
linear-gradient(180deg, #f8f4ed, var(--bg));
}
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, p { margin-top: 0; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 0.75rem; border-bottom: 1px solid var(--line); vertical-align: top; text-align: left; }
code { font-family: "Courier New", monospace; font-size: 0.9rem; }
.clip-player { width: 240px; max-width: 100%; border-radius: 12px; background: #000; }
.site-header,
.page {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
}
.site-header {
display: flex;
justify-content: space-between;
align-items: end;
gap: 1rem;
padding: 1.5rem 0 1rem;
}
.brand {
color: var(--ink);
font-size: 2rem;
font-weight: 700;
}
.nav {
display: flex;
gap: 1rem;
}
.page {
padding-bottom: 3rem;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 1.25rem;
box-shadow: 0 8px 30px rgba(31, 36, 33, 0.05);
}
.hero,
.split,
.stats-grid,
.meta-grid {
display: grid;
gap: 1rem;
margin-bottom: 1rem;
}
.hero { grid-template-columns: 2fr 1fr; }
.split { grid-template-columns: 1.15fr 1fr; }
.stats-grid { grid-template-columns: repeat(4, 1fr); }
.meta-grid { grid-template-columns: repeat(4, 1fr); }
.upload-form {
display: grid;
gap: 0.75rem;
align-content: center;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
}
.muted {
color: var(--muted);
}
.pill {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: var(--soft);
border: 1px solid var(--line);
font-size: 0.85rem;
}
.status-uploaded,
.status-pending { background: #f5e8b8; }
.status-processing { background: #cfe0ff; }
.status-parsed { background: #c9ead0; }
.status-failed { background: #f1c4bd; }
button,
.button-link,
input[type="submit"] {
display: inline-block;
border: none;
border-radius: 999px;
background: var(--accent);
color: white;
padding: 0.7rem 1rem;
font: inherit;
cursor: pointer;
}
button.secondary {
background: var(--soft);
color: var(--ink);
border: 1px solid var(--line);
}
input[type="file"] {
width: 100%;
border: 1px dashed var(--line);
border-radius: 12px;
background: var(--soft);
padding: 1rem;
}
dt {
color: var(--muted);
font-size: 0.85rem;
}
dd {
margin: 0.3rem 0 0;
}
@media (max-width: 900px) {
.hero,
.split,
.stats-grid,
.meta-grid {
grid-template-columns: 1fr;
}
.site-header {
flex-direction: column;
align-items: start;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+4
View File
@@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2011-2021 Twitter, Inc.
Copyright (c) 2011-2021 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
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
@@ -0,0 +1,427 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::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;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
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],
abbr[data-bs-original-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.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
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: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
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: #6c757d;
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]::-webkit-calendar-picker-indicator {
display: none;
}
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] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-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
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{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:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .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[data-bs-original-title],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}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,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]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button: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:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-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.min.css.map */
File diff suppressed because one or more lines are too long
@@ -0,0 +1,424 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::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;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
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],
abbr[data-bs-original-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.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr ;
unicode-bidi: bidi-override;
}
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: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
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: #6c757d;
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]::-webkit-calendar-picker-indicator {
display: none;
}
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] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-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
@@ -0,0 +1,8 @@
/*!
* Bootstrap Reboot v5.1.0 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{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:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .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[data-bs-original-title],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}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,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]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button: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:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-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.min.css.map */
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
@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) .NET Foundation and Contributors
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,435 @@
/**
* @license
* Unobtrusive validation support library for jQuery and jQuery Validate
* Copyright (c) .NET Foundation. All rights reserved.
* Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
* @version v4.0.0
*/
/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
/*global document: false, jQuery: false */
(function (factory) {
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS-like environments that support module.exports
module.exports = factory(require('jquery-validation'));
} else {
// Browser global
jQuery.validator.unobtrusive = factory(jQuery);
}
}(function ($) {
var $jQval = $.validator,
adapters,
data_validation = "unobtrusiveValidation";
function setValidationValues(options, ruleName, value) {
options.rules[ruleName] = value;
if (options.message) {
options.messages[ruleName] = options.message;
}
}
function splitAndTrim(value) {
return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
}
function escapeAttributeValue(value) {
// As mentioned on http://api.jquery.com/category/selectors/
return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
}
function getModelPrefix(fieldName) {
return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
}
function appendModelPrefix(value, prefix) {
if (value.indexOf("*.") === 0) {
value = value.replace("*.", prefix);
}
return value;
}
function onError(error, inputElement) { // 'this' is the form element
var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
container.removeClass("field-validation-valid").addClass("field-validation-error");
error.data("unobtrusiveContainer", container);
if (replace) {
container.empty();
error.removeClass("input-validation-error").appendTo(container);
}
else {
error.hide();
}
}
function onErrors(event, validator) { // 'this' is the form element
var container = $(this).find("[data-valmsg-summary=true]"),
list = container.find("ul");
if (list && list.length && validator.errorList.length) {
list.empty();
container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
$.each(validator.errorList, function () {
$("<li />").html(this.message).appendTo(list);
});
}
}
function onSuccess(error) { // 'this' is the form element
var container = error.data("unobtrusiveContainer");
if (container) {
var replaceAttrValue = container.attr("data-valmsg-replace"),
replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
container.addClass("field-validation-valid").removeClass("field-validation-error");
error.removeData("unobtrusiveContainer");
if (replace) {
container.empty();
}
}
}
function onReset(event) { // 'this' is the form element
var $form = $(this),
key = '__jquery_unobtrusive_validation_form_reset';
if ($form.data(key)) {
return;
}
// Set a flag that indicates we're currently resetting the form.
$form.data(key, true);
try {
$form.data("validator").resetForm();
} finally {
$form.removeData(key);
}
$form.find(".validation-summary-errors")
.addClass("validation-summary-valid")
.removeClass("validation-summary-errors");
$form.find(".field-validation-error")
.addClass("field-validation-valid")
.removeClass("field-validation-error")
.removeData("unobtrusiveContainer")
.find(">*") // If we were using valmsg-replace, get the underlying error
.removeData("unobtrusiveContainer");
}
function validationInfo(form) {
var $form = $(form),
result = $form.data(data_validation),
onResetProxy = $.proxy(onReset, form),
defaultOptions = $jQval.unobtrusive.options || {},
execInContext = function (name, args) {
var func = defaultOptions[name];
func && $.isFunction(func) && func.apply(form, args);
};
if (!result) {
result = {
options: { // options structure passed to jQuery Validate's validate() method
errorClass: defaultOptions.errorClass || "input-validation-error",
errorElement: defaultOptions.errorElement || "span",
errorPlacement: function () {
onError.apply(form, arguments);
execInContext("errorPlacement", arguments);
},
invalidHandler: function () {
onErrors.apply(form, arguments);
execInContext("invalidHandler", arguments);
},
messages: {},
rules: {},
success: function () {
onSuccess.apply(form, arguments);
execInContext("success", arguments);
}
},
attachValidation: function () {
$form
.off("reset." + data_validation, onResetProxy)
.on("reset." + data_validation, onResetProxy)
.validate(this.options);
},
validate: function () { // a validation function that is called by unobtrusive Ajax
$form.validate();
return $form.valid();
}
};
$form.data(data_validation, result);
}
return result;
}
$jQval.unobtrusive = {
adapters: [],
parseElement: function (element, skipAttach) {
/// <summary>
/// Parses a single HTML element for unobtrusive validation attributes.
/// </summary>
/// <param name="element" domElement="true">The HTML element to be parsed.</param>
/// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
/// validation to the form. If parsing just this single element, you should specify true.
/// If parsing several elements, you should specify false, and manually attach the validation
/// to the form when you are finished. The default is false.</param>
var $element = $(element),
form = $element.parents("form")[0],
valInfo, rules, messages;
if (!form) { // Cannot do client-side validation without a form
return;
}
valInfo = validationInfo(form);
valInfo.options.rules[element.name] = rules = {};
valInfo.options.messages[element.name] = messages = {};
$.each(this.adapters, function () {
var prefix = "data-val-" + this.name,
message = $element.attr(prefix),
paramValues = {};
if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
prefix += "-";
$.each(this.params, function () {
paramValues[this] = $element.attr(prefix + this);
});
this.adapt({
element: element,
form: form,
message: message,
params: paramValues,
rules: rules,
messages: messages
});
}
});
$.extend(rules, { "__dummy__": true });
if (!skipAttach) {
valInfo.attachValidation();
}
},
parse: function (selector) {
/// <summary>
/// Parses all the HTML elements in the specified selector. It looks for input elements decorated
/// with the [data-val=true] attribute value and enables validation according to the data-val-*
/// attribute values.
/// </summary>
/// <param name="selector" type="String">Any valid jQuery selector.</param>
// $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
// element with data-val=true
var $selector = $(selector),
$forms = $selector.parents()
.addBack()
.filter("form")
.add($selector.find("form"))
.has("[data-val=true]");
$selector.find("[data-val=true]").each(function () {
$jQval.unobtrusive.parseElement(this, true);
});
$forms.each(function () {
var info = validationInfo(this);
if (info) {
info.attachValidation();
}
});
}
};
adapters = $jQval.unobtrusive.adapters;
adapters.add = function (adapterName, params, fn) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
/// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
/// mmmm is the parameter name).</param>
/// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
/// attributes into jQuery Validate rules and/or messages.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
if (!fn) { // Called with no params, just a function
fn = params;
params = [];
}
this.push({ name: adapterName, params: params, adapt: fn });
return this;
};
adapters.addBool = function (adapterName, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has no parameter values.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, function (options) {
setValidationValues(options, ruleName || adapterName, true);
});
};
adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
/// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
/// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a minimum value.</param>
/// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
/// have a maximum value.</param>
/// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
/// have both a minimum and maximum value.</param>
/// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the minimum value. The default is "min".</param>
/// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
/// contains the maximum value. The default is "max".</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
var min = options.params.min,
max = options.params.max;
if (min && max) {
setValidationValues(options, minMaxRuleName, [min, max]);
}
else if (min) {
setValidationValues(options, minRuleName, min);
}
else if (max) {
setValidationValues(options, maxRuleName, max);
}
});
};
adapters.addSingleVal = function (adapterName, attribute, ruleName) {
/// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
/// the jQuery Validate validation rule has a single value.</summary>
/// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
/// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
/// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
/// The default is "val".</param>
/// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
/// of adapterName will be used instead.</param>
/// <returns type="jQuery.validator.unobtrusive.adapters" />
return this.add(adapterName, [attribute || "val"], function (options) {
setValidationValues(options, ruleName || adapterName, options.params[attribute]);
});
};
$jQval.addMethod("__dummy__", function (value, element, params) {
return true;
});
$jQval.addMethod("regex", function (value, element, params) {
var match;
if (this.optional(element)) {
return true;
}
match = new RegExp(params).exec(value);
return (match && (match.index === 0) && (match[0].length === value.length));
});
$jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
var match;
if (nonalphamin) {
match = value.match(/\W/g);
match = match && match.length >= nonalphamin;
}
return match;
});
if ($jQval.methods.extension) {
adapters.addSingleVal("accept", "mimtype");
adapters.addSingleVal("extension", "extension");
} else {
// for backward compatibility, when the 'extension' validation method does not exist, such as with versions
// of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
// validating the extension, and ignore mime-type validations as they are not supported.
adapters.addSingleVal("extension", "extension", "accept");
}
adapters.addSingleVal("regex", "pattern");
adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
adapters.add("equalto", ["other"], function (options) {
var prefix = getModelPrefix(options.element.name),
other = options.params.other,
fullOtherName = appendModelPrefix(other, prefix),
element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
setValidationValues(options, "equalTo", element);
});
adapters.add("required", function (options) {
// jQuery Validate equates "required" with "mandatory" for checkbox elements
if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
setValidationValues(options, "required", true);
}
});
adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
var value = {
url: options.params.url,
type: options.params.type || "GET",
data: {}
},
prefix = getModelPrefix(options.element.name);
$.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
var paramName = appendModelPrefix(fieldName, prefix);
value.data[paramName] = function () {
var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
// For checkboxes and radio buttons, only pick up values from checked fields.
if (field.is(":checkbox")) {
return field.filter(":checked").val() || field.filter(":hidden").val() || '';
}
else if (field.is(":radio")) {
return field.filter(":checked").val() || '';
}
return field.val();
};
});
setValidationValues(options, "remote", value);
});
adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
if (options.params.min) {
setValidationValues(options, "minlength", options.params.min);
}
if (options.params.nonalphamin) {
setValidationValues(options, "nonalphamin", options.params.nonalphamin);
}
if (options.params.regex) {
setValidationValues(options, "regex", options.params.regex);
}
});
adapters.add("fileextensions", ["extensions"], function (options) {
setValidationValues(options, "extension", options.params.extensions);
});
$(function () {
$jQval.unobtrusive.parse(document);
});
return $jQval.unobtrusive;
}));
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More