Compare commits

..

10 Commits

Author SHA1 Message Date
bob
edc649fc36 final 2026-06-02 13:31:27 -05:00
bob
15310f0fd0 prevent negative prices 2026-06-01 11:11:41 -05:00
bob
763305ca89 almost ready 2026-06-01 10:52:06 -05:00
bob
8b0eb0db78 Cut metered-proxy bandwidth: re-sweep floor + wire-size logging
JobQueue now skips bands swept within MinResweepHours (config, default 6h) instead of re-scraping the whole catalogue continuously — the dominant cost on the metered residential proxy. Roughly linear savings with no data loss (full pagination retained); 0 disables it. Worker logs the real compressed transferSize per job (what the proxy bills) rather than the ~6.5x-larger decompressed length, so spend is visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:27:37 -05:00
bob
94177f9a8c Fix worker proxy relay leak and enable noVNC under --scale
_relay waited for both pipe directions (gather), leaking a task holding two sockets on every half-closed tunnel — visible as a flood of pending-task lines under load. Tear the tunnel down when either side closes (FIRST_COMPLETED + close both writers), matching the .NET LocalForwardingProxy's WhenAny. Also move the worker's noVNC to an ephemeral host port so replicas don't collide under 'docker compose up --scale worker=N'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:12:51 -05:00
bob
dc7c3f99ae Add cs.money worker stack with per-worker IPRoyal residential proxy
Brings up the pull-model scraper: the .NET C2 hands skin+wear jobs to Python nodriver workers that scrape cs.money and post results back, plus the supporting Core/EFCore data model, migrations, and docker-compose orchestration.

IPRoyal proxying lets workers scale horizontally with a distinct residential exit IP each: every worker process mints its own sticky session at startup, and an in-process forwarding proxy injects the gateway auth so Chromium talks only to an auth-free localhost endpoint (zero CDP). On a Cloudflare challenge a worker rotates to a fresh session/IP and re-warms. Verified end-to-end against live IPRoyal: distinct US residential exits per worker and IP rotation on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:03:53 -05:00
bob
eb5fb0dac7 remove selenium related code for now 2026-05-29 22:17:11 -05:00
bob
d1752b1b07 add csfloat api usage 2026-05-29 22:08:32 -05:00
bob
b51f1d9f5f Change to static skin catalog population 2026-05-29 18:36:17 -05:00
bob
6f3c0175cd Add init weapon scraper 2026-05-29 14:00:58 -05:00
161 changed files with 29917 additions and 120 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
# Keep build contexts small/clean (both images use the repo root as context).
**/bin/
**/obj/
**/.vs/
.git/
.gitignore
*.user
# Python worker local artifacts
worker/.venv/
worker/__pycache__/
worker/captures/
# Discovery dumps
csmoney-probe/
csmoney-captures/
# Docs/markdown aren't needed in images
**/*.md
# Secrets: compose reads .env for variable substitution; never bake it into an image
.env

31
.editorconfig Normal file
View File

@@ -0,0 +1,31 @@
# EditorConfig — https://editorconfig.org
root = true
[*.cs]
#### Brace style ####
# Require braces on all control-flow blocks (if/else/for/foreach/while/...),
# even single statements. Enforced as a build error so this:
# if (x == false)
# return -1;
# must instead be written:
# if (x == false)
# {
# return -1;
# }
csharp_prefer_braces = true
dotnet_diagnostic.IDE0011.severity = error
#### Explicit constructors ####
# Prefer explicit constructors over primary constructors; don't suggest the
# "use primary constructor" refactor.
csharp_style_prefer_primary_constructors = false
dotnet_diagnostic.IDE0290.severity = none
#### Logging analyzer ####
# CA1873: "Avoid potentially expensive logging" — suppressed.
dotnet_diagnostic.CA1873.severity = none
# EF Core migrations are generated; don't enforce code style on them.
[**/Migrations/*.cs]
generated_code = true

8
.gitignore vendored
View File

@@ -98,3 +98,11 @@ venv/
env/
*.egg-info/
.pytest_cache/
# cs.money discovery capture dumps (JSON responses)
csmoney-captures/
# API response capture dumps (CSFloat schema/listing samples, worker page dumps)
captures/
# Local compose secrets (DB connection string, tokens)
.env

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,37 @@
using BlueLaminate.Core.CsMoney;
using BlueLaminate.Core.SkinLand;
namespace BlueLaminate.C2;
/// <summary>A unit of scrape work handed to a worker: one skin+wear, as a search.</summary>
/// <param name="JobId">Opaque id the worker echoes back when posting results.</param>
/// <param name="SkinId">Catalogue skin this job targets.</param>
/// <param name="ConditionId">Wear band (skin_conditions row), or null for a whole skin.</param>
/// <param name="Search">Free-text market search, e.g. "M4A4 Cyber Security ft".</param>
/// <param name="MaxPages">Safety cap on page fetches (60 items each). The worker
/// paginates by walking the float axis, so a skin+wear needs ceil(listings/60) pages.</param>
public sealed record ScrapeJobDto(string JobId, int SkinId, int? ConditionId, string Search, int MaxPages);
/// <summary>A worker's results for a claimed job: the listings it scraped.</summary>
/// <param name="Items">All sell-order items gathered across pages (raw cs.money shape).</param>
/// <param name="Pages">How many pages the worker fetched.</param>
/// <param name="StoppedReason">Why it stopped. "completed" = full sweep (authoritative);
/// anything else (fetch-cap / challenged / stuck-float-tie) is partial.</param>
public sealed record ScrapeResultDto(List<CsMoneyItem> Items, int Pages, string? StoppedReason);
/// <summary>A unit of skin.land scrape work: one skin+wear, as its market page URL.</summary>
/// <param name="JobId">Opaque id the worker echoes back when posting results.</param>
/// <param name="SkinId">Catalogue skin this job targets.</param>
/// <param name="ConditionId">Wear band (skin_conditions row).</param>
/// <param name="Url">The skin.land market page, e.g.
/// "https://skin.land/market/csgo/ak-47-redline-field-tested/". The worker resolves the
/// internal skin_id from this page, then pages the obtained-skins API.</param>
/// <param name="MaxPages">Safety cap on offer-page fetches (Laravel paginator, ~26/page).</param>
public sealed record SkinLandJobDto(string JobId, int SkinId, int ConditionId, string Url, int MaxPages);
/// <summary>A worker's results for a claimed skin.land job: the offers it scraped.</summary>
/// <param name="Items">All obtained-skins offers gathered across pages (raw skin.land shape).</param>
/// <param name="Pages">How many offer pages the worker fetched.</param>
/// <param name="StoppedReason">Why it stopped. "completed" = full sweep (authoritative);
/// anything else (fetch-cap / challenged / no-skin-id) is partial.</param>
public sealed record SkinLandResultDto(List<SkinLandOffer> Items, int Pages, string? StoppedReason);

View File

@@ -0,0 +1,24 @@
# Build context is the REPO ROOT (so Central Package Management's Directory.*.props
# at the root are available). Build with:
# docker compose build (compose sets the context)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore against the full solution sources the C2 transitively needs.
COPY Directory.Build.props Directory.Packages.props ./
COPY BlueLaminate/ BlueLaminate/
RUN dotnet restore BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
RUN dotnet publish BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj \
-c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
# NOTE: deliberately do NOT install libgssapi-krb5-2. Without it Npgsql logs a
# harmless "cannot load libgssapi_krb5.so.2" line and falls back to password auth;
# WITH it, a failed/misconfigured connection attempt segfaults during GSS negotiation
# (observed: container exit 139 / crash-loop). Graceful failure beats the segfault.
WORKDIR /app
COPY --from=build /app ./
# Bind all interfaces inside the container (overrides appsettings' localhost binding).
ENV ASPNETCORE_URLS=http://+:5080
EXPOSE 5080
ENTRYPOINT ["dotnet", "BlueLaminate.C2.dll"]

View File

@@ -0,0 +1,136 @@
using System.Collections.Concurrent;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
namespace BlueLaminate.C2;
/// <summary>
/// Hands out scrape jobs to workers, one skin+wear at a time, driven directly by the
/// catalogue's per-band, per-site checkpoints (the rows in <c>skin_condition_sweeps</c>
/// for this queue's <see cref="_source"/>) rather than a pre-built queue. Each claim picks
/// the stalest band (never-swept first), leases it in memory so two workers can't get the
/// same one, and builds the work target. On completion the ingest stamps the band's
/// checkpoint, so it drops to the back — the sweep loops the whole catalogue continuously
/// and resumes cleanly after restarts. Because the checkpoint is per-site, a band one
/// market just swept is still due on another.
/// <para>
/// The queue is source-agnostic: it's constructed with the checkpoint
/// <see cref="_source"/> and a <see cref="_targetBuilder"/> that turns a band into the
/// thing a worker needs — a free-text search for cs.money, a market URL for skin.land — so
/// one class drives every market. Register one instance per source.
/// </para>
/// <para>
/// A <see cref="_minResweepInterval"/> floor keeps a band from being re-handed-out until
/// its data is at least that stale. Without it the queue re-scrapes the whole catalogue as
/// fast as the workers run, which on a metered residential proxy is the dominant cost; the
/// floor trades a little price-freshness for a roughly linear bandwidth cut. When every
/// band is fresher than the floor the queue hands out nothing (workers idle) until one ages.
/// </para>
/// </summary>
public sealed class JobQueue
{
// A leased condition can't be re-handed-out until released or the lease expires (so a
// crashed worker's band returns to the pool instead of stalling forever).
private static readonly TimeSpan LeaseTtl = TimeSpan.FromMinutes(15);
private const int CandidateBatch = 100;
private readonly string _source;
private readonly TimeSpan _minResweepInterval;
private readonly Func<Candidate, string> _targetBuilder;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ConcurrentDictionary<int, DateTimeOffset> _leases = new(); // conditionId -> leasedAt
private readonly ConcurrentDictionary<string, JobMapping> _inFlight = new(); // jobId -> mapping
/// <param name="source">
/// The <c>skin_condition_sweeps.Source</c> this queue reads/leases on (a
/// <c>SweepSource</c> value, e.g. "csmoney" / "skinland").
/// </param>
/// <param name="minResweepInterval">
/// How stale a band's checkpoint must be before it's eligible again.
/// <see cref="TimeSpan.Zero"/> disables the floor (continuous re-sweep).
/// </param>
/// <param name="targetBuilder">Turns a claimed band into the worker's target string.</param>
public JobQueue(string source, TimeSpan minResweepInterval, Func<Candidate, string> targetBuilder)
{
_source = source;
_minResweepInterval = minResweepInterval;
_targetBuilder = targetBuilder;
}
public async Task<ClaimedJob?> ClaimNextAsync(SkinTrackerDbContext db, int maxPages, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
// Reclaim expired leases first.
var cutoff = DateTimeOffset.UtcNow - LeaseTtl;
foreach (var (cid, at) in _leases)
{
if (at < cutoff)
{
_leases.TryRemove(cid, out _);
}
}
// Only consider bands that are never-swept or stale past the re-sweep floor,
// then stalest first (never-swept null sorts before any timestamp). The
// checkpoint is read for THIS queue's source only (a correlated subquery over
// the per-site sweep rows), so a band another market just swept is still
// never-swept here. With the floor in place a fully-fresh catalogue yields no
// candidates, so workers idle instead of needlessly re-pulling on the proxy.
var freshCutoff = DateTimeOffset.UtcNow - _minResweepInterval;
var candidates = await db.SkinConditions
.Select(c => new
{
Candidate = new Candidate(c.Id, c.SkinId, c.Skin.Weapon.Name, c.Skin.Name, c.Condition),
SweptAt = c.Sweeps
.Where(s => s.Source == _source)
.Select(s => (DateTimeOffset?)s.SweptAt)
.FirstOrDefault(),
})
.Where(x => x.SweptAt == null || x.SweptAt <= freshCutoff)
.OrderBy(x => x.SweptAt.HasValue)
.ThenBy(x => x.SweptAt)
.Take(CandidateBatch)
.Select(x => x.Candidate)
.ToListAsync(ct);
var pick = candidates.FirstOrDefault(c => !_leases.ContainsKey(c.ConditionId));
if (pick is null)
{
return null; // everything in the stalest batch is already in flight
}
_leases[pick.ConditionId] = DateTimeOffset.UtcNow;
var jobId = Guid.NewGuid().ToString("N");
_inFlight[jobId] = new JobMapping(pick.SkinId, pick.ConditionId);
return new ClaimedJob(jobId, pick.SkinId, pick.ConditionId, _targetBuilder(pick), maxPages);
}
finally
{
_gate.Release();
}
}
/// <summary>Resolve a posted job to its skin+condition and release its lease.</summary>
public JobMapping? Complete(string jobId)
{
if (_inFlight.TryRemove(jobId, out var mapping))
{
_leases.TryRemove(mapping.ConditionId, out _);
return mapping;
}
return null;
}
public int InFlight => _inFlight.Count;
public sealed record JobMapping(int SkinId, int ConditionId);
/// <summary>A claimed band ready to hand to a worker: its ids + built target string.</summary>
public sealed record ClaimedJob(string JobId, int SkinId, int ConditionId, string Target, int MaxPages);
public sealed record Candidate(int ConditionId, int SkinId, string Weapon, string SkinName, string Condition);
}

View File

@@ -0,0 +1,154 @@
using BlueLaminate.C2;
using BlueLaminate.Core.CsMoney;
using BlueLaminate.Core.DependencyInjection;
using BlueLaminate.Core.SkinLand;
using System.Text.Json.Serialization;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
// The C2: hands cs.money and skin.land scrape jobs to Python workers and ingests their
// results. Reuses the whole BlueLaminate stack (DB, ingest services) via the one
// composition root. Content root = the binary directory so appsettings.json is found
// regardless of the working directory the process is launched from (matches the CLI).
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
ContentRootPath = AppContext.BaseDirectory,
});
builder.Services.AddBlueLaminateCore(builder.Configuration);
// Worker result bodies carry some numbers as JSON strings (skin.land's item_float comes
// through as "0.60…"); allow string-encoded numbers so they bind, parsed straight to
// decimal (full precision preserved). Harmless to cs.money's numeric fields.
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.NumberHandling |= JsonNumberHandling.AllowReadingFromString);
// Re-sweep floor: don't re-hand-out a band whose listings were swept less than this many
// hours ago. The dominant cost on the metered residential proxy is re-scraping already-
// fresh bands, so this caps how often any band is re-pulled. 0 = continuous. Shared by
// both markets (each keeps its own per-site checkpoints, so the floors are independent).
var minResweepHours = builder.Configuration.GetValue("MinResweepHours", 6.0);
var floor = TimeSpan.FromHours(minResweepHours);
// One JobQueue per market source (same class, different checkpoint source + target). The
// candidate query reads each band's checkpoint for that queue's source only, so the two
// sweeps progress independently over the shared catalogue.
builder.Services.AddKeyedSingleton(CsMoneyIngestService.Source, new JobQueue(
CsMoneyIngestService.Source, floor,
c => $"{c.Weapon} {c.SkinName} {Wear.ToCode(c.Condition) ?? c.Condition}".Trim()));
builder.Services.AddKeyedSingleton(SkinLandIngestService.Source, new JobQueue(
SkinLandIngestService.Source, floor,
c => SkinLandSlug.MarketUrl(c.Weapon, c.SkinName, c.Condition)));
var app = builder.Build();
// Apply pending EF migrations at startup (incl. the market_listings view) so a fresh
// container is ready with one command. Disable with AutoMigrate=false if you'd rather run
// `dotnet ef database update` yourself.
if (app.Configuration.GetValue("AutoMigrate", true))
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SkinTrackerDbContext>();
db.Database.Migrate();
}
// Shared-secret gate. Workers send it as X-Worker-Token; if no token is configured the
// gate is open (local dev). Set WorkerToken (config) / WORKER_TOKEN (env) in prod.
var workerToken = builder.Configuration["WorkerToken"];
var maxPagesPerJob = builder.Configuration.GetValue("MaxPagesPerJob", 60);
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
// Operator read endpoints: "where is this listed?" across markets. Open (read-only).
app.MapGet("/market/skin/{skinId:int}", async (
int skinId, MarketPresenceService presence, CancellationToken ct) =>
Results.Ok(await presence.ForSkinAsync(skinId, ct)));
app.MapGet("/market/instance/{instanceId:int}", async (
int instanceId, MarketPresenceService presence, CancellationToken ct) =>
Results.Ok(await presence.ForInstanceAsync(instanceId, ct)));
// The same X-Worker-Token gate applied to every worker-facing route group.
Func<RouteGroupBuilder, RouteGroupBuilder> withTokenGate = group =>
{
group.AddEndpointFilter(async (ctx, next) =>
{
if (!string.IsNullOrEmpty(workerToken)
&& ctx.HttpContext.Request.Headers["X-Worker-Token"].ToString() != workerToken)
{
return Results.Unauthorized();
}
return await next(ctx);
});
return group;
};
// --- cs.money worker endpoints (unchanged behaviour) ------------------------------------
var jobs = withTokenGate(app.MapGroup("/jobs"));
// Claim the next stalest skin+wear to scrape. 204 when nothing is currently available
// (everything in the stalest batch is already leased to other workers).
jobs.MapGet("/next", async (
[FromKeyedServices(CsMoneyIngestService.Source)] JobQueue queue,
SkinTrackerDbContext db, CancellationToken ct) =>
{
var job = await queue.ClaimNextAsync(db, maxPagesPerJob, ct);
return job is null
? Results.NoContent()
: Results.Ok(new ScrapeJobDto(job.JobId, job.SkinId, job.ConditionId, job.Target, job.MaxPages));
});
// Post a claimed job's scraped listings. The C2 owns parsing/persistence so the worker
// stays dumb: it just forwards the raw cs.money items it gathered.
jobs.MapPost("/{jobId}/result", async (
string jobId, ScrapeResultDto result,
[FromKeyedServices(CsMoneyIngestService.Source)] JobQueue queue,
CsMoneyIngestService ingest, CancellationToken ct) =>
{
var mapping = queue.Complete(jobId);
if (mapping is null)
{
return Results.NotFound(new { error = "unknown or expired jobId" });
}
// Only a fully-walked sweep ("completed") is authoritative. On a partial result
// (fetch-cap / challenged / float tie) we still upsert what we saw, but we must NOT
// mark unseen listings Removed or stamp the swept-checkpoint — the unseen ones may
// simply be unfetched, and the band must be re-queued and retried.
var complete = string.Equals(result.StoppedReason, "completed", StringComparison.OrdinalIgnoreCase);
var r = await ingest.IngestAsync(mapping.SkinId, mapping.ConditionId, result.Items ?? [], complete, ct);
return Results.Ok(r);
});
// --- skin.land worker endpoints ---------------------------------------------------------
var skinLandJobs = withTokenGate(app.MapGroup("/skinland/jobs"));
skinLandJobs.MapGet("/next", async (
[FromKeyedServices(SkinLandIngestService.Source)] JobQueue queue,
SkinTrackerDbContext db, CancellationToken ct) =>
{
var job = await queue.ClaimNextAsync(db, maxPagesPerJob, ct);
return job is null
? Results.NoContent()
: Results.Ok(new SkinLandJobDto(job.JobId, job.SkinId, job.ConditionId, job.Target, job.MaxPages));
});
skinLandJobs.MapPost("/{jobId}/result", async (
string jobId, SkinLandResultDto result,
[FromKeyedServices(SkinLandIngestService.Source)] JobQueue queue,
SkinLandIngestService ingest, CancellationToken ct) =>
{
var mapping = queue.Complete(jobId);
if (mapping is null)
{
return Results.NotFound(new { error = "unknown or expired jobId" });
}
var complete = string.Equals(result.StoppedReason, "completed", StringComparison.OrdinalIgnoreCase);
var r = await ingest.IngestAsync(mapping.SkinId, mapping.ConditionId, result.Items ?? [], complete, ct);
return Results.Ok(r);
});
app.Run();

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"Urls": "http://0.0.0.0:5080",
"ConnectionStrings": {
"SkinTracker": "Host=localhost;Port=5432;Database=skintracker;Username=postgres"
},
"WorkerToken": "dev-worker-token",
"MaxPagesPerJob": 60,
"MinResweepHours": 6
}

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="BlueLaminate.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BlueLaminate.Core\BlueLaminate.Core.csproj" />
<ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using BlueLaminate.Scraper.CsFloat;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>fetch-listings</c>: fetch active CSFloat listings for one skin via the
/// official API and print them. Fetch-and-print only — nothing is written to the
/// database. Pure presentation over <see cref="CsFloatListingsClient"/>.
/// </summary>
internal static class FetchListingsCommand
{
public static Command Build(IHost host)
{
var defIndexOption = new Option<int?>("--def-index")
{
Description = "CSFloat weapon def_index (e.g. AK-47=7, M4A4=16)."
};
var paintIndexOption = new Option<int?>("--paint-index")
{
Description = "CSFloat paint_index for a specific skin (e.g. M4A4 | Cyber Security=985)."
};
var sortByOption = new Option<string>("--sort-by")
{
Description = "Listing sort order: lowest_price, highest_price, most_recent, "
+ "lowest_float, highest_float, best_deal, etc.",
DefaultValueFactory = _ => "lowest_price",
};
var maxOption = new Option<int>("--max")
{
Description = "Maximum number of listings to fetch (paged 50 at a time).",
DefaultValueFactory = _ => 50,
};
var dumpOption = new Option<string?>("--dump")
{
Description = "Optional file path to write the fetched listings as JSON."
};
var command = new Command(
"fetch-listings",
"Fetch active CSFloat listings for one skin via the official API and print them. "
+ "Reads CSFLOAT_API_KEY. Fetch-and-print only — nothing is written to the database.")
{
defIndexOption,
paintIndexOption,
sortByOption,
maxOption,
dumpOption,
};
command.SetAction((parseResult, ct) => RunAsync(
host,
parseResult.GetValue(defIndexOption),
parseResult.GetValue(paintIndexOption),
parseResult.GetValue(sortByOption)!,
parseResult.GetValue(maxOption),
parseResult.GetValue(dumpOption),
ct));
return command;
}
// Defaults to the M4A4 | Cyber Security sample so it runs with no args.
private static async Task<int> RunAsync(
IHost host, int? defIndex, int? paintIndex, string sortBy, int max, string? dumpPath,
CancellationToken ct)
{
var def = defIndex ?? 16;
var paint = paintIndex ?? 985;
using var scope = host.Services.CreateScope();
CsFloatListingsClient? client = null;
try
{
client = scope.ServiceProvider.GetRequiredService<CsFloatListingsClient>();
Console.WriteLine(
$"Fetching up to {max} active listings for def_index={def}, paint_index={paint} "
+ $"(sort: {sortBy})…");
var listings = await client.GetListingsAsync(def, paint, sortBy, max, ct: ct);
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
Console.WriteLine();
if (listings.Count == 0)
{
Console.WriteLine("No active listings found.");
return 0;
}
Console.WriteLine($"{listings.Count} listing(s):");
Console.WriteLine($" {"Price",10} {"Float",-10} {"Seed",-6} {"Wear",-16} {"Name"}");
foreach (var l in listings)
{
var st = (l.IsStatTrak ? " ST" : "") + (l.IsSouvenir ? " SV" : "")
+ (l.StickerCount > 0 ? $" +{l.StickerCount}stk" : "");
Console.WriteLine(
$" {l.Price,10:C} {l.FloatValue,-10:0.000000} {l.PaintSeed,-6} "
+ $"{l.WearName,-16} {l.MarketHashName}{st}");
}
if (!string.IsNullOrWhiteSpace(dumpPath))
{
var json = System.Text.Json.JsonSerializer.Serialize(
listings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(dumpPath, json, ct);
Console.WriteLine();
Console.WriteLine($"Wrote {listings.Count} listing(s) to {Path.GetFullPath(dumpPath)}");
}
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
if (client is not null)
{
Console.Error.WriteLine(client.LastRateLimit.ToString());
}
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Fetch failed: {ex.Message}");
return 1;
}
}
}

View File

@@ -0,0 +1,267 @@
using BlueLaminate.Cli.Tui;
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Tradeups;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>find-tradeups</c>: surfaces the most profitable 10-input tradeup contracts over the
/// live listings. Pure presentation over <see cref="TradeupFinder.FindAsync"/> — all the
/// economics live in the Core engine so the future web UI shares them verbatim. The CLI
/// flags only override <see cref="TradeupOptions"/> for the run.
/// <para>
/// In an interactive terminal it opens the <see cref="TradeupBrowser"/> TUI; pipe the
/// output or pass <c>--plain</c> for the scriptable table dump.
/// </para>
/// </summary>
internal static class FindTradeupsCommand
{
public static Command Build(IHost host)
{
var topOption = new Option<int>("--top")
{
Description = "How many contracts to show.",
DefaultValueFactory = _ => 20,
};
var minProfitOption = new Option<decimal?>("--min-profit")
{
Description = "Only show contracts whose ranking profit clears this amount (USD).",
};
var statTrakOption = new Option<StatTrakMode?>("--stattrak")
{
Description = "Which universes to search: Both, NonStatTrakOnly, or StatTrakOnly.",
};
var rankingOption = new Option<TradeupRanking?>("--rank")
{
Description = "Rank by WorstCaseProfit (guaranteed) or ExpectedProfit.",
};
var allowRiskyOption = new Option<bool>("--allow-risky")
{
Description = "Include contracts that aren't guaranteed-profit (off by default).",
};
var detailOption = new Option<bool>("--detail")
{
Description = "Plain mode only: show the per-output distribution and the copies to buy.",
};
var plainOption = new Option<bool>("--plain")
{
Description = "Force the non-interactive table dump instead of the TUI.",
};
var command = new Command(
"find-tradeups",
"Find profitable 10-input CS2 tradeup contracts from live listings, ranked best-first.")
{
topOption,
minProfitOption,
statTrakOption,
rankingOption,
allowRiskyOption,
detailOption,
plainOption,
};
command.SetAction((parseResult, ct) => RunAsync(
host,
parseResult.GetValue(topOption),
parseResult.GetValue(minProfitOption),
parseResult.GetValue(statTrakOption),
parseResult.GetValue(rankingOption),
parseResult.GetValue(allowRiskyOption),
parseResult.GetValue(detailOption),
parseResult.GetValue(plainOption),
ct));
return command;
}
private static async Task<int> RunAsync(
IHost host,
int top,
decimal? minProfit,
StatTrakMode? statTrak,
TradeupRanking? ranking,
bool allowRisky,
bool detail,
bool plain,
CancellationToken ct)
{
using var scope = host.Services.CreateScope();
// Apply per-run overrides on top of the configured TradeupOptions. The finder reads
// IOptions, so mutate the resolved instance for this scope only.
var options = scope.ServiceProvider.GetRequiredService<IOptions<TradeupOptions>>().Value;
if (minProfit is { } mp)
{
options.MinProfit = mp;
}
if (statTrak is { } st)
{
options.StatTrak = st;
}
if (ranking is { } r)
{
options.Ranking = r;
}
if (allowRisky)
{
options.GuaranteedOnly = false;
}
var interactive = !plain && TradeupBrowser.IsSupported;
// When launched bare (no search-policy flags), open the TUI on its settings screen so
// the options are tweakable in-app. Passing any policy flag skips straight to results
// (the settings screen is still reachable from there via "Adjust & re-run").
var noPolicyFlags = minProfit is null && statTrak is null && ranking is null && !allowRisky;
var showSettings = interactive && noPolicyFlags;
try
{
var finder = scope.ServiceProvider.GetRequiredService<TradeupFinder>();
while (true)
{
if (showSettings && TradeupBrowser.PromptSettings(options, ref top) == SettingsAction.Quit)
{
return 0;
}
Console.WriteLine(
$"Searching tradeups ({options.StatTrak}, rank by {options.Ranking}, "
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")}, "
+ $"min profit {options.MinProfit:C})…");
Console.WriteLine();
var candidates = await finder.FindAsync(maxResults: top, ct: ct);
if (!interactive)
{
if (candidates.Count == 0)
{
Console.WriteLine("No qualifying contracts found.");
}
else
{
Print(candidates, options.Ranking, detail);
}
return 0;
}
if (candidates.Count == 0)
{
Console.WriteLine("No qualifying contracts found — adjusting settings.");
Console.WriteLine();
showSettings = true;
continue;
}
if (TradeupBrowser.Run(candidates, options) == BrowseAction.Quit)
{
return 0;
}
// BrowseAction.AdjustSettings → loop back to the settings screen and re-search.
showSettings = true;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"Tradeup search failed: {ex.Message}");
return 1;
}
}
private static void Print(
IReadOnlyList<TradeupCandidate> candidates, TradeupRanking ranking, bool detail)
{
Console.WriteLine(
$"{"#",-3} {"Collection",-34} {"Recipe",-22} {"ST",-3} "
+ $"{"Cost",10} {"E[net]",10} {"Worst",10} {"E[profit]",11} {"Worst P/L",11} {"G",2}");
Console.WriteLine(new string('-', 140));
var rank = 1;
foreach (var c in candidates)
{
var recipe = c.CollectionCount > 1
? $"{c.InputRarity}→mix×{c.CollectionCount}"
: $"{c.InputRarity}→{c.OutputRarity}";
var guaranteed = c.Guaranteed ? "✓" : " ";
Console.WriteLine(
$"{rank,-3} {Truncate(c.CollectionName, 34),-34} {recipe,-22} "
+ $"{(c.StatTrak ? "ST" : ""),-3} "
+ $"{c.InputCost,10:C} {c.ExpectedNet,10:C} {c.WorstCaseNet,10:C} "
+ $"{c.ExpectedProfit,11:C} {c.WorstCaseProfit,11:C} {guaranteed,2}");
if (detail)
{
PrintDetail(c);
}
rank++;
}
Console.WriteLine();
Console.WriteLine(
$"Ranked by {ranking}. 'G' marks guaranteed (every output clears input cost). "
+ "Sell prices are net of undercut + fee; outputs with no comparable listing are unpriced.");
}
private static void PrintDetail(TradeupCandidate c)
{
if (c.CollectionCount > 1)
{
var mix = string.Join(", ", c.Composition.Select(p =>
$"{p.InputCount}× {p.CollectionName} → {p.OutputRarity}"));
Console.WriteLine($" mix: {mix}");
}
Console.WriteLine(
$" avg input fraction {c.AverageFraction:F4} — "
+ $"possible outputs ({c.Outcomes.Count}):");
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
{
var price = o.NetSellPrice is { } net ? net.ToString("C") : "(unpriced)";
var source = o.PriceSource switch
{
"csfloat-live" => " (csfloat live)",
"market-floor" => " (floor est)",
_ => string.Empty,
};
Console.WriteLine(
$" {o.Probability,6:P1} {Truncate(o.Name, 44),-44} "
+ $"float {o.OutputFloat:F4} {o.Band,-14} {price,10} liq {o.Liquidity}{source}");
}
// The actionable buy list: each of the ten copies on its own line — exactly what to
// search for, on which market, at what float and price. Sorted cheapest-first.
Console.WriteLine($" buy ({c.Inputs.Count} inputs, total {c.InputCost:C}):");
var n = 1;
foreach (var input in c.Inputs.OrderBy(i => i.Price))
{
var locator = string.IsNullOrWhiteSpace(input.InspectLink)
? $"id {input.ExternalId}"
: input.InspectLink;
Console.WriteLine(
$" {n,2}. {Truncate(input.MarketHashName, 52),-52} "
+ $"float {FullFloat(input.FloatValue),-20} {input.Price,9:C} @ {input.Marketplace,-9} {locator}");
n++;
}
}
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..(max - 1)] + "…";
// Full stored listing float (trailing zeros dropped) so a copy is matchable exactly.
private static string FullFloat(decimal value)
=> value.ToString("0.##################", System.Globalization.CultureInfo.InvariantCulture);
}

View File

@@ -0,0 +1,74 @@
using BlueLaminate.Core.Listings;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>sweep-catalog</c>: catalogue-driven sweep querying each catalogue skin's
/// listings by def_index+paint_index. Presentation over
/// <see cref="ListingSweepService.SweepCatalogAsync"/>.
/// </summary>
internal static class SweepCatalogCommand
{
public static Command Build(IHost host)
{
var command = new Command(
"sweep-catalog",
"Catalogue-driven sweep: query each catalogue skin's listings by def_index+paint_index, "
+ "split by wear band (min_float/max_float), so only weapons are fetched (no "
+ "stickers/cases/agents) and each wear band is an independent checkpoint. Each band is "
+ "paged to completion, so Removed-tracking is accurate. Runs continuously (looping the "
+ "catalogue, never-swept/stalest bands first) until Ctrl+C; paces off rate-limit headers. "
+ "Reads CSFLOAT_API_KEY.");
command.SetAction((parseResult, ct) => RunAsync(host, ct));
return command;
}
private static async Task<int> RunAsync(IHost host, CancellationToken ct)
{
using var scope = host.Services.CreateScope();
CsFloatListingsClient? client = null;
try
{
var service = scope.ServiceProvider.GetRequiredService<ListingSweepService>();
client = scope.ServiceProvider.GetRequiredService<CsFloatListingsClient>();
Console.WriteLine("Catalogue sweep (weapons only). Running until Ctrl+C…");
var r = await service.SweepCatalogAsync(ct: ct);
Console.WriteLine();
Console.WriteLine($"Catalogue sweep {r.StoppedReason}:");
Console.WriteLine($" Wear-bands : {r.SkinsCovered}");
Console.WriteLine($" Pages fetched : {r.Pages}");
Console.WriteLine($" Listings seen : {r.Seen}");
Console.WriteLine($" Inserted : {r.Inserted}");
Console.WriteLine($" Updated : {r.Updated}");
Console.WriteLine($" Removed : {r.Removed}");
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
if (client is not null)
{
Console.Error.WriteLine(client.LastRateLimit.ToString());
}
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Catalogue sweep failed: {ex.Message}");
return 1;
}
}
}

View File

@@ -0,0 +1,102 @@
using BlueLaminate.Core.Listings;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>sweep-listings</c>: global incremental sweep of active CSFloat listings into
/// the database. Presentation over <see cref="ListingSweepService.SweepAsync"/>.
/// </summary>
internal static class SweepListingsCommand
{
public static Command Build(IHost host)
{
var maxRequestsOption = new Option<int>("--max-requests")
{
Description = "Hard cap on API pages this run (rate-limit budget; 200/window).",
DefaultValueFactory = _ => 4,
};
var maxIngestOption = new Option<int>("--max-listings")
{
Description = "Hard cap on listings ingested this run.",
DefaultValueFactory = _ => 200,
};
var fullOption = new Option<bool>("--full")
{
Description = "Cold full pass: keep paging past already-seen listings (default is "
+ "incremental — stop once caught up)."
};
var command = new Command(
"sweep-listings",
"Global incremental sweep of active CSFloat listings into the database. Pages most_recent, "
+ "upserts by listing id, paces off rate-limit headers. Reads CSFLOAT_API_KEY.")
{
maxRequestsOption,
maxIngestOption,
fullOption,
};
command.SetAction((parseResult, ct) => RunAsync(
host,
parseResult.GetValue(maxRequestsOption),
parseResult.GetValue(maxIngestOption),
parseResult.GetValue(fullOption),
ct));
return command;
}
private static async Task<int> RunAsync(
IHost host, int maxRequests, int maxListings, bool full, CancellationToken ct)
{
using var scope = host.Services.CreateScope();
CsFloatListingsClient? client = null;
try
{
var service = scope.ServiceProvider.GetRequiredService<ListingSweepService>();
client = scope.ServiceProvider.GetRequiredService<CsFloatListingsClient>();
Console.WriteLine(
$"Sweeping listings ({(full ? "full cold pass" : "incremental")}; "
+ $"max {maxRequests} requests, {maxListings} listings)…");
var r = await service.SweepAsync(
maxRequests: maxRequests,
maxListings: maxListings,
incremental: !full,
ct: ct);
Console.WriteLine();
Console.WriteLine($"Sweep complete ({r.StoppedReason}):");
Console.WriteLine($" Pages fetched : {r.Pages}");
Console.WriteLine($" Listings seen : {r.Seen}");
Console.WriteLine($" Inserted : {r.Inserted}");
Console.WriteLine($" Updated : {r.Updated}");
Console.WriteLine($" Removed : {r.Removed}");
Console.WriteLine($" Catalog-linked: {r.Linked}");
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
if (client is not null)
{
Console.Error.WriteLine(client.LastRateLimit.ToString());
}
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Sweep failed: {ex.Message}");
return 1;
}
}
}

View File

@@ -0,0 +1,98 @@
using BlueLaminate.Core.Skins;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.CommandLine;
namespace BlueLaminate.Cli.Commands;
/// <summary>
/// <c>sync-skins</c>: load the CS2 skin catalogue and upsert it (throttled monthly).
/// Presentation over <see cref="SkinSyncService.SyncAsync"/>; <c>--dry-run</c>
/// loads and prints via <see cref="SkinCatalogClient"/> without touching the DB.
/// </summary>
internal static class SyncSkinsCommand
{
public static Command Build(IHost host)
{
var forceOption = new Option<bool>("--force")
{
Description = "Ignore the once-a-month throttle and sync now."
};
var dryRunOption = new Option<bool>("--dry-run")
{
Description = "Load and print the skins without writing to the database."
};
var command = new Command(
"sync-skins",
"Load the CS2 skin catalogue from the CSGO-API dataset and upsert it (throttled to once a month).")
{
forceOption,
dryRunOption,
};
command.SetAction((parseResult, ct) => RunAsync(
host,
parseResult.GetValue(forceOption),
parseResult.GetValue(dryRunOption),
ct));
return command;
}
private static async Task<int> RunAsync(IHost host, bool force, bool dryRun, CancellationToken ct)
{
using var scope = host.Services.CreateScope();
if (dryRun)
{
return await DryRunAsync(scope.ServiceProvider, ct);
}
var service = scope.ServiceProvider.GetRequiredService<SkinSyncService>();
var result = await service.SyncAsync(force, ct);
if (result.Skipped)
{
Console.WriteLine(
$"Skipped: skins were last synced {result.LastRanAt:u}. "
+ "Next run allowed one month later — pass --force to override.");
}
else
{
Console.WriteLine(
$"Synced {result.Loaded} skins: {result.Inserted} inserted, "
+ $"{result.Updated} updated, "
+ $"{result.Loaded - result.Inserted - result.Updated} unchanged "
+ $"({result.WeaponsCreated} weapons, {result.CollectionsCreated} collections created).");
}
return 0;
}
// Loads the catalogue and prints it without a database — no service involved.
private static async Task<int> DryRunAsync(IServiceProvider sp, CancellationToken ct)
{
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("BlueLaminate.Cli.SyncSkins");
var client = sp.GetRequiredService<SkinCatalogClient>();
logger.LogInformation("Loading skin catalogue (dry run — nothing will be written).");
var skins = await client.FetchAsync(ct);
logger.LogInformation("Loaded {Count} skins.", skins.Count);
Console.WriteLine($"Loaded {skins.Count} skins (dry run, nothing written):");
foreach (var s in skins)
{
var tags = (s.StatTrakAvailable ? " ST" : "") + (s.SouvenirAvailable ? " SV" : "");
var range = s.FloatMin is not null ? $"{s.FloatMin:0.00}-{s.FloatMax:0.00}" : "—";
var sources = s.Sources.Count > 0 ? string.Join(", ", s.Sources.Select(x => x.Name)) : "—";
var idx = $"{s.DefIndex?.ToString() ?? ""}/{s.PaintIndex?.ToString() ?? ""}";
Console.WriteLine(
$" {idx,-10} {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
}
return 0;
}
}

View File

@@ -0,0 +1,23 @@
using OpenTelemetry;
using OpenTelemetry.Logs;
namespace BlueLaminate.Cli.Logging;
/// <summary>
/// Minimal console sink for the OpenTelemetry log pipeline: one line per record
/// as "{utc timestamp} {message}". Requires IncludeFormattedMessage so the
/// message arrives with its template arguments already substituted.
/// </summary>
public sealed class CompactConsoleLogExporter : BaseExporter<LogRecord>
{
public override ExportResult Export(in Batch<LogRecord> batch)
{
foreach (var record in batch)
{
var message = record.FormattedMessage ?? record.Body ?? string.Empty;
Console.WriteLine($"[{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff'Z'}] {message}");
}
return ExportResult.Success;
}
}

View File

@@ -0,0 +1,88 @@
using BlueLaminate.Cli.Commands;
using BlueLaminate.Cli.Logging;
using BlueLaminate.Core.DependencyInjection;
using BlueLaminate.EFCore.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenTelemetry;
using OpenTelemetry.Resources;
using System.CommandLine;
// Generic Host = composition root. The exact same wiring a web frontend would use:
// configuration → AddBlueLaminateCore → resolve services per command scope. Args are
// deliberately NOT handed to the host (System.CommandLine owns parsing; the host's
// command-line config provider would reject bare verbs like "sync-skins"). The
// content root is the binary directory so appsettings.json is found regardless of CWD.
var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
ContentRootPath = AppContext.BaseDirectory,
});
// Reuse the connection string stored in the EFCore project's user secrets (dev).
builder.Configuration.AddUserSecrets<SkinTrackerDbContextFactory>(optional: true);
// OpenTelemetry logging through a compact console sink that prints one
// "{utc timestamp} {message}" line per record. Swapping in an OTLP exporter later
// is a change here. ClearProviders drops the default console logger so we don't
// double-print.
builder.Logging.ClearProviders();
// IHttpClientFactory logs each request at Information under these categories; mute
// to Warning so the compact console stays one line per app message.
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
// EF Core logs every SQL command at Information; we only care about failures, so
// raise its floor to Warning (failed commands still log at Error).
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
builder.Logging.AddOpenTelemetry(otel =>
{
otel.SetResourceBuilder(
ResourceBuilder.CreateDefault().AddService("BlueLaminate.Cli"));
otel.IncludeFormattedMessage = true;
otel.AddProcessor(new SimpleLogRecordExportProcessor(new CompactConsoleLogExporter()));
});
builder.Services.AddBlueLaminateCore(builder.Configuration);
using var host = builder.Build();
// This CLI builds the host but doesn't run it, so ValidateOnStart won't fire on its
// own — trigger it explicitly. Invalid configuration (e.g. CsFloat:MaxLimit out of
// range) fails fast here with a clear message instead of being silently clamped.
try
{
host.Services.GetRequiredService<IStartupValidator>().Validate();
}
catch (OptionsValidationException ex)
{
Console.Error.WriteLine("Invalid configuration:");
foreach (var failure in ex.Failures)
{
Console.Error.WriteLine($" - {failure}");
}
return 1;
}
// System.CommandLine builds the command tree, parsing, and help. Each command lives
// in its own file under Commands/ and resolves its service from a DI scope.
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
SyncSkinsCommand.Build(host),
FetchListingsCommand.Build(host),
SweepListingsCommand.Build(host),
SweepCatalogCommand.Build(host),
FindTradeupsCommand.Build(host),
};
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
// which loops until stopped) unwind gracefully instead of hard-killing the process
// mid-write.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true; // prevent immediate termination; let the token cancel cleanly
cts.Cancel();
};
return await root.Parse(args).InvokeAsync(cancellationToken: cts.Token);

View File

@@ -0,0 +1,352 @@
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Tradeups;
using Spectre.Console;
using System.Globalization;
namespace BlueLaminate.Cli.Tui;
/// <summary>What the user chose to do next after browsing results.</summary>
internal enum BrowseAction
{
/// <summary>Leave the finder.</summary>
Quit,
/// <summary>Return to the settings screen and run a fresh search.</summary>
AdjustSettings,
}
/// <summary>What the user chose on the settings screen.</summary>
internal enum SettingsAction
{
/// <summary>Run a search with the current settings.</summary>
RunSearch,
/// <summary>Leave the finder.</summary>
Quit,
}
/// <summary>
/// Interactive terminal browser for tradeup candidates: a settings screen, then an
/// arrow-key navigable list of contracts that drills into a detail view with the output
/// distribution and a line-by-line buy list (each copy linked to its source listing).
/// Pure presentation over the <see cref="TradeupCandidate"/>s the Core engine produces; it
/// only mutates a <see cref="TradeupOptions"/> the command then feeds back to the finder.
/// <para>
/// The render/prompt methods take an <see cref="IAnsiConsole"/> so they can be exercised
/// against a recording console in tests; only the live loop needs the real terminal.
/// </para>
/// </summary>
internal static class TradeupBrowser
{
private const int QuitSentinel = -1;
private const int AdjustSentinel = -2;
/// <summary>Whether the current terminal can host the interactive prompts.</summary>
public static bool IsSupported => AnsiConsole.Profile.Capabilities.Interactive;
/// <summary>
/// Settings screen: shows the current search options and lets the user tweak any of them
/// before running. Mutates <paramref name="options"/> and <paramref name="top"/> in place;
/// returns whether to run a search or quit.
/// </summary>
public static SettingsAction PromptSettings(TradeupOptions options, ref int top)
=> PromptSettings(AnsiConsole.Console, options, ref top);
public static SettingsAction PromptSettings(IAnsiConsole console, TradeupOptions options, ref int top)
{
while (true)
{
console.Clear();
console.Write(new Rule("[bold yellow]Tradeup Finder[/] — settings")
{
Justification = Justify.Left,
});
console.MarkupLine("[grey]Pick a setting to change it, then [green]Run search[/]. (↑/↓ + Enter)[/]");
console.WriteLine();
const string run = "[green]▶ Run search[/]";
const string quit = "[red]✕ Quit[/]";
var stItem = $"StatTrak universe · [aqua]{options.StatTrak}[/]";
var rankItem = $"Rank by · [aqua]{options.Ranking}[/]";
var guarItem = "Profit filter · "
+ (options.GuaranteedOnly ? "[green]guaranteed only[/]" : "[yellow]include risky[/]");
var minItem = $"Min profit · [aqua]{options.MinProfit:C}[/]";
var topItem = $"Show top · [aqua]{top}[/]";
var choice = console.Prompt(
new SelectionPrompt<string>()
.Title("Settings")
.PageSize(12)
.AddChoices(run, stItem, rankItem, guarItem, minItem, topItem, quit));
if (choice == run)
{
return SettingsAction.RunSearch;
}
if (choice == quit)
{
console.Clear();
return SettingsAction.Quit;
}
if (choice == stItem)
{
options.StatTrak = console.Prompt(
new SelectionPrompt<StatTrakMode>()
.Title("StatTrak universe to search")
.AddChoices(StatTrakMode.Both, StatTrakMode.NonStatTrakOnly, StatTrakMode.StatTrakOnly));
}
else if (choice == rankItem)
{
options.Ranking = console.Prompt(
new SelectionPrompt<TradeupRanking>()
.Title("Rank surviving contracts by")
.AddChoices(TradeupRanking.WorstCaseProfit, TradeupRanking.ExpectedProfit));
}
else if (choice == guarItem)
{
options.GuaranteedOnly = console.Prompt(
new SelectionPrompt<string>()
.Title("Which contracts should survive?")
.AddChoices("Guaranteed only (worst output still profits)", "Include risky (any positive EV)"))
.StartsWith("Guaranteed");
}
else if (choice == minItem)
{
options.MinProfit = console.Prompt(
new TextPrompt<decimal>("Minimum ranking profit (USD):")
.DefaultValue(options.MinProfit)
.ShowDefaultValue());
}
else if (choice == topItem)
{
top = console.Prompt(
new TextPrompt<int>("Show how many contracts:")
.DefaultValue(top)
.ShowDefaultValue()
.Validate(v => v > 0 ? ValidationResult.Success() : ValidationResult.Error("must be > 0")));
}
}
}
public static BrowseAction Run(IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
=> Run(AnsiConsole.Console, candidates, options);
public static BrowseAction Run(
IAnsiConsole console, IReadOnlyList<TradeupCandidate> candidates, TradeupOptions options)
{
while (true)
{
console.Clear();
RenderHeader(console, candidates.Count, options);
var indices = Enumerable.Range(0, candidates.Count)
.Append(AdjustSentinel)
.Append(QuitSentinel)
.ToList();
var selected = console.Prompt(
new SelectionPrompt<int>()
.Title("Select a contract to inspect its [green]buy list[/]:")
.PageSize(18)
.WrapAround()
.MoreChoicesText("[grey](↑/↓ to scroll, type to filter)[/]")
.AddChoices(indices)
.UseConverter(ListChoiceLabel(candidates)));
switch (selected)
{
case QuitSentinel:
console.Clear();
return BrowseAction.Quit;
case AdjustSentinel:
return BrowseAction.AdjustSettings;
}
RenderDetail(console, candidates[selected], selected);
const string back = "← Back to list";
const string adjust = "⚙ Adjust settings & re-run";
const string quit = "Quit";
var next = console.Prompt(
new SelectionPrompt<string>()
.Title(string.Empty)
.AddChoices(back, adjust, quit));
if (next == quit)
{
console.Clear();
return BrowseAction.Quit;
}
if (next == adjust)
{
return BrowseAction.AdjustSettings;
}
}
}
private static Func<int, string> ListChoiceLabel(IReadOnlyList<TradeupCandidate> candidates) => i => i switch
{
QuitSentinel => "[red]Quit[/]",
AdjustSentinel => "[yellow]⚙ Adjust settings & re-run[/]",
_ => SummaryLine(candidates[i], i),
};
private static void RenderHeader(IAnsiConsole console, int count, TradeupOptions options)
{
console.Write(new Rule($"[bold yellow]Tradeup Finder[/] — {count} contracts")
{
Justification = Justify.Left,
});
console.MarkupLine(
$"[grey]{options.StatTrak} · rank by {options.Ranking} · "
+ $"{(options.GuaranteedOnly ? "guaranteed-only" : "incl. risky")} · "
+ $"sell net of {options.UndercutRate:P0} undercut + {options.SellFeeRate:P0} fee[/]");
console.WriteLine();
}
// One aligned, lightly-coloured line per contract for the selection list. Padding is
// applied to the raw text before markup so columns stay aligned (skin/collection names
// contain no markup brackets in practice; escaped defensively anyway).
internal static string SummaryLine(TradeupCandidate c, int index)
{
var rank = $"{index + 1,3}.";
var collection = Truncate(c.CollectionName, 30).PadRight(30);
var recipeText = c.CollectionCount > 1
? $"{c.InputRarity}→mix×{c.CollectionCount}"
: $"{c.InputRarity}→{c.OutputRarity}";
var recipe = recipeText.PadRight(21);
var st = c.StatTrak ? "ST " : " ";
var worst = Money(c.WorstCaseProfit).PadLeft(10);
var expected = Money(c.ExpectedProfit).PadLeft(10);
var flag = c.Guaranteed ? "[green]✓[/]" : " ";
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
return $"{rank} {Markup.Escape(collection)} [aqua]{Markup.Escape(recipe)}[/] {st} "
+ $"cost [silver]{Money(c.InputCost),10}[/] worst [{worstColor}]{worst}[/] "
+ $"exp [green]{expected}[/] {flag}";
}
internal static void RenderDetail(IAnsiConsole console, TradeupCandidate c, int index)
{
console.Clear();
var recipe = c.CollectionCount > 1 ? $"{c.InputRarity}→mix" : $"{c.InputRarity}→{c.OutputRarity}";
var title = $"#{index + 1} [bold]{Markup.Escape(c.CollectionName)}[/] "
+ $"[aqua]{recipe}[/]{(c.StatTrak ? " [orange1]StatTrak[/]" : string.Empty)}";
console.Write(new Rule(title) { Justification = Justify.Left });
if (c.CollectionCount > 1)
{
var mix = string.Join(" ", c.Composition.Select(p =>
$"[silver]{p.InputCount}×[/] {Markup.Escape(p.CollectionName)} [grey]→ {p.OutputRarity}[/]"));
console.MarkupLine($"[grey]mix:[/] {mix}");
}
var worstColor = c.WorstCaseProfit > 0 ? "green" : "red";
var economics = new Grid();
economics.AddColumns(6);
economics.AddRow(
"[grey]Input cost[/]", "[grey]E(net)[/]", "[grey]Worst net[/]",
"[grey]E(profit)[/]", "[grey]Worst P/L[/]", "[grey]Avg float frac[/]");
economics.AddRow(
$"[silver]{Money(c.InputCost)}[/]",
$"{Money(c.ExpectedNet)}",
$"{Money(c.WorstCaseNet)}",
$"[green]{Money(c.ExpectedProfit)}[/]",
$"[{worstColor}]{Money(c.WorstCaseProfit)}[/]",
$"{c.AverageFraction:F4}");
console.Write(new Panel(economics)
{
Header = new PanelHeader(c.Guaranteed ? " [green]GUARANTEED[/] " : " [yellow]not guaranteed[/] "),
Border = BoxBorder.Rounded,
});
RenderOutcomes(console, c);
RenderBuyList(console, c);
}
private static void RenderOutcomes(IAnsiConsole console, TradeupCandidate c)
{
var table = new Table().Border(TableBorder.Minimal).Title("[bold]Possible outputs[/]");
table.AddColumn(new TableColumn("Chance").RightAligned());
table.AddColumn("Output");
table.AddColumn(new TableColumn("Float").RightAligned());
table.AddColumn("Wear");
table.AddColumn(new TableColumn("Net sell").RightAligned());
table.AddColumn(new TableColumn("Liq").RightAligned());
foreach (var o in c.Outcomes.OrderByDescending(o => o.NetSellPrice ?? -1m))
{
var price = o.NetSellPrice is { } net ? $"[green]{Money(net)}[/]" : "[grey](unpriced)[/]";
var liquidity = o.PriceSource switch
{
"csfloat-live" => $"{o.Liquidity} [blue]· csfloat live[/]",
"market-floor" => $"{o.Liquidity} [yellow]· floor est[/]",
_ => o.Liquidity.ToString(),
};
table.AddRow(
$"{o.Probability:P1}",
Markup.Escape(o.Name),
$"{o.OutputFloat:F4}",
Markup.Escape(o.Band.ToName()),
price,
liquidity);
}
console.Write(table);
}
private static void RenderBuyList(IAnsiConsole console, TradeupCandidate c)
{
var table = new Table().Border(TableBorder.Minimal)
.Title($"[bold]Buy list[/] — {c.Inputs.Count} inputs, total [silver]{Money(c.InputCost)}[/]");
table.AddColumn(new TableColumn("#").RightAligned());
table.AddColumn("Item");
table.AddColumn(new TableColumn("Float").RightAligned().NoWrap());
table.AddColumn(new TableColumn("Price").RightAligned());
table.AddColumn("Market");
table.AddColumn("Listing");
var n = 1;
foreach (var input in c.Inputs.OrderBy(i => i.Price))
{
table.AddRow(
n.ToString(),
Markup.Escape(input.MarketHashName),
FullFloat(input.FloatValue),
$"[silver]{Money(input.Price)}[/]",
Markup.Escape(input.Marketplace),
ListingLink(input));
n++;
}
console.Write(table);
}
// A clickable inspect link in terminals that support OSC-8 hyperlinks; otherwise the
// external listing id, so the copy is still traceable.
private static string ListingLink(InputListing input)
{
if (!string.IsNullOrWhiteSpace(input.InspectLink)
&& !input.InspectLink.Contains('[')
&& !input.InspectLink.Contains(']'))
{
return $"[link={input.InspectLink}]inspect[/]";
}
return $"[grey]{Markup.Escape(input.ExternalId)}[/]";
}
private static string Money(decimal value) => value.ToString("C");
// The real listing float at full stored precision (trailing zeros dropped), so a copy can
// be matched exactly on the market. Invariant culture keeps the decimal point a dot.
private static string FullFloat(decimal value)
=> value.ToString("0.##################", CultureInfo.InvariantCulture);
private static string Truncate(string value, int max)
=> value.Length <= max ? value : value[..(max - 1)] + "…";
}

View File

@@ -0,0 +1,19 @@
{
"ConnectionStrings": {
"SkinTracker": "Host=localhost;Port=5432;Database=skintracker;Username=postgres"
},
"CsFloat": {
"ApiKey": "",
"BaseUrl": "https://csfloat.com/api/v1/listings",
"MaxLimit": 50
},
"SkinCatalog": {
"Url": "https://raw.githubusercontent.com/ByMykel/CSGO-API/refs/heads/main/public/api/en/skins.json"
},
"Sweep": {
"PageDelay": "00:00:05",
"MaxJitter": "00:00:03",
"RateLimitSafetyMargin": 2,
"RateLimitCooldown": "00:01:00"
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BlueLaminate.EFCore\BlueLaminate.EFCore.csproj" />
<ProjectReference Include="..\BlueLaminate.Scraper\BlueLaminate.Scraper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,386 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.CsMoney;
/// <summary>Outcome of ingesting one skin+wear scrape job's results.</summary>
public sealed record CsMoneyIngestResult(
int Matched, int Inserted, int Updated, int Removed, int Skipped);
/// <summary>
/// Persists the listings the worker scraped for one targeted skin+wear job into the
/// <c>cs_money_listings</c> table. Mirrors the CSFloat <c>ListingSweepService</c>
/// patterns — upsert by natural key, resolve each listing to a market-agnostic
/// <see cref="SkinInstance"/> by fingerprint, soft-track Removed, flag dupes — but
/// scoped to the one skin+condition the job targeted (so it's the per-band unit, and
/// Removed-tracking is exact). cs.money's free-text search is fuzzy, so results are
/// filtered to the intended skin (by name) and wear (by quality) before persisting.
/// </summary>
public sealed class CsMoneyIngestService
{
public const string Source = SweepSource.CsMoney;
private readonly SkinTrackerDbContext _db;
private readonly ILogger<CsMoneyIngestService> _logger;
public CsMoneyIngestService(SkinTrackerDbContext db, ILogger<CsMoneyIngestService> logger)
{
_db = db;
_logger = logger;
}
/// <param name="complete">
/// True only when the worker walked the whole skin+wear (stoppedReason "completed").
/// On a partial sweep we upsert what we saw but skip Removed-marking, the price
/// point, and the swept-checkpoint — unseen listings may just be unfetched, so the
/// band stays un-stamped and gets re-queued rather than being wrongly pruned.
/// </param>
public async Task<CsMoneyIngestResult> IngestAsync(
int skinId, int? conditionId, IReadOnlyList<CsMoneyItem> items, bool complete, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var skin = await _db.Skins
.Where(s => s.Id == skinId)
.Select(s => new { s.Id, s.Name, Weapon = s.Weapon.Name })
.FirstOrDefaultAsync(ct);
if (skin is null)
{
_logger.LogWarning("Ingest skipped: skin {SkinId} not found.", skinId);
return new CsMoneyIngestResult(0, 0, 0, 0, items.Count);
}
string? conditionName = null;
if (conditionId is { } cid)
{
conditionName = await _db.SkinConditions
.Where(c => c.Id == cid).Select(c => c.Condition).FirstOrDefaultAsync(ct);
}
var expectedShort = Normalize($"{skin.Weapon} | {skin.Name}");
var expectedQuality = Wear.ToCode(conditionName);
// cs.money search is fuzzy — keep only items that are actually this skin (by
// name) and, when the job targets a wear band, this wear (by quality).
var matched = items.Where(it =>
{
var a = it.Asset;
if (a?.Names?.Short is null)
{
return false;
}
if (Normalize(a.Names.Short) != expectedShort)
{
return false;
}
return expectedQuality is null
|| string.Equals(a.Quality, expectedQuality, StringComparison.OrdinalIgnoreCase);
}).ToList();
// Of the name/wear matches, keep only listings with a usable price. cs.money's
// pricing.default is the DISCOUNTED display price and occasionally arrives <= 0
// (its discount math underflows, or a price-less render returns 0); a non-positive
// asking price is impossible and would poison the cheapest-price point downstream.
// Fall back to priceBeforeDiscount when default isn't positive, and drop the
// listing only when neither is — counting those as skipped like a filter miss.
var priced = new List<(CsMoneyItem Item, decimal Price)>(matched.Count);
var droppedNoPrice = 0;
var pricedByFallback = 0;
foreach (var it in matched)
{
if (ResolvePrice(it.Pricing) is not { } px)
{
droppedNoPrice++;
continue;
}
if (!(it.Pricing!.Default > 0m))
{
pricedByFallback++;
}
priced.Add((it, px));
}
if (pricedByFallback > 0 || droppedNoPrice > 0)
{
_logger.LogWarning(
"cs.money non-positive default price for {Skin}: {Fallback} used priceBeforeDiscount, "
+ "{Dropped} dropped (no usable price).",
skin.Name, pricedByFallback, droppedNoPrice);
}
var skipped = items.Count - priced.Count;
if (priced.Count == 0)
{
// Nothing usable for this skin+wear. If the sweep was complete this is genuine
// (none listed, a name mismatch, or no usable price) — stamp the checkpoint so
// it advances. If it was partial (e.g. challenged before any item), leave it
// un-stamped so the band is retried.
if (complete)
{
await StampCheckpointAsync(conditionId, now, ct);
await _db.SaveChangesAsync(ct);
}
return new CsMoneyIngestResult(0, 0, 0, 0, skipped);
}
var sellOrderIds = priced.Select(p => p.Item.Id).ToList();
var existing = await _db.CsMoneyListings
.Where(l => sellOrderIds.Contains(l.SellOrderId))
.ToDictionaryAsync(l => l.SellOrderId, ct);
var inserted = 0;
var updated = 0;
var touched = new HashSet<long>();
var touchedInstanceIds = new HashSet<int>();
foreach (var (it, price) in priced)
{
touched.Add(it.Id);
var instance = await ResolveInstanceAsync(skinId, conditionId, it, now, ct);
if (instance is not null)
{
touchedInstanceIds.Add(instance.Id);
}
if (existing.TryGetValue(it.Id, out var row))
{
row.Price = price;
row.PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount;
row.ComputedPrice = it.Pricing?.Computed;
row.AssetId = it.Asset?.Id?.ToString();
row.LastSeenAt = now;
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.ConditionId = conditionId;
row.SkinInstance = instance;
updated++;
}
else
{
var entity = Map(it, price, skinId, conditionId, now);
entity.SkinInstance = instance;
_db.CsMoneyListings.Add(entity);
inserted++;
}
}
// Persist inserts/updates before the set-based Removed/dupe queries run.
await _db.SaveChangesAsync(ct);
await FlagDupesAsync(touchedInstanceIds, now, ct);
// The following only hold if we saw the FULL skin+wear set. On a partial sweep,
// listings we didn't fetch are not gone (so don't mark them Removed), the
// cheapest item may be among the unfetched (so don't record a price point), and
// the band isn't fully swept (so don't stamp the checkpoint — let it re-queue).
var removed = 0;
if (complete)
{
removed = await MarkRemovedAsync(skinId, conditionId, touched, now, ct);
// Record a price point (the cheapest live listing) for this skin+wear.
if (conditionId is { } condId)
{
var minPrice = priced.Min(p => p.Price);
await _db.PriceHistories.AddAsync(new PriceHistory
{
SkinId = skinId,
ConditionId = condId,
Price = minPrice,
Currency = "USD",
RecordedAt = now,
Source = Source,
}, ct);
}
await StampCheckpointAsync(conditionId, now, ct);
}
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"cs.money ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, "
+ "{Rem} removed), {Skipped} skipped by filter{Partial}.",
skin.Weapon, skin.Name, conditionName ?? "all", priced.Count, inserted, updated, removed, skipped,
complete ? "" : " [PARTIAL — not pruned/checkpointed]");
return new CsMoneyIngestResult(priced.Count, inserted, updated, removed, skipped);
}
// Find the physical item matching this listing's fingerprint, or create one.
// Shared with CSFloat listings, so a copy seen on both markets is one instance.
// Skipped for non-skin items (no float/pattern) — the fingerprint is meaningless.
private async Task<SkinInstance?> ResolveInstanceAsync(
int skinId, int? conditionId, CsMoneyItem it, DateTimeOffset now, CancellationToken ct)
{
if (it.Asset?.Float is not { } floatValue || it.Asset.Pattern is not { } pattern)
{
return null;
}
var seed = pattern;
var st = it.Asset.IsStatTrak;
var sv = it.Asset.IsSouvenir;
var tracked = _db.ChangeTracker.Entries<SkinInstance>()
.Select(e => e.Entity)
.FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv);
if (tracked is not null)
{
tracked.LastSeenAt = now;
return tracked;
}
var instance = await _db.SkinInstances.FirstOrDefaultAsync(
i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv, ct);
if (instance is not null)
{
instance.LastSeenAt = now;
return instance;
}
instance = new SkinInstance
{
SkinId = skinId,
ConditionId = conditionId,
FloatValue = floatValue,
PaintSeed = seed,
StatTrak = st,
Souvenir = sv,
FirstSeenAt = now,
LastSeenAt = now,
};
_db.SkinInstances.Add(instance);
return instance;
}
// Flag this skin+wear's once-Active listings we didn't see this run as Removed.
private async Task<int> MarkRemovedAsync(
int skinId, int? conditionId, HashSet<long> touched, DateTimeOffset now, CancellationToken ct)
{
return await _db.CsMoneyListings
.Where(l => l.SkinId == skinId
&& l.ConditionId == conditionId
&& l.Status == ListingStatus.Active
&& !touched.Contains(l.SellOrderId))
.ExecuteUpdateAsync(setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now), ct);
}
// Same dupe signal as CSFloat: a fingerprint live under 2+ distinct asset ids at
// once. Considers cs.money listings only (cross-market dupe analysis is later).
private async Task FlagDupesAsync(HashSet<int> instanceIds, DateTimeOffset now, CancellationToken ct)
{
if (instanceIds.Count == 0)
{
return;
}
var dupeInstanceIds = await _db.CsMoneyListings
.Where(l => l.SkinInstanceId != null
&& instanceIds.Contains(l.SkinInstanceId!.Value)
&& l.Status == ListingStatus.Active
&& l.AssetId != null)
.GroupBy(l => l.SkinInstanceId!.Value)
.Where(g => g.Select(l => l.AssetId).Distinct().Count() >= 2)
.Select(g => g.Key)
.ToListAsync(ct);
if (dupeInstanceIds.Count == 0)
{
return;
}
var newlyFlagged = await _db.SkinInstances
.Where(i => dupeInstanceIds.Contains(i.Id) && !i.SuspectedDupe)
.ExecuteUpdateAsync(setters => setters
.SetProperty(i => i.SuspectedDupe, true)
.SetProperty(i => i.DupeFirstSeenAt, now), ct);
if (newlyFlagged > 0)
{
_logger.LogWarning("cs.money dupe detection: {Count} instance(s) newly flagged.", newlyFlagged);
}
}
// Stamp this band's cs.money checkpoint (upsert into skin_condition_sweeps under
// the csmoney source). Caller persists via SaveChangesAsync.
private async Task StampCheckpointAsync(int? conditionId, DateTimeOffset now, CancellationToken ct)
{
if (conditionId is { } cid)
{
await SweepCheckpoints.StampConditionAsync(_db, cid, Source, now, ct);
}
}
// The effective asking price for a listing, or null when none is usable. cs.money's
// pricing.default is the DISCOUNTED price and occasionally comes back <= 0 (discount
// underflow, or a price-less render); fall back to the positive priceBeforeDiscount
// (conservative — never understates cost) and give up only when neither is positive.
private static decimal? ResolvePrice(CsMoneyPricing? pricing)
{
if (pricing is null)
{
return null;
}
if (pricing.Default > 0m)
{
return pricing.Default;
}
if (pricing.PriceBeforeDiscount is { } before && before > 0m)
{
return before;
}
return null;
}
private static CsMoneyListing Map(CsMoneyItem it, decimal price, int skinId, int? conditionId, DateTimeOffset now) => new()
{
SellOrderId = it.Id,
AssetId = it.Asset?.Id?.ToString(),
SkinId = skinId,
ConditionId = conditionId,
MarketHashName = it.Asset?.Names?.Full ?? it.Asset?.Names?.Short ?? "",
Quality = it.Asset?.Quality,
FloatValue = it.Asset?.Float,
PaintSeed = it.Asset?.Pattern,
Phase = it.Asset?.Phase,
IsStatTrak = it.Asset?.IsStatTrak ?? false,
IsSouvenir = it.Asset?.IsSouvenir ?? false,
StickerCount = it.Stickers?.Count(s => s is not null) ?? 0,
Price = price,
PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount,
ComputedPrice = it.Pricing?.Computed,
Currency = "USD",
InspectLink = it.Links?.InspectLink,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
};
// Normalize a market name for matching: drop the StatTrak/Souvenir/★ adornments,
// collapse whitespace, lowercase. So "StatTrak™ M4A4 | Cyber Security" and the
// catalogue's "M4A4 | Cyber Security" compare equal.
private static string Normalize(string name)
{
var s = name
.Replace("★", " ", StringComparison.Ordinal)
.Replace("StatTrak™", " ", StringComparison.OrdinalIgnoreCase)
.Replace("Souvenir", " ", StringComparison.OrdinalIgnoreCase);
return string.Join(' ', s.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.ToLowerInvariant();
}
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json.Serialization;
namespace BlueLaminate.Core.CsMoney;
/// <summary>
/// The subset of a cs.money <c>sell-orders</c> item we persist, parsed from the
/// JSON the Python worker scrapes. Decimals are parsed directly (not via double) so
/// the full-precision float round-trips exactly into <c>numeric(20,18)</c>.
/// </summary>
public sealed class CsMoneyItem
{
[JsonPropertyName("id")] public long Id { get; set; }
[JsonPropertyName("asset")] public CsMoneyAsset? Asset { get; set; }
[JsonPropertyName("pricing")] public CsMoneyPricing? Pricing { get; set; }
[JsonPropertyName("stickers")] public List<CsMoneySticker?>? Stickers { get; set; }
[JsonPropertyName("links")] public CsMoneyLinks? Links { get; set; }
}
public sealed class CsMoneyAsset
{
[JsonPropertyName("id")] public long? Id { get; set; }
[JsonPropertyName("names")] public CsMoneyNames? Names { get; set; }
[JsonPropertyName("isStatTrak")] public bool IsStatTrak { get; set; }
[JsonPropertyName("isSouvenir")] public bool IsSouvenir { get; set; }
[JsonPropertyName("quality")] public string? Quality { get; set; }
[JsonPropertyName("pattern")] public int? Pattern { get; set; }
[JsonPropertyName("phase")] public string? Phase { get; set; }
[JsonPropertyName("float")] public decimal? Float { get; set; }
}
public sealed class CsMoneyNames
{
[JsonPropertyName("short")] public string? Short { get; set; }
[JsonPropertyName("full")] public string? Full { get; set; }
}
public sealed class CsMoneyPricing
{
[JsonPropertyName("default")] public decimal Default { get; set; }
[JsonPropertyName("priceBeforeDiscount")] public decimal? PriceBeforeDiscount { get; set; }
[JsonPropertyName("computed")] public decimal? Computed { get; set; }
}
public sealed class CsMoneyLinks
{
[JsonPropertyName("inspectLink")] public string? InspectLink { get; set; }
}
public sealed class CsMoneySticker
{
[JsonPropertyName("name")] public string? Name { get; set; }
}

View File

@@ -0,0 +1,46 @@
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
namespace BlueLaminate.Core.CsMoney;
/// <summary>One marketplace's current presence for a skin or a physical item.</summary>
/// <param name="Marketplace">"csfloat", "csmoney", …</param>
/// <param name="ActiveCount">Active listings on this market.</param>
/// <param name="MinPrice">Cheapest active listing (the comparable price).</param>
/// <param name="MaxPrice">Dearest active listing.</param>
/// <param name="LastSeenAt">When this market was last observed to have it.</param>
public sealed record MarketPresence(
string Marketplace, int ActiveCount, decimal MinPrice, decimal MaxPrice, DateTimeOffset LastSeenAt);
/// <summary>
/// Answers "where is this listed?" over the cross-market <c>market_listings</c> view.
/// Per physical item (<see cref="ForInstanceAsync"/>) for the exact-copy / arbitrage /
/// dupe view, or per catalogue skin (<see cref="ForSkinAsync"/>) for "which markets
/// carry this skin, and cheapest where".
/// </summary>
public sealed class MarketPresenceService
{
private const string Active = "Active";
private readonly SkinTrackerDbContext _db;
public MarketPresenceService(SkinTrackerDbContext db) => _db = db;
/// <summary>Markets currently listing this exact physical copy.</summary>
public Task<List<MarketPresence>> ForInstanceAsync(int skinInstanceId, CancellationToken ct = default) =>
_db.MarketListings
.Where(m => m.SkinInstanceId == skinInstanceId && m.Status == Active)
.GroupBy(m => m.Marketplace)
.Select(g => new MarketPresence(
g.Key, g.Count(), g.Min(x => x.Price), g.Max(x => x.Price), g.Max(x => x.LastSeenAt)))
.ToListAsync(ct);
/// <summary>Markets currently listing this skin (any wear), cheapest per market.</summary>
public Task<List<MarketPresence>> ForSkinAsync(int skinId, CancellationToken ct = default) =>
_db.MarketListings
.Where(m => m.SkinId == skinId && m.Status == Active)
.GroupBy(m => m.Marketplace)
.Select(g => new MarketPresence(
g.Key, g.Count(), g.Min(x => x.Price), g.Max(x => x.Price), g.Max(x => x.LastSeenAt)))
.ToListAsync(ct);
}

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.Core.CsMoney;
/// <summary>
/// Maps between the catalogue's full wear names (<c>SkinCondition.Condition</c>) and
/// cs.money's short wear codes (the <c>quality</c> field, also used in market search).
/// </summary>
public static class Wear
{
private static readonly Dictionary<string, string> NameToCode = new(StringComparer.OrdinalIgnoreCase)
{
["Factory New"] = "fn",
["Minimal Wear"] = "mw",
["Field-Tested"] = "ft",
["Well-Worn"] = "ww",
["Battle-Scarred"] = "bs",
};
/// <summary>"Field-Tested" → "ft". Null/unknown → null.</summary>
public static string? ToCode(string? conditionName) =>
conditionName is not null && NameToCode.TryGetValue(conditionName, out var code) ? code : null;
}

View File

@@ -0,0 +1,89 @@
using BlueLaminate.Core.Listings;
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Skins;
using BlueLaminate.EFCore.DependencyInjection;
using BlueLaminate.Scraper.CsFloat;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BlueLaminate.Core.DependencyInjection;
/// <summary>
/// The single composition root for BlueLaminate's application logic. Any frontend
/// — the CLI today, a web UI later — wires up the whole stack with one call:
/// <c>services.AddBlueLaminateCore(configuration)</c>. Nothing about the database,
/// the CSFloat client, or the sweep/sync services is duplicated per host.
/// </summary>
public static class ServiceCollectionExtensions
{
private const string CsFloatHttpClient = "csfloat";
private const string CatalogHttpClient = "catalog";
public static IServiceCollection AddBlueLaminateCore(
this IServiceCollection services,
IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("SkinTracker")
?? throw new InvalidOperationException(
"Connection string 'SkinTracker' is not configured. Set it via user secrets (dev) "
+ "or the ConnectionStrings__SkinTracker environment variable (prod).");
// Database (DbContext is registered scoped by the EFCore extension).
services.AddSkinTrackerData(connectionString);
// Options bound from configuration. The CsFloat API key falls back to the
// legacy CSFLOAT_API_KEY environment variable so existing setups keep working.
services.AddOptions<CsFloatOptions>()
.Bind(configuration.GetSection(CsFloatOptions.SectionName))
.Configure(o =>
{
if (string.IsNullOrWhiteSpace(o.ApiKey))
{
o.ApiKey = configuration["CSFLOAT_API_KEY"];
}
})
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<SkinCatalogOptions>()
.Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
services.AddOptions<SweepOptions>()
.Bind(configuration.GetSection(SweepOptions.SectionName));
services.AddOptions<TradeupOptions>()
.Bind(configuration.GetSection(TradeupOptions.SectionName));
// Typed-handler pooling via IHttpClientFactory; clients are scoped so a
// command's handler and the service it drives share one instance (and thus
// the same LastRateLimit) within a single request scope.
services.AddHttpClient(CsFloatHttpClient, ConfigureHttpClient);
services.AddHttpClient(CatalogHttpClient, ConfigureHttpClient);
services.AddScoped(sp => new CsFloatListingsClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(CsFloatHttpClient),
sp.GetRequiredService<IOptions<CsFloatOptions>>().Value,
sp.GetRequiredService<ILogger<CsFloatListingsClient>>()));
services.AddScoped(sp => new SkinCatalogClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(CatalogHttpClient),
sp.GetRequiredService<IOptions<SkinCatalogOptions>>().Value));
// Application services (constructor injection; DbContext keeps them scoped).
services.AddScoped<ListingSweepService>();
services.AddScoped<SkinSyncService>();
services.AddScoped<CsMoney.CsMoneyIngestService>();
services.AddScoped<CsMoney.MarketPresenceService>();
services.AddScoped<SkinLand.SkinLandIngestService>();
services.AddScoped<Tradeups.TradeupGraphBuilder>();
services.AddScoped<Tradeups.TradeupFinder>();
return services;
}
private static void ConfigureHttpClient(HttpClient http)
{
http.Timeout = TimeSpan.FromMinutes(2);
http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate");
}
}

View File

@@ -0,0 +1,14 @@
namespace BlueLaminate.Core.Listings;
/// <param name="SkinsCovered">Wear-band sweeps fully paged this run (a skin contributes
/// one per wear band, or one whole-skin sweep if it has no bands).</param>
/// <param name="SkinsSkipped">Units left untouched (e.g. request budget ran out).</param>
public sealed record CatalogSweepResult(
int SkinsCovered,
int SkinsSkipped,
int Pages,
int Seen,
int Inserted,
int Updated,
int Removed,
string StoppedReason);

View File

@@ -0,0 +1,17 @@
namespace BlueLaminate.Core.Listings;
/// <param name="Pages">How many API pages were fetched.</param>
/// <param name="Seen">Total listings returned across those pages.</param>
/// <param name="Inserted">New listings inserted.</param>
/// <param name="Updated">Existing listings refreshed (price/last-seen/etc.).</param>
/// <param name="Removed">Listings flagged Removed (only on a complete pass).</param>
/// <param name="Linked">Listings resolved to a catalogue skin by def/paint.</param>
/// <param name="StoppedReason">Why the sweep ended.</param>
public sealed record ListingSweepResult(
int Pages,
int Seen,
int Inserted,
int Updated,
int Removed,
int Linked,
string StoppedReason);

View File

@@ -0,0 +1,780 @@
using BlueLaminate.Core.Options;
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BlueLaminate.Core.Listings;
/// <summary>
/// Global incremental sweep of CSFloat active listings into the database. Pages
/// <c>sort_by=most_recent</c> with no item filter, so it captures every listing —
/// including items not in our catalogue. Each listing is upserted by its stable
/// CSFloat id; <see cref="Listing.FirstSeenAt"/>/<see cref="Listing.LastSeenAt"/>
/// bound the observation window.
///
/// Two things keep it safe against the 200-request rate limit and partial runs:
/// <list type="bullet">
/// <item><b>Pacing.</b> After each page it waits a base courtesy delay plus
/// random jitter so requests stay well under the limit and aren't perfectly
/// regular; and it inspects the client's rate-limit headers, sleeping until the
/// reset epoch when remaining is low rather than risking a 429.</item>
/// <item><b>Removed-tracking only on a complete pass.</b> Marking unseen listings
/// as Removed is only valid when the whole market was covered. A capped or
/// incremental run that stops early must not do it, or it would falsely "sell"
/// everything it didn't reach.</item>
/// </list>
/// </summary>
public sealed class ListingSweepService
{
public const string Source = "listings";
public const string CatalogSource = SweepSource.CsFloatCatalog;
private readonly SkinTrackerDbContext _db;
private readonly CsFloatListingsClient _client;
private readonly ILogger<ListingSweepService> _logger;
private readonly SweepOptions _options;
public ListingSweepService(
SkinTrackerDbContext db,
CsFloatListingsClient client,
ILogger<ListingSweepService> logger,
IOptions<SweepOptions> options)
{
_db = db;
_client = client;
_logger = logger;
_options = options.Value;
}
/// <param name="maxRequests">Hard cap on API pages this run (rate-limit budget).</param>
/// <param name="maxListings">Hard cap on listings ingested this run.</param>
/// <param name="incremental">
/// Stop once a whole page is already-known listings (cheap daily delta). When
/// false, keep paging until the cursor or a cap is exhausted (cold pass).
/// </param>
/// <param name="delayBetweenPages">Optional courtesy delay between pages.</param>
public async Task<ListingSweepResult> SweepAsync(
int maxRequests = 4,
int maxListings = 200,
bool incremental = true,
TimeSpan? delayBetweenPages = null,
CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var pages = 0;
var seen = 0;
var inserted = 0;
var updated = 0;
var linked = 0;
string? cursor = null;
string stoppedReason = "cursor exhausted";
var completePass = true;
// Catalogue lookup for best-effort skin linking, built once per run.
var skinByIndex = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new { s.Id, s.DefIndex, s.PaintIndex })
.ToDictionaryAsync(s => (s.DefIndex!.Value, s.PaintIndex!.Value), s => s.Id, ct);
// (skin, wear) -> condition id, so each listing's wear band is set directly.
var conditionLookup = await BuildConditionLookupAsync(ct);
// Track which listing ids we touched this run, so a complete pass can flag
// the rest as Removed.
var touchedIds = new HashSet<string>();
var touchedInstanceIds = new HashSet<int>();
while (true)
{
if (pages >= maxRequests)
{
stoppedReason = $"hit max-requests cap ({maxRequests})";
completePass = false;
break;
}
if (seen >= maxListings)
{
stoppedReason = $"hit max-listings cap ({maxListings})";
completePass = false;
break;
}
ListingsPageResult page;
try
{
page = await _client.FetchPageAsync(
defIndex: null, paintIndex: null, sortBy: "most_recent",
limit: _client.MaxLimit, cursor: cursor, ct: ct);
}
catch (CsFloatApiException ex)
{
_logger.LogError("Sweep aborted: {Message}", ex.Message);
stoppedReason = $"API error: {ex.Status}";
completePass = false;
break;
}
pages++;
seen += page.Listings.Count;
var (ins, upd, link, allKnown) = await IngestPageAsync(
page.Listings, skinByIndex, conditionLookup, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
linked += link;
_logger.LogInformation(
"Page {Page}: {Count} listings ({Ins} new, {Upd} updated); {Rate}",
pages, page.Listings.Count, ins, upd, _client.LastRateLimit);
cursor = page.Cursor;
// End of the market. A short page (fewer than a full page) is the last
// one — the cursor points past the end, so fetching again would only burn
// a request on an empty response.
if (string.IsNullOrEmpty(cursor) || page.Listings.Count < _client.MaxLimit)
{
stoppedReason = "cursor exhausted";
break;
}
// Incremental short-circuit: a full page we already knew means we've
// caught up to the previous sweep. This is a partial pass by design.
if (incremental && allKnown)
{
stoppedReason = "reached already-seen listings (incremental)";
completePass = false;
break;
}
await PaceAsync(delayBetweenPages, ct);
}
// Persist inserts/updates before computing Removed so the touched set is durable.
await _db.SaveChangesAsync(ct);
var removed = 0;
if (completePass)
{
removed = await MarkRemovedAsync(touchedIds, now, ct);
}
else
{
_logger.LogInformation("Partial pass — skipping Removed-tracking to avoid false sales.");
}
await FlagDupesAsync(touchedInstanceIds, now, ct);
await _db.ScrapeRuns.AddAsync(
new ScrapeRun { Source = Source, RanAt = now, ItemCount = seen }, ct);
await _db.SaveChangesAsync(ct);
return new ListingSweepResult(pages, seen, inserted, updated, removed, linked, stoppedReason);
}
/// <summary>
/// Catalogue-driven sweep: walk skins that have def/paint indexes and query
/// their listings with a server-side def_index+paint_index filter, <b>split by
/// wear band</b>. Each <c>skin_conditions</c> row (one per overlapping wear tier,
/// with clamped float bounds) becomes its own unit, queried with the API's
/// min_float/max_float filter; skins with no wear bands (e.g. vanilla knives) are
/// swept whole. Splitting keeps even high-volume Covert skins to small,
/// independently-checkpointable units — an interrupted run resumes at wear-band
/// granularity rather than redoing a whole skin. Because each band is paged to
/// completion, Removed-tracking is accurate per band (scoped by wear name).
///
/// Runs <b>continuously</b> until <paramref name="ct"/> is cancelled (Ctrl+C):
/// it sweeps the whole catalogue, then loops and starts over. The unit list is
/// re-queried each pass, so newly-synced skins/bands are picked up and the
/// ordering (never-swept first, rarest first, then least-recently-swept) keeps
/// refreshing the stalest data. There is no request cap — request rate is bounded
/// only by <see cref="PaceAsync"/>, which sleeps when the rate-limit bucket runs
/// low so we never fire a request at zero remaining.
/// </summary>
/// <param name="delayBetweenPages">Optional courtesy delay between pages.</param>
public async Task<CatalogSweepResult> SweepCatalogAsync(
TimeSpan? delayBetweenPages = null,
CancellationToken ct = default)
{
var pages = 0;
var seen = 0;
var inserted = 0;
var updated = 0;
var removed = 0;
var covered = 0;
var stoppedReason = "stopped";
try
{
// Repeat the whole catalogue until cancelled. Re-querying each pass picks
// up newly-synced skins and re-orders by this site's latest checkpoint.
while (!ct.IsCancellationRequested)
{
var now = DateTimeOffset.UtcNow;
var units = await BuildSweepUnitsAsync(ct);
if (units.Count == 0)
{
stoppedReason = "no catalogue skins to sweep";
break;
}
// (skin, wear) -> condition id, refreshed each pass alongside the units.
var conditionLookup = await BuildConditionLookupAsync(ct);
var index = 0;
foreach (var unit in units)
{
ct.ThrowIfCancellationRequested();
index++;
var wear = unit.Condition ?? "all wears";
// One-entry lookup so IngestPageAsync resolves SkinId to this skin.
var lookup = new Dictionary<(int, int), int> { [(unit.Def, unit.Paint)] = unit.SkinId };
var touchedIds = new HashSet<string>();
var touchedInstanceIds = new HashSet<int>();
string? cursor = null;
while (true)
{
ListingsPageResult page;
try
{
// min_float/max_float are null for whole-skin units (no wear
// bands); set, they restrict the page to this wear band.
page = await _client.FetchPageAsync(
defIndex: unit.Def, paintIndex: unit.Paint, sortBy: "lowest_price",
limit: _client.MaxLimit, cursor: cursor,
minFloat: unit.MinFloat, maxFloat: unit.MaxFloat, ct: ct);
}
catch (CsFloatApiException ex)
{
_logger.LogError(
"Catalogue sweep aborted on {Weapon} | {Skin} ({Wear}): {Message}",
unit.Weapon, unit.SkinName, wear, ex.Message);
await _db.SaveChangesAsync(CancellationToken.None);
return Finish($"API error: {ex.Status}");
}
pages++;
seen += page.Listings.Count;
var (ins, upd, _, _) = await IngestPageAsync(
page.Listings, lookup, conditionLookup, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
_logger.LogInformation(
"[{Index}/{Total}] {Weapon} | {Skin} ({Wear}): {Count} listings; {Remaining} requests remaining",
index, units.Count, unit.Weapon, unit.SkinName, wear, page.Listings.Count,
_client.LastRateLimit.Remaining);
cursor = page.Cursor;
// A short page (fewer than a full page of listings) is the last
// page: CSFloat still returns a cursor pointing past the end, so
// fetching again would only burn a request on an empty response.
if (string.IsNullOrEmpty(cursor) || page.Listings.Count < _client.MaxLimit)
{
break;
}
await PaceAsync(delayBetweenPages, ct);
}
// Persist this band's listings/instances before dupe analysis so the
// asset-id grouping query sees them.
await _db.SaveChangesAsync(ct);
await FlagDupesAsync(touchedInstanceIds, now, ct);
await _db.SaveChangesAsync(ct);
// Each unit is paged to completion, so Removed-tracking is accurate.
// Scope it to the wear band (by wear name) so sweeping one band never
// false-removes another band's listings of the same skin. Then stamp
// the band's checkpoint so it leaves the never-swept queue.
if (unit.ConditionId is { } conditionId)
{
removed += await MarkRemovedForSkinConditionAsync(
unit.SkinId, unit.Condition!, touchedIds, now, ct);
await SweepCheckpoints.StampConditionAsync(_db, conditionId, CatalogSource, now, ct);
}
else
{
removed += await MarkRemovedForSkinAsync(unit.SkinId, touchedIds, now, ct);
await SweepCheckpoints.StampSkinAsync(_db, unit.SkinId, CatalogSource, now, ct);
}
// Persist the checkpoint upsert now so a cancellation between bands
// doesn't lose it (the stamp goes through the change tracker, not a
// set-based update).
await _db.SaveChangesAsync(ct);
covered++;
await PaceAsync(delayBetweenPages, ct);
}
_logger.LogInformation(
"Completed a full catalogue pass ({Covered} wear-band sweeps so far); restarting from the stalest.",
covered);
}
}
catch (OperationCanceledException)
{
stoppedReason = "stopped (cancellation requested)";
}
// Final bookkeeping with a non-cancellable token so the run is always recorded.
await _db.ScrapeRuns.AddAsync(
new ScrapeRun { Source = CatalogSource, RanAt = DateTimeOffset.UtcNow, ItemCount = seen },
CancellationToken.None);
await _db.SaveChangesAsync(CancellationToken.None);
return Finish(stoppedReason);
CatalogSweepResult Finish(string reason) =>
new(covered, 0, pages, seen, inserted, updated, removed, reason);
}
// Rank a skin's rarity tier high→low so sweeps process the rarest (and least
// abundant) skins first. Names come from the CSGO-API catalogue; an unknown
// value ranks lowest so it's swept last rather than jumping the queue.
private static int RarityRank(string rarity) => rarity switch
{
"Extraordinary" => 8, // knives & gloves
"Contraband" => 7, // e.g. M4A4 | Howl
"Covert" => 6,
"Classified" => 5,
"Restricted" => 4,
"Mil-Spec Grade" => 3,
"Industrial Grade" => 2,
"Consumer Grade" => 1,
_ => 0,
};
// One unit of catalogue-sweep work: a skin filtered to a single wear band, or a
// whole skin when it has no bands. Float bounds + ConditionId are null for the
// whole-skin case (checkpointed in skin_sweeps rather than skin_condition_sweeps).
// SweptAt is this site's checkpoint for the unit and drives the never-swept-first /
// stalest-first ordering.
private sealed record SweepUnit(
int SkinId,
int Def,
int Paint,
string SkinName,
string Weapon,
string Rarity,
int? ConditionId,
string? Condition,
decimal? MinFloat,
decimal? MaxFloat,
DateTimeOffset? SweptAt);
// Build and order this pass's sweep units. Each skin with def/paint indexes
// contributes one unit per wear band (skin_conditions row), or a single
// whole-skin unit if it has no bands (e.g. vanilla knives with no float range) —
// so those skins keep being swept rather than silently dropping out.
//
// Ordering, in priority:
// 1. never-swept first — so a restart resumes rather than redoing swept bands;
// 2. highest rarity first — rare skins (Covert/knives/gloves) have few listings,
// so capture them before the mass-quantity low grades;
// 3. least-recently-swept — refresh the stalest data first;
// 4. then by skin and ascending float — keeps a skin's bands contiguous and in
// FN→BS order ("wear within skin").
// Sorted in memory because rarity rank isn't a database column; the catalogue is
// small (~2k skins) so this is negligible.
private async Task<List<SweepUnit>> BuildSweepUnitsAsync(CancellationToken ct)
{
// Read each unit's checkpoint for THIS site only (a correlated subquery over the
// per-source sweep rows), so a band swept on another site still sorts as
// never-swept here. No row for this source => null => front of the queue.
var skins = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new
{
s.Id,
Def = s.DefIndex!.Value,
Paint = s.PaintIndex!.Value,
s.Name,
Weapon = s.Weapon.Name,
s.Rarity,
SweptAt = s.Sweeps
.Where(x => x.Source == CatalogSource)
.Select(x => (DateTimeOffset?)x.SweptAt)
.FirstOrDefault(),
Conditions = s.Conditions
.Select(c => new
{
c.Id,
c.Condition,
c.FloatMin,
c.FloatMax,
SweptAt = c.Sweeps
.Where(x => x.Source == CatalogSource)
.Select(x => (DateTimeOffset?)x.SweptAt)
.FirstOrDefault(),
})
.ToList(),
})
.ToListAsync(ct);
var units = new List<SweepUnit>();
foreach (var s in skins)
{
if (s.Conditions.Count == 0)
{
units.Add(new SweepUnit(
s.Id, s.Def, s.Paint, s.Name, s.Weapon, s.Rarity,
ConditionId: null, Condition: null, MinFloat: null, MaxFloat: null,
SweptAt: s.SweptAt));
continue;
}
foreach (var c in s.Conditions)
{
units.Add(new SweepUnit(
s.Id, s.Def, s.Paint, s.Name, s.Weapon, s.Rarity,
ConditionId: c.Id, Condition: c.Condition,
MinFloat: c.FloatMin, MaxFloat: c.FloatMax,
SweptAt: c.SweptAt));
}
}
return units
.OrderBy(u => u.SweptAt != null)
.ThenByDescending(u => RarityRank(u.Rarity))
.ThenBy(u => u.SweptAt)
.ThenBy(u => u.SkinId)
.ThenBy(u => u.MinFloat)
.ToList();
}
// (skinId, wear name) -> skin_conditions.id, built once per run so each listing's
// wear band resolves without a per-row query. The wear name equals
// skin_conditions.condition (CSFloat's authoritative tier name, e.g. "Factory New").
private async Task<Dictionary<(int SkinId, string Condition), int>> BuildConditionLookupAsync(
CancellationToken ct) =>
await _db.SkinConditions
.Select(c => new { c.SkinId, c.Condition, c.Id })
.ToDictionaryAsync(c => (c.SkinId, c.Condition), c => c.Id, ct);
// Flag this skin's once-Active listings that we didn't see this run as Removed.
private async Task<int> MarkRemovedForSkinAsync(
int skinId, HashSet<string> touchedIds, DateTimeOffset now, CancellationToken ct)
{
return await _db.Listings
.Where(l => l.SkinId == skinId
&& l.Status == ListingStatus.Active
&& !touchedIds.Contains(l.CsFloatListingId))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now),
ct);
}
// Wear-band-scoped Removed-tracking: flag only this skin's once-Active listings in
// the given wear band that we didn't see this run. Scoping by wear name (CSFloat's
// authoritative tier, identical to skin_conditions.condition) means sweeping one
// band can't false-remove listings from the skin's other bands.
private async Task<int> MarkRemovedForSkinConditionAsync(
int skinId, string wearName, HashSet<string> touchedIds, DateTimeOffset now, CancellationToken ct)
{
return await _db.Listings
.Where(l => l.SkinId == skinId
&& l.WearName == wearName
&& l.Status == ListingStatus.Active
&& !touchedIds.Contains(l.CsFloatListingId))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now),
ct);
}
// Upsert a page of listings. Returns counts plus whether every listing on the
// page already existed (the incremental stop signal). Also resolves each
// listing to a SkinInstance (the physical item, by fingerprint) and records
// the touched instance ids so the caller can run dupe detection over them.
private async Task<(int Inserted, int Updated, int Linked, bool AllKnown)> IngestPageAsync(
IReadOnlyList<CsFloatListing> listings,
IReadOnlyDictionary<(int, int), int> skinByIndex,
IReadOnlyDictionary<(int, string), int> conditionBySkinAndWear,
HashSet<string> touchedIds,
HashSet<int> touchedInstanceIds,
DateTimeOffset now,
CancellationToken ct)
{
if (listings.Count == 0)
{
return (0, 0, 0, true);
}
var ids = listings.Select(l => l.ListingId).ToList();
var existing = await _db.Listings
.Where(l => ids.Contains(l.CsFloatListingId))
.ToDictionaryAsync(l => l.CsFloatListingId, ct);
var inserted = 0;
var updated = 0;
var linked = 0;
var allKnown = true;
foreach (var l in listings)
{
touchedIds.Add(l.ListingId);
int? skinId = skinByIndex.TryGetValue((l.DefIndex, l.PaintIndex), out var id) ? id : null;
if (skinId is not null)
{
linked++;
}
// Wear band: resolve from (skin, wear name) so both the catalogue and the
// incremental sweep set the same condition_id. Null when the skin is
// unknown or the item has no wear (e.g. vanilla knives).
int? conditionId = skinId is { } skinForCond && l.WearName is { } wearForCond
&& conditionBySkinAndWear.TryGetValue((skinForCond, wearForCond), out var resolvedCond)
? resolvedCond
: null;
// Resolve the physical item only when we know the skin — the
// fingerprint is meaningless without it.
var instance = skinId is { } sid
? await ResolveInstanceAsync(sid, l, now, ct)
: null;
if (instance is not null)
{
touchedInstanceIds.Add(instance.Id);
}
if (existing.TryGetValue(l.ListingId, out var row))
{
// Refresh mutable fields. Price can change; a re-appeared listing
// returns to Active.
row.Price = l.Price;
row.LastSeenAt = now;
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.SkinId = skinId;
row.ConditionId = conditionId;
row.AssetId = l.AssetId;
row.SkinInstance = instance;
updated++;
}
else
{
allKnown = false;
var entity = MapToEntity(l, skinId, conditionId, now);
entity.SkinInstance = instance;
_db.Listings.Add(entity);
inserted++;
}
}
return (inserted, updated, linked, allKnown);
}
// Find the SkinInstance matching this listing's fingerprint, or create one.
// The fingerprint is (skin, full-precision float, seed, stattrak, souvenir).
// It is deliberately NOT unique — duped copies share it — so a match may
// already represent more than one physical item; dupe detection runs later.
private async Task<SkinInstance?> ResolveInstanceAsync(
int skinId, CsFloatListing l, DateTimeOffset now, CancellationToken ct)
{
// Floatless items (e.g. Vanilla knives) can't be fingerprinted; skip the
// instance and leave the listing's SkinInstanceId null, like the cs.money path.
if (l.FloatValue is not { } floatValue)
{
return null;
}
var seed = l.PaintSeed;
// Check the change-tracker first (an instance just added earlier this page
// isn't queryable yet), then the database.
var tracked = _db.ChangeTracker.Entries<SkinInstance>()
.Select(e => e.Entity)
.FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir);
if (tracked is not null)
{
tracked.LastSeenAt = now;
return tracked;
}
var instance = await _db.SkinInstances.FirstOrDefaultAsync(
i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir,
ct);
if (instance is not null)
{
instance.LastSeenAt = now;
return instance;
}
instance = new SkinInstance
{
SkinId = skinId,
FloatValue = floatValue,
PaintSeed = seed,
StatTrak = l.IsStatTrak,
Souvenir = l.IsSouvenir,
FirstSeenAt = now,
LastSeenAt = now,
};
_db.SkinInstances.Add(instance);
return instance;
}
private static Listing MapToEntity(CsFloatListing l, int? skinId, int? conditionId, DateTimeOffset now) => new()
{
CsFloatListingId = l.ListingId,
Type = l.Type,
Price = l.Price,
ListedAt = l.CreatedAt,
AssetId = l.AssetId,
DefIndex = l.DefIndex,
PaintIndex = l.PaintIndex,
MarketHashName = l.MarketHashName,
WearName = l.WearName,
FloatValue = l.FloatValue,
PaintSeed = l.PaintSeed,
IsStatTrak = l.IsStatTrak,
IsSouvenir = l.IsSouvenir,
StickerCount = l.StickerCount,
SellerSteamId = l.SellerSteamId,
InspectLink = l.InspectLink,
SkinId = skinId,
ConditionId = conditionId,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
};
// Flag every currently-Active listing we did NOT see this run as Removed.
// Only called after a complete pass. Done in a single set-based update to
// avoid loading the whole table.
private async Task<int> MarkRemovedAsync(
HashSet<string> touchedIds, DateTimeOffset now, CancellationToken ct)
{
return await _db.Listings
.Where(l => l.Status == ListingStatus.Active && !touchedIds.Contains(l.CsFloatListingId))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now),
ct);
}
// Dupe detection. For each instance touched this run, count the DISTINCT
// asset ids among its currently-Active listings. Two or more means the same
// fingerprint (skin+float+seed+ST+souvenir) is live under multiple Steam
// assets at once — the signature of a duplicated item, as opposed to an
// ordinary trade (which retires the old listing before the new one appears,
// leaving a single active asset). Flags freshly-detected dupes and stamps
// when first seen, enabling "alert on fresh duping" downstream.
private async Task FlagDupesAsync(
HashSet<int> instanceIds, DateTimeOffset now, CancellationToken ct)
{
if (instanceIds.Count == 0)
{
return;
}
// Instances (among those touched) with 2+ distinct active asset ids.
var dupeInstanceIds = await _db.Listings
.Where(l => l.SkinInstanceId != null
&& instanceIds.Contains(l.SkinInstanceId!.Value)
&& l.Status == ListingStatus.Active
&& l.AssetId != null)
.GroupBy(l => l.SkinInstanceId!.Value)
.Where(g => g.Select(l => l.AssetId).Distinct().Count() >= 2)
.Select(g => g.Key)
.ToListAsync(ct);
if (dupeInstanceIds.Count == 0)
{
return;
}
// Flag only those not already flagged, stamping first-seen once. Instances
// already marked stay marked (they're excluded by the !SuspectedDupe filter).
var newlyFlagged = await _db.SkinInstances
.Where(i => dupeInstanceIds.Contains(i.Id) && !i.SuspectedDupe)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(i => i.SuspectedDupe, true)
.SetProperty(i => i.DupeFirstSeenAt, now),
ct);
if (newlyFlagged > 0)
{
_logger.LogWarning(
"Dupe detection: {Count} instance(s) newly flagged as suspected dupes.", newlyFlagged);
}
}
// Pace requests against the rate limit: if the bucket is nearly empty, sleep
// until the window resets (or a fallback cooldown) so we never fire a request
// at zero remaining. Otherwise apply a base courtesy delay plus random jitter so
// we stay well under the limit and never poll at a fixed cadence.
private async Task PaceAsync(TimeSpan? delay, CancellationToken ct)
{
var rate = _client.LastRateLimit;
if (rate.Remaining is { } remaining && remaining <= _options.RateLimitSafetyMargin)
{
var wait = ResetWait(rate) ?? _options.RateLimitCooldown;
_logger.LogWarning(
"Rate limit nearly exhausted ({Remaining} left); sleeping {Seconds:0}s before next request.",
remaining, wait.TotalSeconds);
await Task.Delay(wait, ct);
return;
}
var courtesy = (delay ?? _options.PageDelay) + RandomJitter();
if (courtesy > TimeSpan.Zero)
{
_logger.LogDebug("Pacing {Seconds:0.0}s before next page.", courtesy.TotalSeconds);
await Task.Delay(courtesy, ct);
}
}
// Time until the rate-limit window resets, if the API reported a usable value.
// Reset is documented as unverified (epoch seconds vs seconds-until), so try the
// epoch interpretation first, then seconds-until, then Retry-After. Returns null
// when nothing usable was reported, so the caller applies a fallback cooldown.
private static TimeSpan? ResetWait(CsFloatRateLimit rate)
{
if (long.TryParse(rate.Reset, out var reset) && reset > 0)
{
var asEpoch = DateTimeOffset.FromUnixTimeSeconds(reset) - DateTimeOffset.UtcNow;
if (asEpoch > TimeSpan.Zero && asEpoch < TimeSpan.FromHours(1))
{
return asEpoch;
}
var asDelta = TimeSpan.FromSeconds(reset);
if (asDelta > TimeSpan.Zero && asDelta < TimeSpan.FromHours(1))
{
return asDelta;
}
}
if (rate.RetryAfter is { } retry && retry > 0)
{
return TimeSpan.FromSeconds(retry);
}
return null;
}
// A random delay in [0, MaxJitter] added to the base courtesy delay. Random.Shared
// is thread-safe; the spread keeps our request timing from being perfectly regular.
private TimeSpan RandomJitter() =>
_options.MaxJitter * Random.Shared.NextDouble();
}

View File

@@ -0,0 +1,36 @@
namespace BlueLaminate.Core.Options;
/// <summary>
/// Pacing configuration for the listing sweeps, bound from the <c>Sweep</c>
/// configuration section. Controls how the sweep throttles itself between API
/// pages so it stays under CSFloat's rate limit. Defaults preserve the original
/// hard-coded behaviour.
/// </summary>
public sealed class SweepOptions
{
public const string SectionName = "Sweep";
/// <summary>
/// Base courtesy delay between pages, applied even when the rate-limit bucket
/// looks healthy so we never hammer the API at a fixed cadence.
/// </summary>
public TimeSpan PageDelay { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>
/// Upper bound on the random jitter added to <see cref="PageDelay"/>; the
/// spread keeps request timing from being perfectly regular.
/// </summary>
public TimeSpan MaxJitter { get; set; } = TimeSpan.FromSeconds(3);
/// <summary>
/// Pace before the rate-limit bucket is fully empty by this many requests, so
/// a slightly-stale counter can't tip us into a 429.
/// </summary>
public int RateLimitSafetyMargin { get; set; } = 2;
/// <summary>
/// Fallback wait when the bucket is exhausted but the API didn't report a usable
/// reset time. Guarantees we never fire a request at zero remaining.
/// </summary>
public TimeSpan RateLimitCooldown { get; set; } = TimeSpan.FromSeconds(60);
}

View File

@@ -0,0 +1,122 @@
namespace BlueLaminate.Core.Options;
/// <summary>
/// Which StatTrak universes the finder searches. The two input pools are disjoint and
/// never mix in a contract: non-ST inputs (normal souvenir) produce a normal output,
/// ST inputs produce an ST output.
/// </summary>
public enum StatTrakMode
{
/// <summary>Search both the non-ST and ST universes (default).</summary>
Both,
/// <summary>Only the non-ST universe (normal + souvenir inputs → normal output).</summary>
NonStatTrakOnly,
/// <summary>Only the StatTrak universe (ST inputs → ST output).</summary>
StatTrakOnly,
}
/// <summary>
/// How to rank surviving candidates.
/// </summary>
public enum TradeupRanking
{
/// <summary>By worst-case (minimum across outputs) net profit — low variance.</summary>
WorstCaseProfit,
/// <summary>By expected net profit across the output distribution.</summary>
ExpectedProfit,
}
/// <summary>
/// Tuning for the tradeup finder, bound from the <c>Tradeups</c> configuration section.
/// Defaults are sensible for CS2 marketplaces (15% sell fee) and a conservative v1
/// (guaranteed-profit only). Everything here is economics/policy — none of it lives in
/// the CLI.
/// </summary>
public sealed class TradeupOptions
{
public const string SectionName = "Tradeups";
/// <summary>Number of inputs per contract. v1 supports 10-input weapon tradeups only.</summary>
public int ContractSize { get; set; } = 10;
/// <summary>
/// Fraction of the sale price taken as marketplace commission when selling an output
/// (0.15 = 15%). Applied to the realised sell price.
/// </summary>
public decimal SellFeeRate { get; set; } = 0.15m;
/// <summary>
/// Fraction shaved off the lowest active ask to model undercutting it for a quick
/// sale (0.01 = list 1% under the cheapest competitor). Applied before the fee.
/// </summary>
public decimal UndercutRate { get; set; } = 0.01m;
/// <summary>
/// Bucket width used to discretise normalised input fractions for the
/// cardinality-constrained selection DP. Smaller = finer output-float resolution at
/// higher cost. 0.005 resolves the wear boundaries to within 0.005 of output float.
/// </summary>
public decimal FractionBucket { get; set; } = 0.005m;
/// <summary>
/// When true (v1 default) only contracts whose worst-case output still clears input
/// cost survive — a guaranteed profit. When false, any positive-EV contract survives.
/// </summary>
public bool GuaranteedOnly { get; set; } = true;
/// <summary>Minimum net profit (in the listing currency) for a candidate to be reported.</summary>
public decimal MinProfit { get; set; } = 0m;
/// <summary>How surviving candidates are ordered.</summary>
public TradeupRanking Ranking { get; set; } = TradeupRanking.WorstCaseProfit;
/// <summary>Which StatTrak universes to search.</summary>
public StatTrakMode StatTrak { get; set; } = StatTrakMode.Both;
/// <summary>
/// Currency listings must be in to be comparable. The finder ignores listings in
/// other currencies rather than converting (v1 keeps a single money space).
/// </summary>
public string Currency { get; set; } = "USD";
/// <summary>
/// When a proposed output has fewer than this many active listings in our data, its
/// stored lowest-ask is fragile, so the finder re-prices it from the live CSFloat API.
/// </summary>
public int CsFloatThinOutputThreshold { get; set; } = 10;
/// <summary>
/// Enables the live CSFloat re-pricing of thin outputs. Silently inert when no CSFloat
/// API key is configured.
/// </summary>
public bool UseCsFloatForThinOutputs { get; set; } = true;
/// <summary>
/// Hard cap on live CSFloat lookups per search, so the re-pricing pass can't blow the
/// API rate-limit budget. Distinct (skin, ST, wear band) lookups are cached within a run.
/// </summary>
public int CsFloatMaxLookups { get; set; } = 120;
/// <summary>
/// Enables the multi-collection search: alongside the single-collection pass, it mixes
/// inputs from any collections at a rarity tier to maximise expected profit. Off keeps the
/// finder single-collection only.
/// </summary>
public bool MultiCollection { get; set; } = true;
/// <summary>
/// Step of the output-float target grid the multi-collection search sweeps. Each grid
/// point is an independent, parallelised chunk (one knapsack over the tier's pool), so a
/// finer grid is more thorough but does more work. 0.02 ≈ 50 chunks per tier × ST.
/// </summary>
public decimal MultiCollectionFloatGrid { get; set; } = 0.02m;
/// <summary>
/// How many distinct multi-collection contracts to keep per (rarity tier, StatTrak), best
/// expected-profit first, after de-duplicating by collection mix. Caps result volume.
/// </summary>
public int MultiCollectionPerTier { get; set; } = 8;
}

View File

@@ -0,0 +1,205 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.SkinLand;
/// <summary>Outcome of ingesting one skin+wear scrape job's results.</summary>
public sealed record SkinLandIngestResult(
int Matched, int Inserted, int Updated, int Removed, int Skipped);
/// <summary>
/// Persists the offers the worker scraped for one targeted skin+wear job into the
/// <c>skin_land_listings</c> table. Mirrors <see cref="CsMoney.CsMoneyIngestService"/>'s
/// upsert-by-natural-key + soft-track-Removed + complete-vs-partial flow, but is thinner:
/// skin.land exposes no paint seed, so there's no <c>SkinInstance</c> resolution and no
/// dupe detection. The scraped page is already one exact skin+wear (the worker fetches it
/// by slug), so instead of cs.money's fuzzy name filter we only validate defensively that
/// each offer's slug matches the targeted band, skipping any that don't.
/// </summary>
public sealed class SkinLandIngestService
{
public const string Source = SweepSource.SkinLand;
private readonly SkinTrackerDbContext _db;
private readonly ILogger<SkinLandIngestService> _logger;
public SkinLandIngestService(SkinTrackerDbContext db, ILogger<SkinLandIngestService> logger)
{
_db = db;
_logger = logger;
}
/// <param name="complete">
/// True only when the worker walked every page of the skin+wear (stoppedReason
/// "completed"). On a partial sweep we upsert what we saw but skip Removed-marking,
/// the price point, and the swept-checkpoint — unseen offers may just be unfetched, so
/// the band stays un-stamped and gets re-queued rather than being wrongly pruned.
/// </param>
public async Task<SkinLandIngestResult> IngestAsync(
int skinId, int? conditionId, IReadOnlyList<SkinLandOffer> offers, bool complete, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var skin = await _db.Skins
.Where(s => s.Id == skinId)
.Select(s => new { s.Id, s.Name, Weapon = s.Weapon.Name })
.FirstOrDefaultAsync(ct);
if (skin is null)
{
_logger.LogWarning("Ingest skipped: skin {SkinId} not found.", skinId);
return new SkinLandIngestResult(0, 0, 0, 0, offers.Count);
}
string? conditionName = null;
if (conditionId is { } cid)
{
conditionName = await _db.SkinConditions
.Where(c => c.Id == cid).Select(c => c.Condition).FirstOrDefaultAsync(ct);
}
// Each offer carries its skin's slug; the targeted band has a known slug. When we
// can build the expected slug, keep only offers whose slug matches (a cheap guard
// against a wrong/redirected page); otherwise accept all (the worker targeted it).
var expectedSlug = conditionName is null
? null
: SkinLandSlug.Slugify($"{skin.Weapon} {skin.Name} {conditionName}");
var matched = offers.Where(o =>
expectedSlug is null
|| string.Equals(o.Skin?.Url, expectedSlug, StringComparison.OrdinalIgnoreCase)).ToList();
var skipped = offers.Count - matched.Count;
if (matched.Count == 0)
{
// Nothing for this skin+wear. If the sweep was complete this is genuine (none
// listed, or a slug mismatch) — stamp the checkpoint so it advances. If partial
// (e.g. challenged before any page), leave it un-stamped so the band is retried.
if (complete)
{
await StampCheckpointAsync(conditionId, now, ct);
await _db.SaveChangesAsync(ct);
}
return new SkinLandIngestResult(0, 0, 0, 0, skipped);
}
var listingIds = matched.Select(o => o.Id).ToList();
var existing = await _db.SkinLandListings
.Where(l => listingIds.Contains(l.ListingId))
.ToDictionaryAsync(l => l.ListingId, ct);
var inserted = 0;
var updated = 0;
var touched = new HashSet<long>();
foreach (var o in matched)
{
touched.Add(o.Id);
if (existing.TryGetValue(o.Id, out var row))
{
row.Price = o.FinalWithdrawalPrice ?? row.Price;
row.FloatValue = o.ItemFloat;
row.NameTag = o.NameTag;
row.InspectLink = o.ItemLink;
row.StickerCount = o.Stickers?.Count(s => s is not null) ?? 0;
row.LastSeenAt = now;
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.ConditionId = conditionId;
updated++;
}
else
{
_db.SkinLandListings.Add(Map(o, skinId, conditionId, now));
inserted++;
}
}
// Persist inserts/updates before the set-based Removed query runs.
await _db.SaveChangesAsync(ct);
// The following only hold if we saw the FULL skin+wear set. On a partial sweep,
// offers we didn't fetch are not gone (so don't mark them Removed), the cheapest
// offer may be among the unfetched (so don't record a price point), and the band
// isn't fully swept (so don't stamp the checkpoint — let it re-queue).
var removed = 0;
if (complete)
{
removed = await MarkRemovedAsync(skinId, conditionId, touched, now, ct);
if (conditionId is { } condId)
{
var priced = matched.Where(m => m.FinalWithdrawalPrice is not null)
.Select(m => m.FinalWithdrawalPrice!.Value).ToList();
if (priced.Count > 0)
{
await _db.PriceHistories.AddAsync(new PriceHistory
{
SkinId = skinId,
ConditionId = condId,
Price = priced.Min(),
Currency = "USD",
RecordedAt = now,
Source = Source,
}, ct);
}
}
await StampCheckpointAsync(conditionId, now, ct);
}
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"skin.land ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, "
+ "{Rem} removed), {Skipped} skipped by filter{Partial}.",
skin.Weapon, skin.Name, conditionName ?? "all", matched.Count, inserted, updated, removed, skipped,
complete ? "" : " [PARTIAL — not pruned/checkpointed]");
return new SkinLandIngestResult(matched.Count, inserted, updated, removed, skipped);
}
// Flag this skin+wear's once-Active offers we didn't see this run as Removed.
private async Task<int> MarkRemovedAsync(
int skinId, int? conditionId, HashSet<long> touched, DateTimeOffset now, CancellationToken ct)
{
return await _db.SkinLandListings
.Where(l => l.SkinId == skinId
&& l.ConditionId == conditionId
&& l.Status == ListingStatus.Active
&& !touched.Contains(l.ListingId))
.ExecuteUpdateAsync(setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now), ct);
}
// Stamp this band's skin.land checkpoint (upsert into skin_condition_sweeps under the
// skinland source). Caller persists via SaveChangesAsync.
private async Task StampCheckpointAsync(int? conditionId, DateTimeOffset now, CancellationToken ct)
{
if (conditionId is { } cid)
{
await SweepCheckpoints.StampConditionAsync(_db, cid, Source, now, ct);
}
}
private static SkinLandListing Map(SkinLandOffer o, int skinId, int? conditionId, DateTimeOffset now) => new()
{
ListingId = o.Id,
SkinId = skinId,
ConditionId = conditionId,
MarketHashName = o.Skin?.Name ?? "",
FloatValue = o.ItemFloat,
IsStatTrak = o.Skin?.IsStatTrak ?? false,
IsSouvenir = o.Skin?.IsSouvenir ?? false,
NameTag = o.NameTag,
StickerCount = o.Stickers?.Count(s => s is not null) ?? 0,
Price = o.FinalWithdrawalPrice ?? 0m,
Currency = "USD",
InspectLink = o.ItemLink,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
};
}

View File

@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace BlueLaminate.Core.SkinLand;
/// <summary>
/// The subset of a skin.land <c>obtained-skins</c> offer we persist, parsed from the
/// JSON the Python worker scrapes (the paginated <c>data[]</c> array). Decimals are
/// parsed directly (not via double) so the full-precision float round-trips exactly into
/// <c>numeric(20,18)</c>. skin.land exposes no paint seed / def index, so there's nothing
/// to fingerprint a <c>SkinInstance</c> with — the shape is intentionally thin.
/// </summary>
public sealed class SkinLandOffer
{
[JsonPropertyName("id")] public long Id { get; set; }
[JsonPropertyName("item_float")] public decimal? ItemFloat { get; set; }
[JsonPropertyName("final_withdrawal_price")] public decimal? FinalWithdrawalPrice { get; set; }
[JsonPropertyName("name_tag")] public string? NameTag { get; set; }
[JsonPropertyName("item_link")] public string? ItemLink { get; set; }
[JsonPropertyName("stickers")] public List<SkinLandSticker?>? Stickers { get; set; }
[JsonPropertyName("skin")] public SkinLandSkin? Skin { get; set; }
}
public sealed class SkinLandSkin
{
[JsonPropertyName("id")] public long? Id { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("url")] public string? Url { get; set; } // the market slug
[JsonPropertyName("is_stattrak")] public bool IsStatTrak { get; set; }
[JsonPropertyName("is_souvenir")] public bool IsSouvenir { get; set; }
}
public sealed class SkinLandSticker
{
[JsonPropertyName("name")] public string? Name { get; set; }
}

View File

@@ -0,0 +1,69 @@
using System.Text;
namespace BlueLaminate.Core.SkinLand;
/// <summary>
/// Builds a skin.land market URL from the catalogue's weapon + skin + wear. skin.land's
/// market routes are <c>/market/csgo/{slug}/</c> where the slug is simply
/// <c>{weapon}-{skin}-{wear}</c> kebab-cased — verified against the live site (e.g.
/// "M4A4" + "Global Offensive" + "Battle-Scarred" → <c>m4a4-global-offensive-battle-scarred</c>,
/// "AK-47" + "Redline" + "Field-Tested" → <c>ak-47-redline-field-tested</c>). No discovery
/// or stored mapping is needed.
/// <para>
/// StatTrak and Souvenir are <em>separate</em> pages on skin.land (<c>stattrak-</c>/
/// <c>souvenir-</c> prefixed slugs); this builds the base (non-special) page, which is the
/// unit v1 sweeps per <c>SkinCondition</c>.
/// </para>
/// </summary>
public static class SkinLandSlug
{
private const string MarketBase = "https://skin.land/market/csgo/";
/// <summary>"M4A4", "Global Offensive", "Battle-Scarred" → the full market URL.</summary>
public static string MarketUrl(string weapon, string skinName, string condition) =>
$"{MarketBase}{Slugify($"{weapon} {skinName} {condition}")}/";
/// <summary>
/// Lowercase, collapse every run of non-alphanumeric characters to a single hyphen,
/// and trim leading/trailing hyphens. So "AK-47 | Redline (Field-Tested)" and the
/// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested".
/// <para>
/// The apostrophe is the one exception: skin.land keeps it literally in the slug rather
/// than collapsing it to a hyphen (verified live — "AWP | Man-o'-war" →
/// <c>awp-man-o'-war</c>, "AUG | Lil' Pig" → <c>aug-lil'-pig</c>; the collapsed
/// <c>man-o-war</c>/<c>lil-pig</c> forms 404). Both the ASCII (') and typographic ()
/// apostrophe normalize to a literal ASCII apostrophe in the slug.
/// </para>
/// </summary>
public static string Slugify(string value)
{
var sb = new StringBuilder(value.Length);
var pendingHyphen = false;
foreach (var ch in value)
{
if (ch is '\'' or '')
{
// skin.land preserves the apostrophe as part of the word — emit it literally,
// and don't let it trigger a hyphen on either side.
sb.Append('\'');
pendingHyphen = false;
}
else if (char.IsLetterOrDigit(ch))
{
if (pendingHyphen && sb.Length > 0)
{
sb.Append('-');
}
sb.Append(char.ToLowerInvariant(ch));
pendingHyphen = false;
}
else
{
pendingHyphen = true;
}
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,12 @@
namespace BlueLaminate.Core.Skins;
/// <param name="Skipped">True when the monthly throttle suppressed the run.</param>
/// <param name="LastRanAt">When the previous successful run happened, if any.</param>
public sealed record SkinSyncResult(
bool Skipped,
DateTimeOffset? LastRanAt,
int Loaded,
int Inserted,
int Updated,
int WeaponsCreated,
int CollectionsCreated);

View File

@@ -0,0 +1,196 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.Skins;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.Skins;
/// <summary>
/// Loads the CS2 skin catalogue from the CSGO-API dataset and upserts it. The
/// weapon list and the collections/containers are derived from the skins
/// themselves, so any that are missing are created on the fly and no skin is
/// dropped. Throttled to once a month unless forced, since the catalogue changes
/// slowly.
/// </summary>
public sealed class SkinSyncService
{
public const string Source = "skins";
private readonly SkinTrackerDbContext _db;
private readonly SkinCatalogClient _client;
private readonly ILogger<SkinSyncService> _logger;
public SkinSyncService(
SkinTrackerDbContext db,
SkinCatalogClient client,
ILogger<SkinSyncService> logger)
{
_db = db;
_client = client;
_logger = logger;
}
public async Task<SkinSyncResult> SyncAsync(bool force = false, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var lastRanAt = await _db.ScrapeRuns
.Where(r => r.Source == Source)
.OrderByDescending(r => r.RanAt)
.Select(r => (DateTimeOffset?)r.RanAt)
.FirstOrDefaultAsync(ct);
if (!force && lastRanAt is { } last && last.AddMonths(1) > now)
{
_logger.LogInformation(
"Skipping skin sync; last run was {LastRanAt:u} (throttled to monthly).", last);
return new SkinSyncResult(true, last, 0, 0, 0, 0, 0);
}
_logger.LogInformation("Starting skin sync (force: {Force}).", force);
var catalog = await _client.FetchAsync(ct);
_logger.LogInformation("Loaded {Count} skins from the catalogue.", catalog.Count);
var weapons = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct);
var collections = await _db.Collections.ToDictionaryAsync(c => c.Slug, ct);
var existing = await _db.Skins
.Include(s => s.Collections)
.ToDictionaryAsync(s => s.Slug, ct);
var inserted = 0;
var updated = 0;
var weaponsCreated = 0;
var collectionsCreated = 0;
foreach (var s in catalog)
{
var weapon = ResolveWeapon(weapons, s, ref weaponsCreated);
var sources = ResolveCollections(collections, s, ref collectionsCreated);
if (existing.TryGetValue(s.Id, out var skin))
{
if (Apply(skin, s, weapon, sources))
{
updated++;
}
}
else
{
skin = new Skin { Slug = s.Id };
Apply(skin, s, weapon, sources);
_db.Skins.Add(skin);
existing[s.Id] = skin;
inserted++;
}
}
_db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = catalog.Count });
await _db.SaveChangesAsync(ct);
_logger.LogInformation(
"Skin sync complete: {Loaded} loaded, {Inserted} inserted, {Updated} updated, "
+ "{WeaponsCreated} weapons created, {CollectionsCreated} collections created.",
catalog.Count, inserted, updated, weaponsCreated, collectionsCreated);
return new SkinSyncResult(
false, lastRanAt, catalog.Count, inserted, updated, weaponsCreated, collectionsCreated);
}
private Weapon ResolveWeapon(Dictionary<string, Weapon> weapons, CatalogSkin s, ref int created)
{
if (weapons.TryGetValue(s.WeaponName, out var weapon))
{
// Category/team can be refined as the catalogue grows; keep them current.
weapon.Type = s.Category;
weapon.Team = s.Team;
return weapon;
}
weapon = new Weapon { Name = s.WeaponName, Type = s.Category, Team = s.Team };
_db.Weapons.Add(weapon);
weapons[s.WeaponName] = weapon;
created++;
return weapon;
}
private List<Collection> ResolveCollections(
Dictionary<string, Collection> collections, CatalogSkin s, ref int created)
{
var resolved = new List<Collection>(s.Sources.Count);
foreach (var source in s.Sources)
{
if (!collections.TryGetValue(source.Id, out var collection))
{
collection = new Collection { Slug = source.Id, Name = source.Name, Type = source.Type };
_db.Collections.Add(collection);
collections[source.Id] = collection;
created++;
}
resolved.Add(collection);
}
return resolved;
}
// Copies catalogue values onto the entity. Returns true if anything changed.
// The weapon navigation is assigned directly (a newly created weapon has no
// id yet to compare against, so reference-assigning is the only correct way
// to wire the FK). The collection links are reconciled against the current set.
private static bool Apply(Skin skin, CatalogSkin s, Weapon weapon, List<Collection> sources)
{
skin.Weapon = weapon;
var changed = false;
void Set<T>(Func<T> get, Action<T> set, T value)
{
if (!EqualityComparer<T>.Default.Equals(get(), value))
{
set(value);
changed = true;
}
}
Set(() => skin.Name, v => skin.Name = v, s.Name);
Set<int?>(() => skin.DefIndex, v => skin.DefIndex = v, s.DefIndex);
Set<int?>(() => skin.PaintIndex, v => skin.PaintIndex = v, s.PaintIndex);
Set(() => skin.Rarity, v => skin.Rarity = v, s.Rarity);
Set(() => skin.Description, v => skin.Description = v, s.Description);
Set(() => skin.ImageUrl, v => skin.ImageUrl = v, s.ImageUrl);
Set(() => skin.StatTrakAvailable, v => skin.StatTrakAvailable = v, s.StatTrakAvailable);
Set(() => skin.SouvenirAvailable, v => skin.SouvenirAvailable = v, s.SouvenirAvailable);
Set<decimal?>(() => skin.FloatMin, v => skin.FloatMin = v, s.FloatMin);
Set<decimal?>(() => skin.FloatMax, v => skin.FloatMax = v, s.FloatMax);
if (ReconcileCollections(skin.Collections, sources))
{
changed = true;
}
return changed;
}
// Adds collections the skin newly belongs to and removes ones it no longer
// does, comparing by slug. Returns true if the set changed.
private static bool ReconcileCollections(ICollection<Collection> current, List<Collection> desired)
{
var changed = false;
foreach (var collection in desired)
{
if (!current.Any(c => c.Slug == collection.Slug))
{
current.Add(collection);
changed = true;
}
}
foreach (var collection in current.Where(c => desired.All(d => d.Slug != c.Slug)).ToList())
{
current.Remove(collection);
changed = true;
}
return changed;
}
}

View File

@@ -0,0 +1,409 @@
using System.Collections.Concurrent;
using BlueLaminate.Core.Options;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The multi-collection tradeup search. Where the single-collection pass keeps all ten
/// inputs in one collection, this mixes inputs from any collections sharing a rarity tier —
/// the pattern behind most genuinely profitable contracts (cheap inputs from one collection,
/// a valuable output rolled from another).
/// <para>
/// It exploits two facts: an output's probability is linear in how many inputs came from its
/// collection (<c>n_C / size·k_C</c>), and the produced float depends only on the single
/// global average input fraction. So for a FIXED output-float target F, each input copy has an
/// independent reward — its collection's average output value share minus its price — and one
/// max-reward knapsack over the whole tier's pool finds the optimal mix without enumerating
/// collection subsets. The search sweeps F on a grid; each grid point is an independent chunk
/// run in parallel. Because the reward is a linear (expected-value) function, this optimises
/// EXPECTED profit; each winning selection is then evaluated exactly.
/// </para>
/// </summary>
public static class MultiCollectionSearch
{
private sealed record CollectionInfo(
int CollectionId,
string CollectionName,
WeaponRarity OutputRarity,
IReadOnlyList<TradeupOutputSkin> Outputs);
// An input copy already reduced to its collection, float bucket and price.
private readonly record struct PoolItem(int CollectionId, int Bucket, decimal Fraction, InputListing Listing);
public static List<TradeupCandidate> Evaluate(
TradeupGraph graph,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
TradeupOptions options,
CancellationToken ct)
{
var step = options.MultiCollectionFloatGrid;
var size = options.ContractSize;
var maxBucketPerItem = (int)Math.Ceiling(1m / step);
var results = new List<TradeupCandidate>();
// All recipes sharing an input rarity can be mixed (each collection still rolls into
// its own next tier). Group by (input rarity, ST universe).
var byTier = graph.Groups.GroupBy(g => g.InputRarity);
foreach (var tier in byTier)
{
var tierGroups = tier.ToList();
foreach (var statTrak in StatTrakUniverses(options.StatTrak))
{
ct.ThrowIfCancellationRequested();
EvaluateTier(tier.Key, tierGroups, statTrak, listingData, floatBounds, options, step, size,
maxBucketPerItem, results, ct);
}
}
return results;
}
private static void EvaluateTier(
WeaponRarity inputRarity,
List<TradeupInputGroup> tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
TradeupOptions options,
decimal step,
int size,
int maxBucketPerItem,
List<TradeupCandidate> results,
CancellationToken ct)
{
var collections = tierGroups.ToDictionary(
g => g.CollectionId,
g => new CollectionInfo(g.CollectionId, g.CollectionName, g.OutputRarity, g.OutputSkins));
var skinCollection = new Dictionary<int, int>();
foreach (var g in tierGroups)
{
foreach (var skinId in g.InputSkinIds)
{
skinCollection[skinId] = g.CollectionId;
}
}
// Build the tier's input pool once, then trim to the cheapest `size` copies per
// (collection, float bucket) — within a cell every copy has the same float and value,
// so only the cheapest can ever be optimal. Bucketing/trim don't depend on the target,
// so this is done once and reused across all grid chunks.
var trimmed = BuildTrimmedPool(tierGroups, statTrak, listingData, floatBounds, step, maxBucketPerItem, size);
if (trimmed.Count < size)
{
return;
}
var priceBook = listingData.OutputPrices;
// One chunk per float-target grid point, run in parallel.
var grid = new List<decimal>();
for (var f = step; f <= 1m + 1e-9m; f += step)
{
grid.Add(Math.Min(f, 1m));
}
var chunkResults = new ConcurrentBag<TradeupCandidate>();
Parallel.ForEach(
grid,
new ParallelOptions { CancellationToken = ct },
target =>
{
var candidate = EvaluateChunk(
inputRarity, target, trimmed, collections, skinCollection, floatBounds, priceBook,
options, step, size, statTrak);
if (candidate is not null)
{
chunkResults.Add(candidate);
}
});
// Only genuine mixes belong here — single-collection selections are the dedicated
// pass's job, and emitting them too just duplicates rows. De-duplicate by collection
// mix (different float targets often converge on the same set), keep the best expected
// profit, then take the top few.
var deduped = chunkResults
.Where(c => c.CollectionCount >= 2)
.GroupBy(MixSignature)
.Select(grp => grp.MaxBy(c => c.ExpectedProfit)!)
.OrderByDescending(c => c.ExpectedProfit)
.Take(options.MultiCollectionPerTier);
lock (results)
{
results.AddRange(deduped);
}
}
private static List<PoolItem> BuildTrimmedPool(
List<TradeupInputGroup> tierGroups,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
decimal step,
int maxBucketPerItem,
int size)
{
// (collection, bucket) -> cheapest copies.
var cells = new Dictionary<(int Collection, int Bucket), List<PoolItem>>();
foreach (var group in tierGroups)
{
foreach (var skinId in group.InputSkinIds)
{
if (!floatBounds.TryGetValue(skinId, out var bounds))
{
continue;
}
foreach (var listing in listingData.InputsFor(skinId, statTrak))
{
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
var bucket = Math.Clamp((int)Math.Ceiling(fraction / step), 0, maxBucketPerItem);
var key = (group.CollectionId, bucket);
if (!cells.TryGetValue(key, out var cell))
{
cell = new List<PoolItem>();
cells[key] = cell;
}
cell.Add(new PoolItem(group.CollectionId, bucket, fraction, listing));
}
}
}
var trimmed = new List<PoolItem>();
foreach (var cell in cells.Values)
{
cell.Sort(static (a, b) => a.Listing.Price.CompareTo(b.Listing.Price));
for (var i = 0; i < Math.Min(size, cell.Count); i++)
{
trimmed.Add(cell[i]);
}
}
return trimmed;
}
private static TradeupCandidate? EvaluateChunk(
WeaponRarity inputRarity,
decimal target,
List<PoolItem> trimmed,
IReadOnlyDictionary<int, CollectionInfo> collections,
IReadOnlyDictionary<int, int> skinCollection,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
OutputPriceBook priceBook,
TradeupOptions options,
decimal step,
int size,
bool statTrak)
{
// Each collection's average output value if the produced float averages `target`.
var valueByCollection = new Dictionary<int, decimal>(collections.Count);
foreach (var (id, info) in collections)
{
valueByCollection[id] = AverageOutputValue(info, target, priceBook, options, statTrak);
}
var capBucket = (int)Math.Floor(target * size / step);
var items = new List<TradeupSelector.RewardItem>(trimmed.Count);
foreach (var item in trimmed)
{
if (item.Bucket > capBucket)
{
continue;
}
// Reward = this copy's share of expected output value, minus what it costs.
var reward = (double)(valueByCollection[item.CollectionId] / size - item.Listing.Price);
items.Add(new TradeupSelector.RewardItem(item.Bucket, reward, item.Listing));
}
var picks = TradeupSelector.SolveMaxReward(items, size, capBucket);
return picks is null
? null
: BuildCandidate(inputRarity, picks, collections, skinCollection, floatBounds, priceBook, options, size, statTrak);
}
// The conservative average output value of a collection at a given input-float average:
// each next-tier skin is equally likely; an output with no comparable listing contributes 0.
private static decimal AverageOutputValue(
CollectionInfo info, decimal averageFraction, OutputPriceBook priceBook,
TradeupOptions options, bool statTrak)
{
if (info.Outputs.Count == 0)
{
return 0m;
}
decimal total = 0m;
foreach (var output in info.Outputs)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
if (resolved.LowestAsk is { } ask)
{
total += NetSell(ask, options);
}
}
return total / info.Outputs.Count;
}
private static TradeupCandidate BuildCandidate(
WeaponRarity inputRarity,
PickNode picks,
IReadOnlyDictionary<int, CollectionInfo> collections,
IReadOnlyDictionary<int, int> skinCollection,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds,
OutputPriceBook priceBook,
TradeupOptions options,
int size,
bool statTrak)
{
var inputs = picks.ToList();
// Realised average fraction from the actual copies (exact, not bucketed).
decimal fractionSum = 0m;
var counts = new Dictionary<int, int>();
decimal cost = 0m;
foreach (var input in inputs)
{
cost += input.Price;
if (floatBounds.TryGetValue(input.SkinId, out var bounds))
{
fractionSum += TradeupMath.NormalizedFraction(input.FloatValue, bounds.Min, bounds.Max);
}
var collectionId = skinCollection[input.SkinId];
counts[collectionId] = counts.GetValueOrDefault(collectionId) + 1;
}
var averageFraction = fractionSum / size;
var outcomes = new List<TradeupOutcome>();
var composition = new List<TradeupContribution>();
foreach (var (collectionId, n) in counts.OrderByDescending(kv => kv.Value))
{
var info = collections[collectionId];
composition.Add(new TradeupContribution(collectionId, info.CollectionName, info.OutputRarity, n));
var k = info.Outputs.Count;
if (k == 0)
{
continue;
}
var probability = (decimal)n / (size * k);
foreach (var output in info.Outputs)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var band = WearBands.FromFloat(outputFloat);
var resolved = priceBook.Resolve(output.SkinId, statTrak, outputFloat, options.CsFloatThinOutputThreshold);
outcomes.Add(new TradeupOutcome(
output.SkinId,
output.Name,
outputFloat,
band,
probability,
resolved.LowestAsk is { } ask ? NetSell(ask, options) : null,
resolved.BandLiquidity,
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
}
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
var primary = composition[0];
return new TradeupCandidate(
primary.CollectionId,
SummariseMix(composition),
inputRarity,
primary.OutputRarity,
statTrak,
averageFraction,
cost,
expectedNet,
worstCaseNet,
guaranteed,
inputs,
outcomes,
composition);
}
private static string SummariseMix(IReadOnlyList<TradeupContribution> composition)
{
if (composition.Count == 1)
{
return composition[0].CollectionName;
}
var parts = composition.Take(3).Select(c => $"{Shorten(c.CollectionName)} ×{c.InputCount}");
var summary = string.Join(" + ", parts);
return composition.Count > 3 ? $"{summary} +{composition.Count - 3}" : summary;
}
private static string Shorten(string name)
{
// "The 2021 Mirage Collection" -> "Mirage" style trimming for compact summaries.
var trimmed = name;
if (trimmed.StartsWith("The ", StringComparison.Ordinal))
{
trimmed = trimmed[4..];
}
const string suffix = " Collection";
if (trimmed.EndsWith(suffix, StringComparison.Ordinal))
{
trimmed = trimmed[..^suffix.Length];
}
return trimmed;
}
private static string MixSignature(TradeupCandidate candidate)
=> string.Join(',', candidate.Composition
.OrderBy(c => c.CollectionId)
.Select(c => $"{c.CollectionId}:{c.InputCount}"));
private static decimal NetSell(decimal lowestAsk, TradeupOptions options)
=> lowestAsk * (1m - options.UndercutRate) * (1m - options.SellFeeRate);
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
{
decimal expected = 0m;
decimal worst = decimal.MaxValue;
var allPriced = true;
foreach (var outcome in outcomes)
{
var realised = outcome.NetSellPrice ?? 0m;
expected += outcome.Probability * realised;
worst = Math.Min(worst, realised);
if (outcome.NetSellPrice is null)
{
allPriced = false;
}
}
if (outcomes.Count == 0)
{
worst = 0m;
}
return (expected, worst, allPriced && worst > cost);
}
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}

View File

@@ -0,0 +1,67 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>One possible result of a contract and what it would net if it lands.</summary>
/// <param name="Probability">Chance this specific output is produced (single-collection: 1/k).</param>
/// <param name="NetSellPrice">
/// Realisable sale value after undercut + sell fee, or null when nothing comparable is
/// listed (treated as unsellable for the worst-case test).
/// </param>
/// <param name="Liquidity">Active listings backing the price, in the same wear band.</param>
/// <param name="PriceSource">Where the price came from: "market" (our stored listings) or
/// "csfloat-live" (re-priced from the CSFloat API because the stored liquidity was thin).</param>
public sealed record TradeupOutcome(
int SkinId,
string Name,
decimal OutputFloat,
WearBand Band,
decimal Probability,
decimal? NetSellPrice,
int Liquidity,
string PriceSource = "market");
/// <summary>
/// One collection's share of a (possibly multi-collection) contract: how many of the ten
/// inputs came from it, and which output tier those inputs roll into. Single-collection
/// contracts have exactly one of these.
/// </summary>
public sealed record TradeupContribution(
int CollectionId,
string CollectionName,
WeaponRarity OutputRarity,
int InputCount);
/// <summary>
/// A concrete, actionable tradeup: which ten copies to buy, what they cost, the output
/// distribution, and the resulting economics. The finder returns these ranked; a frontend
/// only formats them.
/// <para>
/// A contract may mix several collections (all inputs share the input rarity, but each
/// collection rolls into its own next tier). <see cref="Composition"/> records the per-
/// collection split; <see cref="CollectionCount"/> is its length. <see cref="OutputRarity"/>
/// is the tier of the largest contributor (a display convenience for the common case).
/// </para>
/// </summary>
public sealed record TradeupCandidate(
int CollectionId,
string CollectionName,
WeaponRarity InputRarity,
WeaponRarity OutputRarity,
bool StatTrak,
decimal AverageFraction,
decimal InputCost,
decimal ExpectedNet,
decimal WorstCaseNet,
bool Guaranteed,
IReadOnlyList<InputListing> Inputs,
IReadOnlyList<TradeupOutcome> Outcomes,
IReadOnlyList<TradeupContribution> Composition)
{
/// <summary>Number of distinct collections the inputs are drawn from (1 = single-collection).</summary>
public int CollectionCount => Composition.Count;
/// <summary>Expected profit across the output distribution, net of cost.</summary>
public decimal ExpectedProfit => ExpectedNet - InputCost;
/// <summary>Profit if the worst (lowest-value) output lands — negative unless guaranteed.</summary>
public decimal WorstCaseProfit => WorstCaseNet - InputCost;
}

View File

@@ -0,0 +1,476 @@
using BlueLaminate.Core.Options;
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// Finds profitable 10-input CS2 tradeup contracts over the live listings. It joins three
/// things: the catalogue-derived <see cref="TradeupGraph"/> (which collections produce
/// what), the active <see cref="MarketListing"/>s (what inputs cost and what outputs sell
/// for), and the exact <see cref="TradeupMath"/>. For each (collection-recipe, StatTrak)
/// universe it runs the cardinality-constrained selection DP and values every resulting
/// output distribution, keeping the best contract per recipe and ranking them.
/// <para>
/// When a proposed contract's output is thinly listed in our data, its stored lowest-ask is
/// fragile, so a follow-up pass re-prices that output from the live CSFloat API and
/// recomputes the economics (see <see cref="EnrichThinOutputsAsync"/>).
/// </para>
/// <para>
/// All economics live here, never in a frontend: the CLI and the future web UI both call
/// <see cref="FindAsync"/> and only format the returned candidates.
/// </para>
/// </summary>
public sealed class TradeupFinder
{
private readonly SkinTrackerDbContext _db;
private readonly TradeupGraphBuilder _graphBuilder;
private readonly TradeupOptions _options;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TradeupFinder> _logger;
public TradeupFinder(
SkinTrackerDbContext db,
TradeupGraphBuilder graphBuilder,
IOptions<TradeupOptions> options,
IServiceProvider serviceProvider,
ILogger<TradeupFinder> logger)
{
_db = db;
_graphBuilder = graphBuilder;
_options = options.Value;
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <summary>
/// Runs the search and returns candidates ranked best-first. <paramref name="maxResults"/>
/// caps the returned list; pass 0 or negative for "all".
/// </summary>
public async Task<IReadOnlyList<TradeupCandidate>> FindAsync(
int maxResults = 50,
CancellationToken ct = default)
{
var graph = await _graphBuilder.BuildAsync(ct);
// Float bounds for input skins, needed to normalise each input copy's float to its
// own range before averaging. (Output bounds already live on the graph.)
var floatBounds = await _db.Skins
.Where(s => s.FloatMin != null && s.FloatMax != null)
.Select(s => new { s.Id, Min = s.FloatMin!.Value, Max = s.FloatMax!.Value })
.AsNoTracking()
.ToDictionaryAsync(s => s.Id, s => (s.Min, s.Max), ct);
// def_index/paint_index for output skins, so a thin output can be looked up live on
// CSFloat (which identifies items by these two indexes).
var indexes = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
.AsNoTracking()
.ToDictionaryAsync(s => s.Id, s => (s.Def, s.Paint), ct);
var listingData = await LoadListingsAsync(ct);
var universes = StatTrakUniverses(_options.StatTrak);
var candidates = new List<TradeupCandidate>();
foreach (var group in graph.Groups)
{
foreach (var statTrak in universes)
{
var candidate = EvaluateRecipe(group, statTrak, listingData, floatBounds);
if (candidate is not null)
{
candidates.Add(candidate);
}
}
}
// Multi-collection contracts (expected-profit optimised) on top of the single-collection
// pass. Skipped under StatTrak filters? No — it respects the same universes internally.
if (_options.MultiCollection)
{
var multi = MultiCollectionSearch.Evaluate(graph, listingData, floatBounds, _options, ct);
_logger.LogInformation("Multi-collection search produced {Count} candidate contracts.", multi.Count);
candidates.AddRange(multi);
}
var ranked = candidates.OrderByDescending(RankingMetric).ToList();
// Re-price thin outputs from the live CSFloat API. Only the top window is enriched
// (it's where results are shown, and it bounds the live lookups); the rest keep their
// stored pricing. Then re-filter and re-rank with the refreshed economics.
var window = maxResults > 0 ? Math.Max(maxResults * 3, 60) : ranked.Count;
var head = await EnrichThinOutputsAsync(ranked.Take(window).ToList(), indexes, ct);
ranked = head.Concat(ranked.Skip(window))
.Where(c => !_options.GuaranteedOnly || c.Guaranteed)
.Where(c => RankingMetric(c) >= _options.MinProfit)
.OrderByDescending(RankingMetric)
.ToList();
_logger.LogInformation(
"Tradeup search complete: {Surviving} qualifying contracts (guaranteedOnly={Guaranteed}, "
+ "minProfit={MinProfit}, statTrak={StatTrak}).",
ranked.Count, _options.GuaranteedOnly, _options.MinProfit, _options.StatTrak);
return maxResults > 0 ? ranked.Take(maxResults).ToList() : ranked;
}
/// <summary>
/// Evaluates one (recipe, StatTrak) universe: builds the input pool, solves the
/// selection DP, and returns the best qualifying contract — or null if the recipe can't
/// be filled or nothing clears the filters.
/// </summary>
private TradeupCandidate? EvaluateRecipe(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
{
var pool = BuildPool(group, statTrak, listingData, floatBounds);
if (pool.Count < _options.ContractSize)
{
return null;
}
var selection = TradeupSelector.Solve(pool, _options.ContractSize, _options.FractionBucket);
TradeupCandidate? best = null;
decimal bestMetric = decimal.MinValue;
foreach (var (averageFraction, cost, picks) in selection.Selections())
{
var candidate = BuildCandidate(
group, statTrak, averageFraction, cost, picks, listingData.OutputPrices);
if (_options.GuaranteedOnly && !candidate.Guaranteed)
{
continue;
}
var metric = RankingMetric(candidate);
if (metric < _options.MinProfit)
{
continue;
}
if (metric > bestMetric)
{
bestMetric = metric;
best = candidate;
}
}
return best;
}
private List<SelectableInput> BuildPool(
TradeupInputGroup group,
bool statTrak,
TradeupListingData listingData,
IReadOnlyDictionary<int, (decimal Min, decimal Max)> floatBounds)
{
var pool = new List<SelectableInput>();
foreach (var skinId in group.InputSkinIds)
{
if (!floatBounds.TryGetValue(skinId, out var bounds))
{
continue;
}
foreach (var listing in listingData.InputsFor(skinId, statTrak))
{
var fraction = TradeupMath.NormalizedFraction(listing.FloatValue, bounds.Min, bounds.Max);
pool.Add(new SelectableInput(fraction, listing));
}
}
return pool;
}
private TradeupCandidate BuildCandidate(
TradeupInputGroup group,
bool statTrak,
decimal averageFraction,
decimal cost,
PickNode picks,
OutputPriceBook priceBook)
{
var probability = 1m / group.OutputSkins.Count; // single-collection v1: equally likely.
var outcomes = new List<TradeupOutcome>(group.OutputSkins.Count);
foreach (var output in group.OutputSkins)
{
var outputFloat = TradeupMath.OutputFloat(averageFraction, output.FloatMin, output.FloatMax);
var band = WearBands.FromFloat(outputFloat);
var resolved = priceBook.Resolve(
output.SkinId, statTrak, outputFloat, _options.CsFloatThinOutputThreshold);
outcomes.Add(new TradeupOutcome(
output.SkinId,
output.Name,
outputFloat,
band,
probability,
resolved.LowestAsk is { } ask ? NetSell(ask) : null,
resolved.BandLiquidity,
resolved.Basis == OutputPriceBasis.Floor ? "market-floor" : "market"));
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(outcomes, cost);
return new TradeupCandidate(
group.CollectionId,
group.CollectionName,
group.InputRarity,
group.OutputRarity,
statTrak,
averageFraction,
cost,
expectedNet,
worstCaseNet,
guaranteed,
picks.ToList(),
outcomes,
new[] { new TradeupContribution(group.CollectionId, group.CollectionName, group.OutputRarity, _options.ContractSize) });
}
/// <summary>
/// For each candidate with a thinly-listed output (liquidity below the configured
/// threshold), fetches that output's current lowest ask from the live CSFloat API and
/// recomputes the contract's economics. Distinct (skin, ST, band) lookups are cached and
/// the total is capped, so this stays within the API's rate-limit budget. Inert (returns
/// the input unchanged) when the feature is off or no CSFloat key is configured.
/// </summary>
private async Task<List<TradeupCandidate>> EnrichThinOutputsAsync(
List<TradeupCandidate> candidates,
IReadOnlyDictionary<int, (int Def, int Paint)> indexes,
CancellationToken ct)
{
if (!_options.UseCsFloatForThinOutputs || candidates.Count == 0)
{
return candidates;
}
var client = TryResolveCsFloatClient();
if (client is null)
{
return candidates;
}
var cache = new Dictionary<(int Def, int Paint, bool StatTrak, WearBand Band), BandPrice?>();
var lookups = 0;
var enriched = 0;
var stop = false;
var result = new List<TradeupCandidate>(candidates.Count);
foreach (var candidate in candidates)
{
var thin = candidate.Outcomes.Any(o =>
o.Liquidity < _options.CsFloatThinOutputThreshold && indexes.ContainsKey(o.SkinId));
if (!thin)
{
result.Add(candidate);
continue;
}
var newOutcomes = new List<TradeupOutcome>(candidate.Outcomes.Count);
var changed = false;
foreach (var outcome in candidate.Outcomes)
{
if (outcome.Liquidity >= _options.CsFloatThinOutputThreshold
|| !indexes.TryGetValue(outcome.SkinId, out var idx))
{
newOutcomes.Add(outcome);
continue;
}
var key = (idx.Def, idx.Paint, candidate.StatTrak, outcome.Band);
if (!cache.TryGetValue(key, out var live))
{
if (stop || lookups >= _options.CsFloatMaxLookups)
{
newOutcomes.Add(outcome);
continue;
}
lookups++;
try
{
live = await FetchCsFloatBandPriceAsync(
client, idx.Def, idx.Paint, candidate.StatTrak, outcome.Band, ct);
cache[key] = live;
}
catch (CsFloatApiException ex)
{
// Rate-limited or rejected — stop hitting the API and keep stored prices.
_logger.LogWarning("CSFloat re-pricing halted after {Lookups} lookups: {Message}",
lookups, ex.Message);
stop = true;
newOutcomes.Add(outcome);
continue;
}
}
if (live is { } bp)
{
newOutcomes.Add(outcome with
{
NetSellPrice = NetSell(bp.LowestAsk),
Liquidity = bp.Liquidity,
PriceSource = "csfloat-live",
});
changed = true;
}
else
{
newOutcomes.Add(outcome);
}
}
if (!changed)
{
result.Add(candidate);
continue;
}
var (expectedNet, worstCaseNet, guaranteed) = Economics(newOutcomes, candidate.InputCost);
result.Add(candidate with
{
Outcomes = newOutcomes,
ExpectedNet = expectedNet,
WorstCaseNet = worstCaseNet,
Guaranteed = guaranteed,
});
enriched++;
}
if (enriched > 0)
{
_logger.LogInformation(
"Re-priced {Enriched} contracts with thin outputs via CSFloat ({Lookups} live lookups).",
enriched, lookups);
}
return result;
}
private static async Task<BandPrice?> FetchCsFloatBandPriceAsync(
CsFloatListingsClient client, int defIndex, int paintIndex, bool statTrak, WearBand band, CancellationToken ct)
{
var (min, max) = band.Bounds();
// Sorted lowest_price ascending, scoped to the band — so the first listing matching the
// ST flag (and not a souvenir) is the lowest comparable ask.
var page = await client.FetchPageAsync(
defIndex, paintIndex, sortBy: "lowest_price", limit: 50, cursor: null,
type: "buy_now", minFloat: min, maxFloat: max, ct: ct);
decimal? lowest = null;
var count = 0;
foreach (var listing in page.Listings)
{
if (listing.IsSouvenir || listing.IsStatTrak != statTrak)
{
continue;
}
count++;
lowest ??= listing.Price;
}
return lowest is { } price ? new BandPrice(price, count) : null;
}
private CsFloatListingsClient? TryResolveCsFloatClient()
{
try
{
return _serviceProvider.GetRequiredService<CsFloatListingsClient>();
}
catch (Exception ex)
{
// No API key configured (the client's ctor throws): the feature is simply inert.
_logger.LogWarning("CSFloat re-pricing unavailable, using stored prices only: {Message}", ex.Message);
return null;
}
}
// Realisable sale value from a lowest ask: undercut to sell, then pay the marketplace fee.
private decimal NetSell(decimal lowestAsk)
=> lowestAsk * (1m - _options.UndercutRate) * (1m - _options.SellFeeRate);
private static (decimal Expected, decimal Worst, bool Guaranteed) Economics(
IReadOnlyList<TradeupOutcome> outcomes, decimal cost)
{
decimal expected = 0m;
decimal worst = decimal.MaxValue;
var allPriced = true;
foreach (var outcome in outcomes)
{
var realised = outcome.NetSellPrice ?? 0m;
expected += outcome.Probability * realised;
worst = Math.Min(worst, realised);
if (outcome.NetSellPrice is null)
{
allPriced = false;
}
}
if (outcomes.Count == 0)
{
worst = 0m;
}
// Guaranteed = every output is priced AND even the cheapest one clears input cost.
return (expected, worst, allPriced && worst > cost);
}
private async Task<TradeupListingData> LoadListingsAsync(CancellationToken ct)
{
var rows = await _db.MarketListings
.Where(l => l.Status == "Active"
&& l.Currency == _options.Currency
&& l.SkinId != null
&& l.Price > 0m)
.Select(l => new TradeupListingRow(
l.SkinId!.Value,
l.MarketHashName,
l.Marketplace,
l.InspectLink,
l.ExternalId,
l.IsStatTrak,
l.IsSouvenir,
l.FloatValue,
l.Price))
.ToListAsync(ct);
_logger.LogInformation("Loaded {Count} active {Currency} listings for tradeup search.",
rows.Count, _options.Currency);
return TradeupListingData.Build(rows);
}
private decimal RankingMetric(TradeupCandidate candidate) => _options.Ranking switch
{
TradeupRanking.ExpectedProfit => candidate.ExpectedProfit,
_ => candidate.WorstCaseProfit,
};
private static IReadOnlyList<bool> StatTrakUniverses(StatTrakMode mode) => mode switch
{
StatTrakMode.NonStatTrakOnly => new[] { false },
StatTrakMode.StatTrakOnly => new[] { true },
_ => new[] { false, true },
};
}

View File

@@ -0,0 +1,37 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// A skin that can come OUT of a tradeup, carrying the float bounds needed to map an
/// input average float onto this skin's own wear range. <see cref="StatTrakAvailable"/>
/// is recorded so the listing-side query (Phase C) can filter ST vs non-ST outputs;
/// the graph itself is ST-agnostic.
/// </summary>
public sealed record TradeupOutputSkin(
int SkinId,
string Name,
decimal FloatMin,
decimal FloatMax,
bool StatTrakAvailable);
/// <summary>
/// One tradeup "recipe slot": all eligible input skins of a single rarity within one
/// collection, and the set of output skins they produce (the next rarity tier present
/// in that collection). For a single-collection contract, ten inputs are drawn from
/// <see cref="InputSkinIds"/> and each of <see cref="OutputSkins"/> is an equally likely
/// outcome, so k_C = <c>OutputSkins.Count</c>.
/// </summary>
public sealed record TradeupInputGroup(
int CollectionId,
string CollectionName,
WeaponRarity InputRarity,
WeaponRarity OutputRarity,
IReadOnlyList<int> InputSkinIds,
IReadOnlyList<TradeupOutputSkin> OutputSkins);
/// <summary>
/// The full tradeup reference graph derived from the static catalogue: every
/// (collection, input rarity) → (output rarity, output skins) edge that yields a
/// 10-input weapon tradeup. Built once per process from the monthly-synced catalogue
/// (see <see cref="TradeupGraphBuilder"/>); contains no pricing or listing data.
/// </summary>
public sealed record TradeupGraph(IReadOnlyList<TradeupInputGroup> Groups);

View File

@@ -0,0 +1,197 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// Derives the <see cref="TradeupGraph"/> from the synced catalogue
/// (<see cref="Skin"/> + <see cref="Collection"/> + <see cref="Weapon"/>) with no
/// pricing, no listings, and no new tables. A single query loads the catalogue; the
/// graph is assembled in memory. Because the catalogue changes only when
/// <c>SkinSyncService</c> runs (monthly), callers can build this once and cache it for
/// the process lifetime.
/// </summary>
public sealed class TradeupGraphBuilder
{
/// <summary>
/// Pseudo-collections that are not real tradeup collections. "Limited Edition Item"
/// holds armory/non-tradeable skins (e.g. AK-47 Aphrodite) that must never be treated
/// as tradeup inputs or outputs.
/// </summary>
private static readonly HashSet<string> SkipCollectionNames = new(StringComparer.Ordinal)
{
"Limited Edition Item",
};
/// <summary>
/// Weapon categories that carry weapon-tier rarities but are never weapon tradeup
/// outputs: knives are stored as <c>Covert</c> and gloves as <c>Extraordinary</c>.
/// Excluded defensively even though the rarity/float filters already drop most.
/// </summary>
private static readonly HashSet<string> ExcludedWeaponTypes = new(StringComparer.Ordinal)
{
"Knives",
"Gloves",
};
private const string CollectionType = "Collection";
private readonly SkinTrackerDbContext _db;
private readonly ILogger<TradeupGraphBuilder> _logger;
public TradeupGraphBuilder(SkinTrackerDbContext db, ILogger<TradeupGraphBuilder> logger)
{
_db = db;
_logger = logger;
}
public async Task<TradeupGraph> BuildAsync(CancellationToken ct = default)
{
var skins = await _db.Skins
.Include(s => s.Collections)
.Include(s => s.Weapon)
.AsNoTracking()
.ToListAsync(ct);
// collectionId -> (collection, rarity -> eligible skins). Only Type='Collection'
// sources outside the skip-list participate; one skin can be filed under several
// collections, so it is added to each.
var byCollection = new Dictionary<int, CollectionBucket>();
foreach (var skin in skins)
{
if (!IsEligibleSkin(skin, out var rarity))
{
continue;
}
foreach (var collection in skin.Collections)
{
if (collection.Type != CollectionType || SkipCollectionNames.Contains(collection.Name))
{
continue;
}
if (!byCollection.TryGetValue(collection.Id, out var bucket))
{
bucket = new CollectionBucket(collection);
byCollection[collection.Id] = bucket;
}
bucket.Add(rarity, skin);
}
}
var groups = new List<TradeupInputGroup>();
foreach (var bucket in byCollection.Values)
{
// Tiers that actually have eligible skins in this collection, ascending.
var presentTiers = bucket.SkinsByRarity.Keys.OrderBy(r => (int)r).ToList();
foreach (var inputRarity in presentTiers)
{
// Covert is the v1 ceiling: it can be an output but never a 10-input source.
if (inputRarity >= WeaponRarity.Covert)
{
continue;
}
var outputRarity = NextPresentTier(presentTiers, inputRarity);
if (outputRarity is null)
{
// Collection tops out at this tier (or caps below Covert) — no tradeup.
continue;
}
var inputSkinIds = bucket.SkinsByRarity[inputRarity]
.Select(s => s.Id)
.ToList();
var outputSkins = bucket.SkinsByRarity[outputRarity.Value]
.Select(s => new TradeupOutputSkin(
s.Id,
s.Name,
s.FloatMin!.Value,
s.FloatMax!.Value,
s.StatTrakAvailable))
.ToList();
groups.Add(new TradeupInputGroup(
bucket.Collection.Id,
bucket.Collection.Name,
inputRarity,
outputRarity.Value,
inputSkinIds,
outputSkins));
}
}
_logger.LogInformation(
"Built tradeup graph: {Groups} input groups across {Collections} collections "
+ "from {Skins} catalogue skins.",
groups.Count, byCollection.Count, skins.Count);
return new TradeupGraph(groups);
}
/// <summary>
/// A skin is eligible to appear in the graph (as input or output) iff it parses to a
/// weapon tier, is not a knife/glove, and has both float bounds. The skip-list and
/// Type='Collection' filter are applied per-collection by the caller.
/// </summary>
private static bool IsEligibleSkin(Skin skin, out WeaponRarity rarity)
{
rarity = default;
if (skin.FloatMin is null || skin.FloatMax is null)
{
return false;
}
if (ExcludedWeaponTypes.Contains(skin.Weapon.Type))
{
return false;
}
// Throws on an unknown literal (catalogue rename); returns false for the
// non-weapon rarities (Contraband/Extraordinary).
return WeaponRarityExtensions.TryParse(skin.Rarity, out rarity);
}
/// <summary>The smallest present tier strictly greater than <paramref name="tier"/>, or null.</summary>
private static WeaponRarity? NextPresentTier(List<WeaponRarity> presentTiers, WeaponRarity tier)
{
foreach (var candidate in presentTiers)
{
if (candidate > tier)
{
return candidate;
}
}
return null;
}
private sealed class CollectionBucket
{
public CollectionBucket(Collection collection) => Collection = collection;
public Collection Collection { get; }
public Dictionary<WeaponRarity, List<Skin>> SkinsByRarity { get; } = new();
public void Add(WeaponRarity rarity, Skin skin)
{
if (!SkinsByRarity.TryGetValue(rarity, out var list))
{
list = new List<Skin>();
SkinsByRarity[rarity] = list;
}
list.Add(skin);
}
}
}

View File

@@ -0,0 +1,203 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>One active listing reduced to the fields the finder needs.</summary>
public readonly record struct TradeupListingRow(
int SkinId,
string MarketHashName,
string Marketplace,
string? InspectLink,
string ExternalId,
bool IsStatTrak,
bool IsSouvenir,
decimal? FloatValue,
decimal Price);
/// <summary>
/// A purchasable input copy: a real listing the engine can pick into a contract. Carries
/// the market-hash name, marketplace, and the source listing's inspect link + external id
/// so the output is an actionable buy list that points at the exact listing.
/// </summary>
public readonly record struct InputListing(
int SkinId,
string MarketHashName,
string Marketplace,
string? InspectLink,
string ExternalId,
decimal FloatValue,
decimal Price);
/// <summary>Lowest active ask and listing count for one (skin, ST, wear band).</summary>
public readonly record struct BandPrice(decimal LowestAsk, int Liquidity);
/// <summary>Where a resolved output price came from.</summary>
public enum OutputPriceBasis
{
/// <summary>Nothing comparable is listed anywhere — unpriceable.</summary>
None,
/// <summary>The wear band the output lands in, which is liquid enough to trust.</summary>
Band,
/// <summary>
/// The band was too thin to trust, so this is the skin's cheapest comparable listing
/// across any wear — a conservative proxy. A live CSFloat lookup should refine it.
/// </summary>
Floor,
}
/// <summary>An output price plus how thin its own band was and where the number came from.</summary>
public readonly record struct ResolvedOutputPrice(decimal? LowestAsk, int BandLiquidity, OutputPriceBasis Basis);
/// <summary>
/// The listing-side inputs to the finder (Phase B/C data), built once from a single scan
/// of active listings:
/// <list type="bullet">
/// <item>input pools — every floated input copy, split into the disjoint non-ST and ST
/// universes (non-ST = normal souvenir);</item>
/// <item>an <see cref="OutputPriceBook"/> — the lowest non-souvenir ask per (skin, ST,
/// wear band), used to value a produced output at its computed float.</item>
/// </list>
/// Floatless listings are dropped: an input copy with no float can't be normalised, and
/// an output listing with no float can't be placed in a wear band.
/// </summary>
public sealed class TradeupListingData
{
private readonly IReadOnlyDictionary<int, List<InputListing>> _nonStatTrakInputs;
private readonly IReadOnlyDictionary<int, List<InputListing>> _statTrakInputs;
private readonly OutputPriceBook _outputPrices;
private TradeupListingData(
IReadOnlyDictionary<int, List<InputListing>> nonStatTrakInputs,
IReadOnlyDictionary<int, List<InputListing>> statTrakInputs,
OutputPriceBook outputPrices)
{
_nonStatTrakInputs = nonStatTrakInputs;
_statTrakInputs = statTrakInputs;
_outputPrices = outputPrices;
}
public OutputPriceBook OutputPrices => _outputPrices;
/// <summary>All purchasable input copies of <paramref name="skinId"/> in the given universe.</summary>
public IReadOnlyList<InputListing> InputsFor(int skinId, bool statTrak)
{
var pool = statTrak ? _statTrakInputs : _nonStatTrakInputs;
return pool.TryGetValue(skinId, out var listings) ? listings : Array.Empty<InputListing>();
}
public static TradeupListingData Build(IEnumerable<TradeupListingRow> rows)
{
var nonStInputs = new Dictionary<int, List<InputListing>>();
var stInputs = new Dictionary<int, List<InputListing>>();
var book = new OutputPriceBook();
foreach (var row in rows)
{
if (row.FloatValue is not { } floatValue || row.Price <= 0m)
{
continue;
}
// Input side: a copy can be used as input regardless of souvenir flag; only the
// ST flag splits the two disjoint universes.
var inputs = row.IsStatTrak ? stInputs : nonStInputs;
if (!inputs.TryGetValue(row.SkinId, out var list))
{
list = new List<InputListing>();
inputs[row.SkinId] = list;
}
list.Add(new InputListing(
row.SkinId, row.MarketHashName, row.Marketplace,
row.InspectLink, row.ExternalId, floatValue, row.Price));
// Output side: a tradeup never produces a souvenir, so souvenir listings don't
// price an output.
if (!row.IsSouvenir)
{
book.Add(row.SkinId, row.IsStatTrak, WearBands.FromFloat(floatValue), row.Price);
}
}
return new TradeupListingData(nonStInputs, stInputs, book);
}
}
/// <summary>
/// Phase B artifact: the lowest active ask (and liquidity count) for each
/// (skin, StatTrak, wear band). A produced output is valued by looking up the band its
/// computed float falls into — a conservative, listing-grounded estimate that never
/// invents a premium for a float no one is currently selling near.
/// </summary>
public sealed class OutputPriceBook
{
private readonly Dictionary<(int SkinId, bool StatTrak), Dictionary<WearBand, MutableBand>> _bands = new();
// Skin's cheapest comparable listing across ALL wears — the conservative floor used when a
// single band is too thin to trust.
private readonly Dictionary<(int SkinId, bool StatTrak), MutableBand> _floor = new();
internal void Add(int skinId, bool statTrak, WearBand band, decimal price)
{
var key = (skinId, statTrak);
if (!_bands.TryGetValue(key, out var byBand))
{
byBand = new Dictionary<WearBand, MutableBand>();
_bands[key] = byBand;
}
byBand[band] = byBand.TryGetValue(band, out var entry)
? new MutableBand(Math.Min(entry.LowestAsk, price), entry.Liquidity + 1)
: new MutableBand(price, 1);
_floor[key] = _floor.TryGetValue(key, out var f)
? new MutableBand(Math.Min(f.LowestAsk, price), f.Liquidity + 1)
: new MutableBand(price, 1);
}
/// <summary>
/// The lowest ask for the given skin/ST in the wear band that <paramref name="outputFloat"/>
/// lands in, or null when nothing comparable is listed.
/// </summary>
public BandPrice? PriceAt(int skinId, bool statTrak, decimal outputFloat)
{
if (_bands.TryGetValue((skinId, statTrak), out var byBand)
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
{
return new BandPrice(entry.LowestAsk, entry.Liquidity);
}
return null;
}
/// <summary>
/// Resolves an output's value: the band price when the band is liquid enough
/// (≥ <paramref name="thinThreshold"/> listings); otherwise the skin's overall floor, since
/// a one- or two-listing band is dominated by outliers (e.g. a lone over-priced FN sitting
/// just past a wear boundary). The reported <see cref="ResolvedOutputPrice.BandLiquidity"/>
/// is always the band's own count, so a thin result still triggers live CSFloat re-pricing.
/// </summary>
public ResolvedOutputPrice Resolve(int skinId, bool statTrak, decimal outputFloat, int thinThreshold)
{
var key = (skinId, statTrak);
var bandLiquidity = 0;
if (_bands.TryGetValue(key, out var byBand)
&& byBand.TryGetValue(WearBands.FromFloat(outputFloat), out var entry))
{
bandLiquidity = entry.Liquidity;
if (entry.Liquidity >= thinThreshold)
{
return new ResolvedOutputPrice(entry.LowestAsk, bandLiquidity, OutputPriceBasis.Band);
}
}
// Thin (or empty) band — fall back to the skin's cheapest comparable listing.
if (_floor.TryGetValue(key, out var floor))
{
return new ResolvedOutputPrice(floor.LowestAsk, bandLiquidity, OutputPriceBasis.Floor);
}
return new ResolvedOutputPrice(null, bandLiquidity, OutputPriceBasis.None);
}
private readonly record struct MutableBand(decimal LowestAsk, int Liquidity);
}

View File

@@ -0,0 +1,38 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The exact float arithmetic of a CS2 tradeup. Kept pure and dependency-free so it
/// can be unit-tested in isolation and reused verbatim by any frontend.
/// <para>
/// The contract: each input float is normalised to its own skin's range FIRST, those
/// fractions are averaged, and the average is mapped onto the OUTPUT skin's range. The
/// output float depends only on the average input fraction — a single scalar — which
/// is what makes the search tractable (see the engine design notes).
/// </para>
/// </summary>
public static class TradeupMath
{
/// <summary>
/// Normalises an input float to the fraction of its own skin's wear range:
/// <c>(value min) / (max min)</c>, clamped to [0,1]. A zero-width range
/// (min == max) has no meaningful fraction and yields 0.
/// </summary>
public static decimal NormalizedFraction(decimal floatValue, decimal skinFloatMin, decimal skinFloatMax)
{
var span = skinFloatMax - skinFloatMin;
if (span <= 0m)
{
return 0m;
}
var fraction = (floatValue - skinFloatMin) / span;
return Math.Clamp(fraction, 0m, 1m);
}
/// <summary>
/// Maps an average input fraction onto an output skin's wear range to get the exact
/// float the tradeup would produce: <c>avgFraction × (max min) + min</c>.
/// </summary>
public static decimal OutputFloat(decimal averageFraction, decimal outputFloatMin, decimal outputFloatMax)
=> averageFraction * (outputFloatMax - outputFloatMin) + outputFloatMin;
}

View File

@@ -0,0 +1,278 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>One candidate input copy, with its normalised fraction precomputed.</summary>
public readonly record struct SelectableInput(decimal Fraction, InputListing Listing);
/// <summary>
/// A persistent (shared-tail) singly-linked list of chosen listings. Travels with each
/// DP state so the winning selection is reconstructed for free — no fragile back-pointer
/// walk over a mutated cost table.
/// </summary>
public sealed record PickNode(InputListing Listing, PickNode? Previous)
{
public IReadOnlyList<InputListing> ToList()
{
var items = new List<InputListing>();
for (var node = this; node is not null; node = node.Previous)
{
items.Add(node.Listing);
}
items.Reverse();
return items;
}
/// <summary>Exact total cost of the chosen copies (the DP minimises this in double).</summary>
public decimal TotalCost()
{
var total = 0m;
for (var node = this; node is not null; node = node.Previous)
{
total += node.Listing.Price;
}
return total;
}
}
/// <summary>
/// The result of the selection DP: for every reachable summed-fraction bucket, the
/// cheapest way to pick exactly <see cref="ContractSize"/> distinct input copies whose
/// average fraction rounds (conservatively, upward) to that bucket. The finder reads
/// this once and evaluates output revenue across every bucket, which is equivalent to
/// solving the cheapest-inputs knapsack at every wear-boundary breakpoint at once.
/// </summary>
public sealed class TradeupSelection
{
private readonly PickNode?[] _pickBySum;
private readonly decimal _bucketWidth;
internal TradeupSelection(PickNode?[] pickBySum, int contractSize, decimal bucketWidth)
{
_pickBySum = pickBySum;
ContractSize = contractSize;
_bucketWidth = bucketWidth;
}
public int ContractSize { get; }
/// <summary>
/// Every feasible full selection: the (conservative) average input fraction, the total
/// input cost, and the exact copies to buy.
/// </summary>
public IEnumerable<(decimal AverageFraction, decimal Cost, PickNode Picks)> Selections()
{
for (var sum = 0; sum < _pickBySum.Length; sum++)
{
if (_pickBySum[sum] is { } picks)
{
var averageFraction = sum * _bucketWidth / ContractSize;
yield return (averageFraction, picks.TotalCost(), picks);
}
}
}
}
/// <summary>
/// Solves "pick exactly N distinct listings minimising total price, for each attainable
/// summed-fraction level". A bounded knapsack over the discretised fraction: O(items × N
/// × buckets). Fractions are bucketed by rounding UP, so the reported average float is an
/// upper bound — the conservative direction (it can only make an output look worse, never
/// better). Cost is minimised in <c>double</c> for speed; the exact decimal cost is
/// recovered from the reconstructed picks.
/// </summary>
public static class TradeupSelector
{
public static TradeupSelection Solve(
IReadOnlyList<SelectableInput> pool,
int contractSize,
decimal bucketWidth)
{
if (contractSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(contractSize));
}
if (bucketWidth <= 0m)
{
throw new ArgumentOutOfRangeException(nameof(bucketWidth));
}
// Buckets 0..maxBucketPerItem (fraction is clamped to [0,1]); summed across the
// contract gives the DP's second dimension.
var maxBucketPerItem = (int)Math.Ceiling(1m / bucketWidth);
var maxSum = maxBucketPerItem * contractSize;
var items = Trim(pool, bucketWidth, maxBucketPerItem, contractSize);
// dp[c][s] = cheapest cost (double) to choose exactly c items whose bucket sum is s;
// dpPick carries the actual copies so the winner is reconstructed exactly.
var dpCost = new double[contractSize + 1, maxSum + 1];
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
for (var c = 0; c <= contractSize; c++)
{
for (var s = 0; s <= maxSum; s++)
{
dpCost[c, s] = double.PositiveInfinity;
}
}
dpCost[0, 0] = 0d;
foreach (var (bucket, listing, price) in items)
{
// Descending count + sum so each item is used at most once (0/1 knapsack).
for (var c = contractSize - 1; c >= 0; c--)
{
for (var s = maxSum - bucket; s >= 0; s--)
{
var baseCost = dpCost[c, s];
if (double.IsPositiveInfinity(baseCost))
{
continue;
}
var newCost = baseCost + price;
var ns = s + bucket;
if (newCost < dpCost[c + 1, ns])
{
dpCost[c + 1, ns] = newCost;
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
}
}
}
}
var pickBySum = new PickNode?[maxSum + 1];
for (var s = 0; s <= maxSum; s++)
{
pickBySum[s] = dpPick[contractSize, s];
}
return new TradeupSelection(pickBySum, contractSize, bucketWidth);
}
// Within a single fraction bucket only the cheapest `contractSize` copies can ever be
// part of an optimal selection, so drop the rest up front. This bounds the item count
// regardless of how deep a popular skin's order book is.
private static List<(int Bucket, InputListing Listing, double Price)> Trim(
IReadOnlyList<SelectableInput> pool,
decimal bucketWidth,
int maxBucketPerItem,
int contractSize)
{
var byBucket = new Dictionary<int, List<InputListing>>();
foreach (var item in pool)
{
var bucket = BucketOf(item.Fraction, bucketWidth, maxBucketPerItem);
if (!byBucket.TryGetValue(bucket, out var list))
{
list = new List<InputListing>();
byBucket[bucket] = list;
}
list.Add(item.Listing);
}
var trimmed = new List<(int, InputListing, double)>();
foreach (var (bucket, listings) in byBucket)
{
listings.Sort(static (a, b) => a.Price.CompareTo(b.Price));
var take = Math.Min(contractSize, listings.Count);
for (var i = 0; i < take; i++)
{
trimmed.Add((bucket, listings[i], (double)listings[i].Price));
}
}
return trimmed;
}
// Round the fraction UP to a bucket index (conservative: never understates output float).
private static int BucketOf(decimal fraction, decimal bucketWidth, int maxBucketPerItem)
{
var clamped = Math.Clamp(fraction, 0m, 1m);
var bucket = (int)Math.Ceiling(clamped / bucketWidth);
return Math.Clamp(bucket, 0, maxBucketPerItem);
}
/// <summary>
/// One candidate input copy for the multi-collection search: its (already bucketed) float
/// contribution and a per-item reward (its collection's average output value share minus
/// its price, at a fixed output-float target).
/// </summary>
public readonly record struct RewardItem(int Bucket, double Reward, InputListing Listing);
/// <summary>
/// Picks exactly <paramref name="contractSize"/> copies that MAXIMISE total reward subject
/// to the bucketed float sum not exceeding <paramref name="capBucket"/> (i.e. mean float
/// ≤ the target). This is the multi-collection core: with each item's reward set to its
/// collection's value share minus price, the optimum naturally mixes whichever collections
/// pay off — no collection-subset enumeration. Returns the chosen copies, or null if the
/// contract can't be filled within the cap.
/// </summary>
public static PickNode? SolveMaxReward(
IReadOnlyList<RewardItem> items, int contractSize, int capBucket)
{
if (contractSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(contractSize));
}
var maxSum = Math.Max(0, capBucket);
// dp[c][s] = max total reward for exactly c items with bucket sum s (≤ cap).
var dpReward = new double[contractSize + 1, maxSum + 1];
var dpPick = new PickNode?[contractSize + 1, maxSum + 1];
for (var c = 0; c <= contractSize; c++)
{
for (var s = 0; s <= maxSum; s++)
{
dpReward[c, s] = double.NegativeInfinity;
}
}
dpReward[0, 0] = 0d;
foreach (var (bucket, reward, listing) in items)
{
if (bucket > maxSum)
{
continue; // a single copy already over the cap can't be in any valid contract
}
for (var c = contractSize - 1; c >= 0; c--)
{
for (var s = maxSum - bucket; s >= 0; s--)
{
var baseReward = dpReward[c, s];
if (double.IsNegativeInfinity(baseReward))
{
continue;
}
var newReward = baseReward + reward;
var ns = s + bucket;
if (newReward > dpReward[c + 1, ns])
{
dpReward[c + 1, ns] = newReward;
dpPick[c + 1, ns] = new PickNode(listing, dpPick[c, s]);
}
}
}
}
PickNode? best = null;
var bestReward = double.NegativeInfinity;
for (var s = 0; s <= maxSum; s++)
{
if (dpReward[contractSize, s] > bestReward && dpPick[contractSize, s] is { } pick)
{
bestReward = dpReward[contractSize, s];
best = pick;
}
}
return best;
}
}

View File

@@ -0,0 +1,75 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The ordered weapon-skin rarity tiers that participate in a 10-input tradeup.
/// The ordinal value IS the tier order: a tradeup consumes 10 inputs of tier T and
/// produces an output at the next tier present in the same collection.
/// <para>
/// Only the six weapon tiers are modelled. The catalogue also carries
/// <c>Contraband</c> (the Howl) and <c>Extraordinary</c> (gloves), and knives are
/// stored as <c>Covert</c>; none of those are weapon tradeup tiers, so
/// <see cref="TryParse"/> reports them as "not a weapon tier" rather than mapping
/// them. See the eligibility rules in <see cref="TradeupGraphBuilder"/>.
/// </para>
/// </summary>
public enum WeaponRarity
{
Consumer = 1,
Industrial = 2,
MilSpec = 3,
Restricted = 4,
Classified = 5,
Covert = 6,
}
public static class WeaponRarityExtensions
{
/// <summary>
/// Maps a <c>skins.rarity</c> string literal to its weapon tier.
/// </summary>
/// <returns>
/// <c>true</c> with <paramref name="rarity"/> set when the literal is one of the
/// six weapon tiers; <c>false</c> for <c>Contraband</c>/<c>Extraordinary</c>
/// (valid catalogue rarities that are not weapon tradeup tiers).
/// </returns>
/// <exception cref="ArgumentException">
/// The literal is none of the known catalogue rarities. Thrown deliberately so a
/// catalogue rename surfaces loudly instead of silently dropping a whole tier.
/// </exception>
public static bool TryParse(string rarity, out WeaponRarity result)
{
switch (rarity)
{
case "Consumer Grade":
result = WeaponRarity.Consumer;
return true;
case "Industrial Grade":
result = WeaponRarity.Industrial;
return true;
case "Mil-Spec Grade":
result = WeaponRarity.MilSpec;
return true;
case "Restricted":
result = WeaponRarity.Restricted;
return true;
case "Classified":
result = WeaponRarity.Classified;
return true;
case "Covert":
result = WeaponRarity.Covert;
return true;
// Known, valid catalogue rarities that are not weapon tradeup tiers.
case "Contraband": // The Howl
case "Extraordinary": // Gloves
result = default;
return false;
default:
throw new ArgumentException(
$"Unknown skin rarity literal '{rarity}'. The catalogue may have renamed a "
+ "rarity; update WeaponRarityExtensions.TryParse so a tier isn't silently dropped.",
nameof(rarity));
}
}
}

View File

@@ -0,0 +1,58 @@
namespace BlueLaminate.Core.Tradeups;
/// <summary>
/// The five CS2 wear bands, defined by absolute float thresholds (independent of any
/// individual skin's float range). A produced item's band — and therefore which
/// listings it competes with — is read straight off its absolute float.
/// </summary>
public enum WearBand
{
FactoryNew,
MinimalWear,
FieldTested,
WellWorn,
BattleScarred,
}
public static class WearBands
{
// Upper-exclusive boundaries: FN [0,0.07) MW [0.07,0.15) FT [0.15,0.38)
// WW [0.38,0.45) BS [0.45,1.0].
public const decimal MinimalWearFloor = 0.07m;
public const decimal FieldTestedFloor = 0.15m;
public const decimal WellWornFloor = 0.38m;
public const decimal BattleScarredFloor = 0.45m;
/// <summary>The wear band an absolute float value falls into.</summary>
public static WearBand FromFloat(decimal floatValue) => floatValue switch
{
< MinimalWearFloor => WearBand.FactoryNew,
< FieldTestedFloor => WearBand.MinimalWear,
< WellWornFloor => WearBand.FieldTested,
< BattleScarredFloor => WearBand.WellWorn,
_ => WearBand.BattleScarred,
};
/// <summary>The absolute float range [min, max) that defines this band — used to scope a
/// CSFloat query to the band the produced output lands in.</summary>
public static (decimal Min, decimal Max) Bounds(this WearBand band) => band switch
{
WearBand.FactoryNew => (0.00m, MinimalWearFloor),
WearBand.MinimalWear => (MinimalWearFloor, FieldTestedFloor),
WearBand.FieldTested => (FieldTestedFloor, WellWornFloor),
WearBand.WellWorn => (WellWornFloor, BattleScarredFloor),
WearBand.BattleScarred => (BattleScarredFloor, 1.00m),
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
};
/// <summary>The full wear name as it appears in listing data ("Factory New", …).</summary>
public static string ToName(this WearBand band) => band switch
{
WearBand.FactoryNew => "Factory New",
WearBand.MinimalWear => "Minimal Wear",
WearBand.FieldTested => "Field-Tested",
WearBand.WellWorn => "Well-Worn",
WearBand.BattleScarred => "Battle-Scarred",
_ => throw new ArgumentOutOfRangeException(nameof(band), band, null),
};
}

View File

@@ -1,7 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -9,19 +8,29 @@
</PropertyGroup>
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
<!-- Design-time only (the IDesignTimeDbContextFactory reads it for `dotnet ef`).
Kept in the build output but NOT published: otherwise it flows transitively
into a consumer's publish (e.g. BlueLaminate.C2) and collides with that
project's own appsettings.json (NETSDK1152). -->
<None Update="appsettings.json"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="Never" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8">
<PackageReference Include="EFCore.NamingConventions" />
<!-- Pin the runtime EF Core version so it flows transitively to consumers
(the Design package is PrivateAssets=all and won't). Keeps the version
the library compiles against in sync with what the CLI links. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class CollectionConfiguration : IEntityTypeConfiguration<Collection>
{
public void Configure(EntityTypeBuilder<Collection> entity)
{
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
}
}

View File

@@ -0,0 +1,47 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class CsMoneyListingConfiguration : IEntityTypeConfiguration<CsMoneyListing>
{
public void Configure(EntityTypeBuilder<CsMoneyListing> entity)
{
// cs.money's sell-order id is the natural key; ingest upserts against it and
// must never create duplicates.
entity.HasIndex(e => e.SellOrderId).IsUnique();
entity.Property(e => e.Price).HasPrecision(18, 2);
entity.Property(e => e.PriceBeforeDiscount).HasPrecision(18, 2);
entity.Property(e => e.ComputedPrice).HasPrecision(18, 2);
// Full precision to match SkinInstance for exact fingerprint joins.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Enum as text so the DB is self-describing (matches the project's leaning).
entity.Property(e => e.Status).HasConversion<string>();
// Targeted scrape: results are filtered/sorted by skin+wear and by activity.
entity.HasIndex(e => new { e.SkinId, e.ConditionId });
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.AssetId);
// Each job targets a known skin, so this link is required (Restrict: a skin
// with live listings shouldn't be deleted out from under them).
entity.HasOne(e => e.Skin)
.WithMany()
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Condition)
.WithMany()
.HasForeignKey(e => e.ConditionId)
.OnDelete(DeleteBehavior.SetNull);
// Listings roll up to the physical item they represent (shared with CSFloat).
entity.HasOne(e => e.SkinInstance)
.WithMany()
.HasForeignKey(e => e.SkinInstanceId)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -8,7 +8,8 @@ public class InventoryItemConfiguration : IEntityTypeConfiguration<InventoryItem
{
public void Configure(EntityTypeBuilder<InventoryItem> entity)
{
entity.HasIndex(e => e.AssetId);
// A Steam asset id identifies one physical copy; never store it twice.
entity.HasIndex(e => e.AssetId).IsUnique();
entity.HasOne(e => e.User)
.WithMany(u => u.InventoryItems)

View File

@@ -0,0 +1,51 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class ListingConfiguration : IEntityTypeConfiguration<Listing>
{
public void Configure(EntityTypeBuilder<Listing> entity)
{
// CSFloat's listing id is the natural key; the incremental sweep upserts
// against it and must never create duplicates.
entity.HasIndex(e => e.CsFloatListingId).IsUnique();
entity.Property(e => e.Price).HasPrecision(18, 2);
// Full precision to match SkinInstance for exact fingerprint joins.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Store the enum as text so the DB is self-describing (matches the
// project's readable-data leaning over opaque ints).
entity.Property(e => e.Status).HasConversion<string>();
// The sweep filters/sorts by item identity and by what's still active.
entity.HasIndex(e => new { e.DefIndex, e.PaintIndex });
entity.HasIndex(e => e.Status);
// Best-effort catalogue link: a global sweep sees items we may not have,
// so the FK is optional and set null if the skin is later removed.
entity.HasOne(e => e.Skin)
.WithMany()
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.SetNull);
// Wear band the sweep targeted (set directly from the sweep unit, not
// best-effort). Set null on delete so a condition row can change without
// blocking its listings — matching the cs.money/skin.land tables.
entity.HasOne(e => e.Condition)
.WithMany()
.HasForeignKey(e => e.ConditionId)
.OnDelete(DeleteBehavior.SetNull);
// Listings roll up to the physical item they represent.
entity.HasOne(e => e.SkinInstance)
.WithMany(i => i.Listings)
.HasForeignKey(e => e.SkinInstanceId)
.OnDelete(DeleteBehavior.SetNull);
// Dupe analysis groups a fingerprint's listings by asset id.
entity.HasIndex(e => e.AssetId);
}
}

View File

@@ -0,0 +1,17 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class MarketListingConfiguration : IEntityTypeConfiguration<MarketListing>
{
public void Configure(EntityTypeBuilder<MarketListing> entity)
{
// Backed by the market_listings SQL view (created in a migration), not a
// table — so it's keyless and read-only through EF.
entity.HasNoKey();
entity.ToView("market_listings", SkinTrackerDbContext.Schema);
}
}

View File

@@ -0,0 +1,14 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class ScrapeRunConfiguration : IEntityTypeConfiguration<ScrapeRun>
{
public void Configure(EntityTypeBuilder<ScrapeRun> entity)
{
// The throttle check looks up the most recent run for a given source.
entity.HasIndex(e => new { e.Source, e.RanAt });
}
}

View File

@@ -8,8 +8,11 @@ public class SkinConditionConfiguration : IEntityTypeConfiguration<SkinCondition
{
public void Configure(EntityTypeBuilder<SkinCondition> entity)
{
entity.Property(e => e.MinFloat).HasColumnType("numeric(10,9)");
entity.Property(e => e.MaxFloat).HasColumnType("numeric(10,9)");
entity.Property(e => e.FloatMin).HasColumnType("numeric(10,9)");
entity.Property(e => e.FloatMax).HasColumnType("numeric(10,9)");
// Per-site "last swept" checkpoints live in skin_condition_sweeps (one row per
// site); see SkinConditionSweepConfiguration for the indexes that order them.
entity.HasOne(e => e.Skin)
.WithMany(s => s.Conditions)

View File

@@ -0,0 +1,24 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class SkinConditionSweepConfiguration : IEntityTypeConfiguration<SkinConditionSweep>
{
public void Configure(EntityTypeBuilder<SkinConditionSweep> entity)
{
// One checkpoint per band per site: the natural key, and what the upsert
// ("stamp") in SweepCheckpoints relies on.
entity.HasIndex(e => new { e.SkinConditionId, e.Source }).IsUnique();
// Each site's sweep orders its bands never-swept-first then stalest; index the
// ordering it scans (filter by source, sort by swept_at).
entity.HasIndex(e => new { e.Source, e.SweptAt });
entity.HasOne(e => e.SkinCondition)
.WithMany(c => c.Sweeps)
.HasForeignKey(e => e.SkinConditionId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -8,20 +8,38 @@ public class SkinConfiguration : IEntityTypeConfiguration<Skin>
{
public void Configure(EntityTypeBuilder<Skin> entity)
{
entity.Property(e => e.FloatMin)
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m);
entity.Property(e => e.FloatMax)
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m);
// Nullable: null means the catalogue gives no wear range (e.g. vanilla
// knives), distinct from a genuine 0.01.0 range.
entity.Property(e => e.FloatMin).HasColumnType("numeric(10,9)");
entity.Property(e => e.FloatMax).HasColumnType("numeric(10,9)");
entity.Property(e => e.TrueFloat)
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", stored: true);
entity.HasIndex(e => e.TrueFloat);
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
// Market listings join back to a skin by (def_index, paint_index). Unique
// among populated rows; filtered so the many catalogue rows that predate
// these columns (null) don't collide. Postgres treats nulls as distinct
// anyway, but the filter makes the intent explicit and the index smaller.
entity.HasIndex(e => new { e.DefIndex, e.PaintIndex })
.IsUnique()
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
// Per-site "last swept" checkpoints live in skin_sweeps (one row per site);
// see SkinSweepConfiguration for the indexes that order them.
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
.HasForeignKey(e => e.WeaponId);
// A skin can come from many collections and containers, and each of those
// holds many skins.
entity.HasMany(e => e.Collections)
.WithMany(c => c.Skins)
.UsingEntity(join => join.ToTable("skin_collections"));
}
}

View File

@@ -8,19 +8,34 @@ public class SkinInstanceConfiguration : IEntityTypeConfiguration<SkinInstance>
{
public void Configure(EntityTypeBuilder<SkinInstance> entity)
{
entity.Property(e => e.FloatValue).HasColumnType("numeric(10,9)");
// Full precision so exact-match dupe detection isn't defeated by rounding.
// CSFloat returns deterministic ~17-digit floats; numeric(20,18) holds them.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Primary lookup key for trade fingerprinting.
entity.HasIndex(e => e.FloatValue);
entity.HasIndex(e => e.PaintSeed);
// The fingerprint that identifies a physical item. NOT unique: duped items
// legitimately share a fingerprint, and detecting that collision is the
// point. Indexed for fast fingerprint resolution during the sweep.
entity.HasIndex(e => new
{
e.SkinId,
e.FloatValue,
e.PaintSeed,
e.StatTrak,
e.Souvenir,
});
// Surfacing fresh dupes is a hot query.
entity.HasIndex(e => e.SuspectedDupe);
entity.HasOne(e => e.Skin)
.WithMany(s => s.Instances)
.HasForeignKey(e => e.SkinId);
// Condition is optional now (derived from float later); set null on delete
// rather than restrict so condition rows can change without blocking.
entity.HasOne(e => e.Condition)
.WithMany(c => c.Instances)
.HasForeignKey(e => e.ConditionId)
.OnDelete(DeleteBehavior.Restrict);
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,39 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class SkinLandListingConfiguration : IEntityTypeConfiguration<SkinLandListing>
{
public void Configure(EntityTypeBuilder<SkinLandListing> entity)
{
// skin.land's offer id is the natural key; ingest upserts against it and must
// never create duplicates.
entity.HasIndex(e => e.ListingId).IsUnique();
entity.Property(e => e.Price).HasPrecision(18, 2);
// Full precision (matches SkinInstance/cs.money) even though skin.land offers
// aren't fingerprinted — keep the float lossless for later analysis.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Enum as text so the DB is self-describing (matches the project's leaning).
entity.Property(e => e.Status).HasConversion<string>();
// Targeted scrape: results are filtered/sorted by skin+wear and by activity.
entity.HasIndex(e => new { e.SkinId, e.ConditionId });
entity.HasIndex(e => e.Status);
// Each job targets a known skin, so this link is required (Restrict: a skin with
// live listings shouldn't be deleted out from under them).
entity.HasOne(e => e.Skin)
.WithMany()
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Condition)
.WithMany()
.HasForeignKey(e => e.ConditionId)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,22 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class SkinSweepConfiguration : IEntityTypeConfiguration<SkinSweep>
{
public void Configure(EntityTypeBuilder<SkinSweep> entity)
{
// One checkpoint per skin per site: the natural key the upsert relies on.
entity.HasIndex(e => new { e.SkinId, e.Source }).IsUnique();
// Mirror SkinConditionSweep: index the (source, swept_at) ordering each sweep scans.
entity.HasIndex(e => new { e.Source, e.SweptAt });
entity.HasOne(e => e.Skin)
.WithMany(s => s.Sweeps)
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -8,6 +8,10 @@ public class TradeConfiguration : IEntityTypeConfiguration<Trade>
{
public void Configure(EntityTypeBuilder<Trade> entity)
{
// Steam's trade id is the natural key for an observed trade. Nullable (some
// trades are reconstructed without one); Postgres keeps multiple NULLs distinct.
entity.HasIndex(e => e.SteamTradeId).IsUnique();
entity.HasOne(e => e.FromUser)
.WithMany(u => u.TradesSent)
.HasForeignKey(e => e.FromUserId)

View File

@@ -0,0 +1,14 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class WeaponConfiguration : IEntityTypeConfiguration<Weapon>
{
public void Configure(EntityTypeBuilder<Weapon> entity)
{
// Name is the natural key the scraper upserts against.
entity.HasIndex(e => e.Name).IsUnique();
}
}

View File

@@ -19,14 +19,24 @@ public class SkinTrackerDbContext : DbContext
}
public DbSet<Weapon> Weapons => Set<Weapon>();
public DbSet<ScrapeRun> ScrapeRuns => Set<ScrapeRun>();
public DbSet<Collection> Collections => Set<Collection>();
public DbSet<Skin> Skins => Set<Skin>();
public DbSet<SkinCondition> SkinConditions => Set<SkinCondition>();
public DbSet<SkinSweep> SkinSweeps => Set<SkinSweep>();
public DbSet<SkinConditionSweep> SkinConditionSweeps => Set<SkinConditionSweep>();
public DbSet<SteamUser> SteamUsers => Set<SteamUser>();
public DbSet<SkinInstance> SkinInstances => Set<SkinInstance>();
public DbSet<InventoryItem> InventoryItems => Set<InventoryItem>();
public DbSet<Trade> Trades => Set<Trade>();
public DbSet<TradeItem> TradeItems => Set<TradeItem>();
public DbSet<PriceHistory> PriceHistories => Set<PriceHistory>();
public DbSet<Listing> Listings => Set<Listing>();
public DbSet<CsMoneyListing> CsMoneyListings => Set<CsMoneyListing>();
public DbSet<SkinLandListing> SkinLandListings => Set<SkinLandListing>();
/// <summary>Read-only cross-market view UNIONing the per-market listing tables.</summary>
public DbSet<MarketListing> MarketListings => Set<MarketListing>();
/// <summary>The PostgreSQL schema that owns all of this context's tables.</summary>
public const string Schema = "skintracker";
@@ -35,13 +45,22 @@ public class SkinTrackerDbContext : DbContext
{
modelBuilder.HasDefaultSchema(Schema);
modelBuilder.ApplyConfiguration(new WeaponConfiguration());
modelBuilder.ApplyConfiguration(new ScrapeRunConfiguration());
modelBuilder.ApplyConfiguration(new CollectionConfiguration());
modelBuilder.ApplyConfiguration(new SkinConfiguration());
modelBuilder.ApplyConfiguration(new SkinConditionConfiguration());
modelBuilder.ApplyConfiguration(new SkinSweepConfiguration());
modelBuilder.ApplyConfiguration(new SkinConditionSweepConfiguration());
modelBuilder.ApplyConfiguration(new SteamUserConfiguration());
modelBuilder.ApplyConfiguration(new SkinInstanceConfiguration());
modelBuilder.ApplyConfiguration(new InventoryItemConfiguration());
modelBuilder.ApplyConfiguration(new TradeConfiguration());
modelBuilder.ApplyConfiguration(new TradeItemConfiguration());
modelBuilder.ApplyConfiguration(new PriceHistoryConfiguration());
modelBuilder.ApplyConfiguration(new ListingConfiguration());
modelBuilder.ApplyConfiguration(new CsMoneyListingConfiguration());
modelBuilder.ApplyConfiguration(new SkinLandListingConfiguration());
modelBuilder.ApplyConfiguration(new MarketListingConfiguration());
}
}

View File

@@ -0,0 +1,64 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
namespace BlueLaminate.EFCore.Data;
/// <summary>
/// Write helpers for the per-site sweep checkpoints (<see cref="SkinSweep"/> /
/// <see cref="SkinConditionSweep"/>). Each marketplace sweeper stamps its own row
/// keyed by <c>(entity, source)</c>, so a band swept on one site is still "never
/// swept" on another. Adding a new site means a new <see cref="SweepSource"/>
/// constant — no schema changes.
/// <para>
/// Reads stay inline in the sweep queries (a correlated subquery over the navigation
/// for the relevant <c>Source</c>) so EF can translate and order by them server-side.
/// </para>
/// </summary>
public static class SweepCheckpoints
{
/// <summary>
/// Record that <paramref name="source"/> just swept this wear band. Upserts the
/// single (condition, source) row via the change tracker; the caller persists with
/// <see cref="DbContext.SaveChangesAsync"/>.
/// </summary>
public static async Task StampConditionAsync(
SkinTrackerDbContext db, int conditionId, string source, DateTimeOffset sweptAt, CancellationToken ct)
{
var existing = await db.SkinConditionSweeps
.FirstOrDefaultAsync(s => s.SkinConditionId == conditionId && s.Source == source, ct);
if (existing is null)
{
db.SkinConditionSweeps.Add(new SkinConditionSweep
{
SkinConditionId = conditionId,
Source = source,
SweptAt = sweptAt,
});
}
else
{
existing.SweptAt = sweptAt;
}
}
/// <summary>As <see cref="StampConditionAsync"/>, for a whole-skin unit (no wear bands).</summary>
public static async Task StampSkinAsync(
SkinTrackerDbContext db, int skinId, string source, DateTimeOffset sweptAt, CancellationToken ct)
{
var existing = await db.SkinSweeps
.FirstOrDefaultAsync(s => s.SkinId == skinId && s.Source == source, ct);
if (existing is null)
{
db.SkinSweeps.Add(new SkinSweep
{
SkinId = skinId,
Source = source,
SweptAt = sweptAt,
});
}
else
{
existing.SweptAt = sweptAt;
}
}
}

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// A source a skin originates from: either an in-game collection (e.g.
/// "The Dead Hand Collection") or a container/case (e.g. "Glove Case").
/// </summary>
public class Collection
{
public int Id { get; set; }
public string Name { get; set; } = null!;
/// <summary>Stable id from the CSGO-API catalogue, e.g. "collection-set-community-37"
/// or "crate-4288". The natural key.</summary>
public string Slug { get; set; } = null!;
/// <summary>"Collection" or "Container".</summary>
public string Type { get; set; } = null!;
public ICollection<Skin> Skins { get; set; } = new List<Skin>();
}

View File

@@ -0,0 +1,67 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One sell-order observed on cs.money via its internal
/// <c>GET /2.0/market/sell-orders</c> endpoint (scraped through the Python worker,
/// since cs.money has no public API and sits behind Cloudflare).
/// <para>
/// Kept in its own table rather than shared with the CSFloat <see cref="Listing"/>:
/// cs.money exposes a different shape (its own sell-order id, a pricing breakdown,
/// <c>quality</c>/phase, and no def/paint index). It still links to the
/// market-agnostic <see cref="SkinInstance"/> by fingerprint, so the same physical
/// item seen on both markets rolls up to one instance for cross-market analysis.
/// </para>
/// Soft-tracked across sweeps exactly like <see cref="Listing"/>:
/// <see cref="FirstSeenAt"/>/<see cref="LastSeenAt"/> bound the observation window
/// and <see cref="Status"/> flips to <see cref="ListingStatus.Removed"/> when a
/// once-seen order stops appearing (sold/delisted).
/// </summary>
public class CsMoneyListing
{
public int Id { get; set; }
/// <summary>cs.money's sell-order id (item.id). Natural key for dedup.</summary>
public long SellOrderId { get; set; }
/// <summary>
/// cs.money's asset id for the listed copy. Not a stable identity, but the
/// discriminator that distinguishes duped copies sharing one fingerprint.
/// </summary>
public string? AssetId { get; set; }
// Catalogue links. Unlike the CSFloat global sweep these are NOT best-effort:
// each scrape job targets one skin+wear, so the worker reports which Skin/
// Condition the results belong to and we set them directly.
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
/// <summary>The physical item (by fingerprint), shared with CSFloat listings.</summary>
public int? SkinInstanceId { get; set; }
public SkinInstance? SkinInstance { get; set; }
// Item identity, from the listing's asset block.
public string MarketHashName { get; set; } = null!;
public string? Quality { get; set; } // cs.money wear short code: fn/mw/ft/ww/bs
public decimal? FloatValue { get; set; } // null for non-skin items
public int? PaintSeed { get; set; } // asset.pattern
public string? Phase { get; set; } // doppler phase (sapphire/ruby/…)
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public int StickerCount { get; set; }
// Pricing. cs.money returns a breakdown; Price is the actual asking price.
public decimal Price { get; set; } // pricing.default
public decimal? PriceBeforeDiscount { get; set; }
public decimal? ComputedPrice { get; set; } // pricing.computed (reference price)
public string Currency { get; set; } = "USD"; // cs.money returns no currency field
public string? InspectLink { get; set; }
// Soft-tracking across sweeps.
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public ListingStatus Status { get; set; }
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -0,0 +1,104 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>Lifecycle of a CSFloat listing as observed across sweeps.</summary>
public enum ListingStatus
{
/// <summary>Seen in the most recent sweep that covered it.</summary>
Active = 0,
/// <summary>
/// Previously seen, then absent from a sweep that should have covered it —
/// i.e. sold or delisted. The disappearance is the signal; we can't tell sold
/// from delisted with certainty, but <see cref="Listing.LastSeenAt"/> bounds when.
/// </summary>
Removed = 1,
}
/// <summary>
/// One active-market listing observed on CSFloat via the official
/// <c>GET /api/v1/listings</c> endpoint. Rows are keyed by CSFloat's own listing
/// id and soft-tracked across sweeps: <see cref="FirstSeenAt"/>/<see cref="LastSeenAt"/>
/// bound the observation window and <see cref="Status"/> flips to
/// <see cref="ListingStatus.Removed"/> when a once-seen listing stops appearing,
/// which approximates a sale/delisting.
///
/// A global sweep returns items that may not be in our catalogue, so
/// <see cref="SkinId"/> is a best-effort nullable link (resolved by
/// def_index + paint_index); the listing stands on its own without it.
/// </summary>
public class Listing
{
public int Id { get; set; }
/// <summary>CSFloat's listing id (a snowflake string). Natural key for dedup.</summary>
public string CsFloatListingId { get; set; } = null!;
/// <summary>"buy_now" or "auction".</summary>
public string Type { get; set; } = null!;
/// <summary>Asking price.</summary>
public decimal Price { get; set; }
/// <summary>Currency of <see cref="Price"/>. CSFloat lists in USD.</summary>
public string Currency { get; set; } = "USD";
/// <summary>When CSFloat says the listing was created.</summary>
public DateTimeOffset ListedAt { get; set; }
// Item identity. Stored directly (not only via the Skin link) so listings for
// items outside our catalogue are still fully described.
public int DefIndex { get; set; }
public int PaintIndex { get; set; }
public string MarketHashName { get; set; } = null!;
public string? WearName { get; set; }
/// <summary>
/// Exact float, or null for items with no float at all (e.g. Vanilla knives).
/// Null is deliberately distinct from a genuine 0.0 float; a floatless item
/// also can't be fingerprinted, so its <see cref="SkinInstanceId"/> stays null.
/// </summary>
public decimal? FloatValue { get; set; }
public int PaintSeed { get; set; }
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public int StickerCount { get; set; }
public string? SellerSteamId { get; set; }
public string? InspectLink { get; set; }
/// <summary>
/// Steam asset id of the listed copy. Changes on trade, so not a stable
/// identity — but the discriminator that distinguishes duped copies which
/// otherwise share an identical fingerprint.
/// </summary>
public string? AssetId { get; set; }
/// <summary>Best-effort catalogue link, resolved by def_index + paint_index. Null if unmatched.</summary>
public int? SkinId { get; set; }
public Skin? Skin { get; set; }
/// <summary>
/// The wear band this listing belongs to. Unlike <see cref="SkinId"/> this is NOT
/// best-effort: the catalogue sweep pages one skin+wear band at a time, so the band
/// is set directly from the sweep unit. Null for whole-skin sweeps (e.g. vanilla
/// knives with no wear bands).
/// </summary>
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
/// <summary>
/// The physical item (by fingerprint) this listing is for. Many listings over
/// time roll up to one instance, forming its market-movement history. Nullable
/// because catalogue-less items can't be fingerprinted to a known skin.
/// </summary>
public int? SkinInstanceId { get; set; }
public SkinInstance? SkinInstance { get; set; }
// Soft-tracking across sweeps.
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public ListingStatus Status { get; set; }
/// <summary>When the listing was marked Removed (absent from a sweep). Null while Active.</summary>
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -0,0 +1,45 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// Read-model over the <c>market_listings</c> SQL view, which UNIONs every per-market
/// listing table (CSFloat <see cref="Listing"/>, <see cref="CsMoneyListing"/>, and any
/// future market) tagged with its <see cref="Marketplace"/>. This is how we answer
/// "where is this listed?" — by <see cref="SkinInstanceId"/> for one physical copy,
/// or by <see cref="SkinId"/> for a skin — without merging the source tables.
/// <para>Keyless: it's a view, never inserted/updated through EF.</para>
/// </summary>
public class MarketListing
{
/// <summary>Which market this row came from: "csfloat", "csmoney", …</summary>
public string Marketplace { get; set; } = null!;
/// <summary>The source market's own listing id (as text), for traceability.</summary>
public string ExternalId { get; set; } = null!;
public int? SkinId { get; set; }
public int? ConditionId { get; set; }
/// <summary>The market-agnostic physical item — the key that bridges markets.</summary>
public int? SkinInstanceId { get; set; }
public string MarketHashName { get; set; } = null!;
public string? Wear { get; set; }
public decimal? FloatValue { get; set; }
public int? PaintSeed { get; set; }
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public int StickerCount { get; set; }
public decimal Price { get; set; }
public string Currency { get; set; } = null!;
public string? InspectLink { get; set; }
public string? AssetId { get; set; }
/// <summary>"Active" or "Removed" (text, from each source's status).</summary>
public string Status { get; set; } = null!;
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One successful run of a data-scraping job. Used to throttle jobs that
/// should run infrequently (e.g. the weapon catalogue, which changes rarely).
/// Only successful runs are recorded, so a failed run never blocks a retry.
/// </summary>
public class ScrapeRun
{
public int Id { get; set; }
/// <summary>Identifies which job this run belongs to, e.g. "weapons".</summary>
public string Source { get; set; } = null!;
public DateTimeOffset RanAt { get; set; }
/// <summary>How many records the run inserted or updated.</summary>
public int ItemCount { get; set; }
}

View File

@@ -6,19 +6,44 @@ public class Skin
public int WeaponId { get; set; }
public Weapon Weapon { get; set; } = null!;
/// <summary>Stable id from the CSGO-API catalogue, e.g. "skin-e757fd7191f9". The natural key.</summary>
public string Slug { get; set; } = null!;
// CSFloat/CS item indexes, sourced from the static catalogue (weapon.weapon_id
// and paint_index). Together they identify a skin on CSFloat and let market
// listings join back to this catalogue row. Nullable until a sync populates
// them, since older catalogue rows predate these columns.
public int? DefIndex { get; set; }
public int? PaintIndex { get; set; }
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }
public string? ImageUrl { get; set; }
public decimal FloatMin { get; set; }
public decimal FloatMax { get; set; }
public bool StatTrakAvailable { get; set; }
public bool SouvenirAvailable { get; set; }
// Computed in the database: float_min = 0.0 AND float_max = 1.0.
// A skin with a capped float range behaves differently in tradeup calculations.
public bool TrueFloat { get; private set; }
/// <summary>Every collection and container this skin originates from.</summary>
public ICollection<Collection> Collections { get; set; } = new List<Collection>();
// Null when the catalogue gives no wear range (e.g. vanilla knives). Callers
// must treat null as "unknown", not as a full 0.01.0 range.
public decimal? FloatMin { get; set; }
public decimal? FloatMax { get; set; }
// Computed in the database: float_min = 0.0 AND float_max = 1.0; null while the
// bounds are unknown. A skin with a capped float range behaves differently in
// tradeup calculations.
public bool? TrueFloat { get; private set; }
public ICollection<SkinCondition> Conditions { get; set; } = new List<SkinCondition>();
// Per-site "last swept" checkpoints for the whole-skin sweep unit — only used for
// skins with no wear bands (the per-band checkpoint lives on SkinCondition.Sweeps).
// The sweep processes never-swept (no row) / stalest skins first. See SkinSweep.
public ICollection<SkinSweep> Sweeps { get; set; } = new List<SkinSweep>();
public ICollection<SkinInstance> Instances { get; set; } = new List<SkinInstance>();
public ICollection<PriceHistory> PriceHistories { get; set; } = new List<PriceHistory>();
}

View File

@@ -7,8 +7,15 @@ public class SkinCondition
public Skin Skin { get; set; } = null!;
public string Condition { get; set; } = null!;
public decimal MinFloat { get; set; }
public decimal MaxFloat { get; set; }
public decimal FloatMin { get; set; }
public decimal FloatMax { get; set; }
// Per-site "last swept" checkpoints for this wear band — one row per marketplace
// (Source). The sweep splits each skin by wear and pages one band at a time, so
// this is the per-band checkpoint: an interrupted run resumes from never-swept
// (no row) / stalest bands rather than redoing a whole skin. Tracked per site so a
// band swept on CSFloat is still never-swept on cs.money. See SkinConditionSweep.
public ICollection<SkinConditionSweep> Sweeps { get; set; } = new List<SkinConditionSweep>();
public ICollection<SkinInstance> Instances { get; set; } = new List<SkinInstance>();
public ICollection<PriceHistory> PriceHistories { get; set; } = new List<PriceHistory>();

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One site's "last swept" checkpoint for a single wear band. The catalogue sweep
/// processes least-recently-swept bands first (no row = never swept), so capped/looping
/// runs chain across the catalogue and refresh the stalest data first. Keyed by
/// <c>(SkinConditionId, Source)</c> so each marketplace tracks its own progress
/// independently — a band swept on one site stays never-swept on another.
/// </summary>
public class SkinConditionSweep
{
public int Id { get; set; }
public int SkinConditionId { get; set; }
public SkinCondition SkinCondition { get; set; } = null!;
/// <summary>Which site swept it — a <see cref="SweepSource"/> value.</summary>
public string Source { get; set; } = null!;
public DateTimeOffset SweptAt { get; set; }
}

View File

@@ -1,20 +1,52 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One physical CS2 item, identified by its fingerprint
/// (Skin + FloatValue + PaintSeed + StatTrak + Souvenir) rather than its Steam
/// asset id, which changes on every trade. Decoupled from Steam inventories on
/// purpose: an instance exists from market observation alone, and the optional
/// <see cref="InventoryItem"/> bridge ties it to a <c>SteamUser</c> only once we
/// crawl inventories.
///
/// Duping note: a duplicated item is a byte-for-byte copy with an identical
/// fingerprint, so a fingerprint is NOT guaranteed unique to one physical item.
/// We treat the fingerprint as the item, and flag <see cref="SuspectedDupe"/>
/// when the same fingerprint is seen live under two or more different asset ids
/// at once (see the sweep's dupe detection).
/// </summary>
public class SkinInstance
{
public int Id { get; set; }
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
public int ConditionId { get; set; }
public SkinCondition Condition { get; set; } = null!;
// FloatValue + PaintSeed form a stable fingerprint across trades; the Steam
// asset_id changes on every trade but these do not.
// Nullable: market observation gives a float but not a derived wear bucket.
// Condition can be backfilled later from the float without blocking ingest.
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
// The fingerprint. FloatValue is stored at full precision (see config) so
// that exact-match dupe detection isn't fooled by rounding. An instance is
// only created for items that have a float + paint seed (skins), so both are
// non-null here even though some listings (e.g. vanilla knives) lack them.
public decimal FloatValue { get; set; }
public string PaintSeed { get; set; } = null!;
public int PaintSeed { get; set; }
public bool StatTrak { get; set; }
public bool Souvenir { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
/// <summary>
/// True once this fingerprint was observed live under 2+ distinct asset ids
/// simultaneously — the signature of duplication.
/// </summary>
public bool SuspectedDupe { get; set; }
/// <summary>When the dupe condition was first detected. Null until then.</summary>
public DateTimeOffset? DupeFirstSeenAt { get; set; }
/// <summary>Every market listing observed for this physical item over time.</summary>
public ICollection<Listing> Listings { get; set; } = new List<Listing>();
public ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
}

View File

@@ -0,0 +1,54 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One offer observed on skin.land via its internal
/// <c>GET /api/v2/obtained-skins?skin_id={id}&amp;page={n}</c> endpoint (scraped through
/// the Python worker, since skin.land has no public API and sits behind Cloudflare).
/// <para>
/// Kept in its own table like <see cref="CsMoneyListing"/>, but deliberately thinner:
/// skin.land exposes a full-precision float and price but <b>no paint seed / def index</b>,
/// so an offer can't be fingerprinted to a market-agnostic <see cref="SkinInstance"/> and
/// there is no cross-market roll-up or dupe detection here (revisit if pattern is ever
/// exposed). StatTrak and Souvenir live on <em>separate</em> skin.land pages (their own
/// <c>stattrak-</c>/<c>souvenir-</c> slugs); v1 sweeps the base page per skin+wear, so
/// <see cref="IsStatTrak"/>/<see cref="IsSouvenir"/> are normally false.
/// </para>
/// Soft-tracked across sweeps exactly like <see cref="CsMoneyListing"/>:
/// <see cref="FirstSeenAt"/>/<see cref="LastSeenAt"/> bound the observation window and
/// <see cref="Status"/> flips to <see cref="ListingStatus.Removed"/> when a once-seen
/// offer stops appearing (sold/delisted).
/// </summary>
public class SkinLandListing
{
public int Id { get; set; }
/// <summary>skin.land's offer id (obtained-skin <c>id</c>). Natural key for dedup.</summary>
public long ListingId { get; set; }
// Catalogue links. Like cs.money (and unlike the CSFloat global sweep) these are NOT
// best-effort: each scrape job targets one skin+wear, so we set them directly.
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
// Item identity, from the offer's skin block.
public string MarketHashName { get; set; } = null!;
public decimal? FloatValue { get; set; } // item_float (string, full precision)
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public string? NameTag { get; set; } // offer.name_tag (rare; affects value)
public int StickerCount { get; set; }
// Pricing. skin.land returns a single price (the amount to buy/withdraw the item).
public decimal Price { get; set; } // final_withdrawal_price
public string Currency { get; set; } = "USD"; // prices are read in USD
public string? InspectLink { get; set; } // item_link (steam:// inspect)
// Soft-tracking across sweeps.
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public ListingStatus Status { get; set; }
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One site's "last swept" checkpoint for a whole skin — used only for skins with no
/// wear bands (e.g. vanilla knives), which are swept as a single unit. The per-band
/// equivalent is <see cref="SkinConditionSweep"/>. Keyed by <c>(SkinId, Source)</c> so
/// each marketplace tracks its own progress independently.
/// </summary>
public class SkinSweep
{
public int Id { get; set; }
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
/// <summary>Which site swept it — a <see cref="SweepSource"/> value.</summary>
public string Source { get; set; } = null!;
public DateTimeOffset SweptAt { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// Canonical site identifiers for per-site sweep checkpoints — the <c>Source</c>
/// discriminator on <see cref="SkinSweep"/> and <see cref="SkinConditionSweep"/>.
/// Each marketplace sweeper stamps its own checkpoint under one of these, so a band
/// swept on one site is still "never swept" on another.
/// <para>
/// To add sweeping for a new marketplace, add one constant here and have that
/// sweeper read/stamp checkpoints with it — no schema or query changes needed.
/// </para>
/// </summary>
public static class SweepSource
{
/// <summary>CSFloat catalogue-driven sweep (<c>ListingSweepService.SweepCatalogAsync</c>).</summary>
public const string CsFloatCatalog = "listings-catalog";
/// <summary>cs.money worker sweep (<c>CsMoneyIngestService</c>).</summary>
public const string CsMoney = "csmoney";
/// <summary>skin.land worker sweep (<c>SkinLandIngestService</c>).</summary>
public const string SkinLand = "skinland";
}

View File

@@ -0,0 +1,609 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529182827_AddScrapeRunAndWeaponNameIndex")]
partial class AddScrapeRunAndWeaponNameIndex
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal>("FloatMax")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m)
.HasColumnName("float_max");
b.Property<decimal>("FloatMin")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m)
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<bool>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddScrapeRunAndWeaponNameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "scrape_runs",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
source = table.Column<string>(type: "text", nullable: false),
ran_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
item_count = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_scrape_runs", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_weapons_name",
schema: "skintracker",
table: "weapons",
column: "name",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_scrape_runs_source_ran_at",
schema: "skintracker",
table: "scrape_runs",
columns: new[] { "source", "ran_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "scrape_runs",
schema: "skintracker");
migrationBuilder.DropIndex(
name: "ix_weapons_name",
schema: "skintracker",
table: "weapons");
}
}
}

View File

@@ -0,0 +1,680 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529192841_AddSkinCatalogFields")]
partial class AddSkinCatalogFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CollectionId")
.HasColumnType("integer")
.HasColumnName("collection_id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal>("FloatMax")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(1.0m)
.HasColumnName("float_max");
b.Property<decimal>("FloatMin")
.ValueGeneratedOnAdd()
.HasColumnType("numeric(10,9)")
.HasDefaultValue(0.0m)
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("CollectionId")
.HasDatabaseName("ix_skins_collection_id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
.WithMany("Skins")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skins_collections_collection_id");
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Collection");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Navigation("Skins");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinCatalogFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "collection_id",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "slug",
schema: "skintracker",
table: "skins",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<bool>(
name: "souvenir_available",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "stat_trak_available",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "collections",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "text", nullable: false),
slug = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_collections", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id");
migrationBuilder.CreateIndex(
name: "ix_skins_slug",
schema: "skintracker",
table: "skins",
column: "slug",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_collections_slug",
schema: "skintracker",
table: "collections",
column: "slug",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id",
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropTable(
name: "collections",
schema: "skintracker");
migrationBuilder.DropIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropIndex(
name: "ix_skins_slug",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "slug",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "souvenir_available",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "stat_trak_available",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,676 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529200100_MakeSkinFloatsNullable")]
partial class MakeSkinFloatsNullable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CollectionId")
.HasColumnType("integer")
.HasColumnName("collection_id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("CollectionId")
.HasDatabaseName("ix_skins_collection_id");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", "Collection")
.WithMany("Skins")
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skins_collections_collection_id");
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Collection");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Navigation("Skins");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class MakeSkinFloatsNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<decimal>(
name: "float_min",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: true,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldDefaultValue: 0.0m);
migrationBuilder.AlterColumn<decimal>(
name: "float_max",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: true,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldDefaultValue: 1.0m);
migrationBuilder.AlterColumn<bool>(
name: "true_float",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: true,
computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
stored: true,
oldClrType: typeof(bool),
oldType: "boolean",
oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
oldStored: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<decimal>(
name: "float_min",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: false,
defaultValue: 0.0m,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "float_max",
schema: "skintracker",
table: "skins",
type: "numeric(10,9)",
nullable: false,
defaultValue: 1.0m,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)",
oldNullable: true);
migrationBuilder.AlterColumn<bool>(
name: "true_float",
schema: "skintracker",
table: "skins",
type: "boolean",
nullable: false,
computedColumnSql: "float_min = 0.0 AND float_max = 1.0",
stored: true,
oldClrType: typeof(bool),
oldType: "boolean",
oldNullable: true,
oldComputedColumnSql: "float_min = 0.0 AND float_max = 1.0",
oldStored: true);
}
}
}

View File

@@ -0,0 +1,692 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260529211544_UseStaticSkinCatalog")]
partial class UseStaticSkinCatalog
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class UseStaticSkinCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "collection_id",
schema: "skintracker",
table: "skins");
migrationBuilder.CreateTable(
name: "skin_collections",
schema: "skintracker",
columns: table => new
{
collections_id = table.Column<int>(type: "integer", nullable: false),
skins_id = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_skin_collections", x => new { x.collections_id, x.skins_id });
table.ForeignKey(
name: "fk_skin_collections_collections_collections_id",
column: x => x.collections_id,
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_skin_collections_skins_skins_id",
column: x => x.skins_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_skin_collections_skins_id",
schema: "skintracker",
table: "skin_collections",
column: "skins_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "skin_collections",
schema: "skintracker");
migrationBuilder.AddColumn<int>(
name: "collection_id",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skins_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id");
migrationBuilder.AddForeignKey(
name: "fk_skins_collections_collection_id",
schema: "skintracker",
table: "skins",
column: "collection_id",
principalSchema: "skintracker",
principalTable: "collections",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
}
}

View File

@@ -0,0 +1,829 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530014903_AddListingsAndSkinIndexes")]
partial class AddListingsAndSkinIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,126 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddListingsAndSkinIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "def_index",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "paint_index",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "listings",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
cs_float_listing_id = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
listed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
def_index = table.Column<int>(type: "integer", nullable: false),
paint_index = table.Column<int>(type: "integer", nullable: false),
market_hash_name = table.Column<string>(type: "text", nullable: false),
wear_name = table.Column<string>(type: "text", nullable: true),
float_value = table.Column<decimal>(type: "numeric(10,9)", nullable: false),
paint_seed = table.Column<int>(type: "integer", nullable: false),
is_stat_trak = table.Column<bool>(type: "boolean", nullable: false),
is_souvenir = table.Column<bool>(type: "boolean", nullable: false),
sticker_count = table.Column<int>(type: "integer", nullable: false),
seller_steam_id = table.Column<string>(type: "text", nullable: true),
inspect_link = table.Column<string>(type: "text", nullable: true),
skin_id = table.Column<int>(type: "integer", nullable: true),
first_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
removed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_listings", x => x.id);
table.ForeignKey(
name: "fk_listings_skins_skin_id",
column: x => x.skin_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_skins_def_index_paint_index",
schema: "skintracker",
table: "skins",
columns: new[] { "def_index", "paint_index" },
unique: true,
filter: "def_index IS NOT NULL AND paint_index IS NOT NULL");
migrationBuilder.CreateIndex(
name: "ix_listings_cs_float_listing_id",
schema: "skintracker",
table: "listings",
column: "cs_float_listing_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_listings_def_index_paint_index",
schema: "skintracker",
table: "listings",
columns: new[] { "def_index", "paint_index" });
migrationBuilder.CreateIndex(
name: "ix_listings_skin_id",
schema: "skintracker",
table: "listings",
column: "skin_id");
migrationBuilder.CreateIndex(
name: "ix_listings_status",
schema: "skintracker",
table: "listings",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "listings",
schema: "skintracker");
migrationBuilder.DropIndex(
name: "ix_skins_def_index_paint_index",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "def_index",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "paint_index",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,836 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530023217_AddSkinListingsSweptAt")]
partial class AddSkinListingsSweptAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinListingsSweptAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "listings_swept_at",
schema: "skintracker",
table: "skins",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins",
column: "listings_swept_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "listings_swept_at",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,868 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530030305_AddSkinInstanceDupeTrackingModelB")]
partial class AddSkinInstanceDupeTrackingModelB
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AssetId")
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<int?>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("AssetId")
.HasDatabaseName("ix_listings_asset_id");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_listings_skin_instance_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset?>("DupeFirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("dupe_first_seen_at");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.Property<bool>("SuspectedDupe")
.HasColumnType("boolean")
.HasColumnName("suspected_dupe");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("SuspectedDupe")
.HasDatabaseName("ix_skin_instances_suspected_dupe");
b.HasIndex("SkinId", "FloatValue", "PaintSeed", "StatTrak", "Souvenir")
.HasDatabaseName("ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("Listings")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skin_instances_skin_instance_id");
b.Navigation("Skin");
b.Navigation("SkinInstance");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
b.Navigation("Listings");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,259 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinInstanceDupeTrackingModelB : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_float_value",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_paint_seed",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_skin_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "skin_instances",
type: "numeric(20,18)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)");
migrationBuilder.AlterColumn<int>(
name: "condition_id",
schema: "skintracker",
table: "skin_instances",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "dupe_first_seen_at",
schema: "skintracker",
table: "skin_instances",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "last_seen_at",
schema: "skintracker",
table: "skin_instances",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<bool>(
name: "suspected_dupe",
schema: "skintracker",
table: "skin_instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "listings",
type: "numeric(20,18)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)");
migrationBuilder.AddColumn<string>(
name: "asset_id",
schema: "skintracker",
table: "listings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "skin_instance_id",
schema: "skintracker",
table: "listings",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou",
schema: "skintracker",
table: "skin_instances",
columns: new[] { "skin_id", "float_value", "paint_seed", "stat_trak", "souvenir" });
migrationBuilder.CreateIndex(
name: "ix_skin_instances_suspected_dupe",
schema: "skintracker",
table: "skin_instances",
column: "suspected_dupe");
migrationBuilder.CreateIndex(
name: "ix_listings_asset_id",
schema: "skintracker",
table: "listings",
column: "asset_id");
migrationBuilder.CreateIndex(
name: "ix_listings_skin_instance_id",
schema: "skintracker",
table: "listings",
column: "skin_instance_id");
migrationBuilder.AddForeignKey(
name: "fk_listings_skin_instances_skin_instance_id",
schema: "skintracker",
table: "listings",
column: "skin_instance_id",
principalSchema: "skintracker",
principalTable: "skin_instances",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances",
column: "condition_id",
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_listings_skin_instances_skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_suspected_dupe",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_listings_asset_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropIndex(
name: "ix_listings_skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropColumn(
name: "dupe_first_seen_at",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "last_seen_at",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "suspected_dupe",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "asset_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropColumn(
name: "skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "skin_instances",
type: "numeric(10,9)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(20,18)");
migrationBuilder.AlterColumn<int>(
name: "condition_id",
schema: "skintracker",
table: "skin_instances",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "listings",
type: "numeric(10,9)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(20,18)");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_float_value",
schema: "skintracker",
table: "skin_instances",
column: "float_value");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_paint_seed",
schema: "skintracker",
table: "skin_instances",
column: "paint_seed");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_skin_id",
schema: "skintracker",
table: "skin_instances",
column: "skin_id");
migrationBuilder.AddForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances",
column: "condition_id",
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
}
}
}

View File

@@ -0,0 +1,875 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530222302_AddSkinConditionListingsSweptAt")]
partial class AddSkinConditionListingsSweptAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skintracker")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_inventory_items");
b.HasIndex("AssetId")
.HasDatabaseName("ix_inventory_items_asset_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_inventory_items_skin_instance_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_inventory_items_user_id");
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AssetId")
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<int?>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("AssetId")
.HasDatabaseName("ix_listings_asset_id");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_listings_skin_instance_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_price_histories");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_price_histories_condition_id");
b.HasIndex("SkinId", "ConditionId", "RecordedAt")
.HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
b.ToTable("price_histories", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("Source")
.IsRequired()
.HasColumnType("text")
.HasColumnName("source");
b.HasKey("Id")
.HasName("pk_scrape_runs");
b.HasIndex("Source", "RanAt")
.HasDatabaseName("ix_scrape_runs_source_ran_at");
b.ToTable("scrape_runs", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skin_conditions_listings_swept_at");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset?>("DupeFirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("dupe_first_seen_at");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.Property<bool>("SuspectedDupe")
.HasColumnType("boolean")
.HasColumnName("suspected_dupe");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("SuspectedDupe")
.HasDatabaseName("ix_skin_instances_suspected_dupe");
b.HasIndex("SkinId", "FloatValue", "PaintSeed", "StatTrak", "Souvenir")
.HasDatabaseName("ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("SteamId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("steam_id");
b.HasKey("Id")
.HasName("pk_steam_users");
b.HasIndex("SteamId")
.IsUnique()
.HasDatabaseName("ix_steam_users_steam_id");
b.ToTable("steam_users", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("Listings")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skin_instances_skin_instance_id");
b.Navigation("Skin");
b.Navigation("SkinInstance");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
b.Navigation("Listings");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinConditionListingsSweptAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "listings_swept_at",
schema: "skintracker",
table: "skin_conditions",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skin_conditions_listings_swept_at",
schema: "skintracker",
table: "skin_conditions",
column: "listings_swept_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_skin_conditions_listings_swept_at",
schema: "skintracker",
table: "skin_conditions");
migrationBuilder.DropColumn(
name: "listings_swept_at",
schema: "skintracker",
table: "skin_conditions");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddCsMoneyListing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "cs_money_listings",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
sell_order_id = table.Column<long>(type: "bigint", nullable: false),
asset_id = table.Column<string>(type: "text", nullable: true),
skin_id = table.Column<int>(type: "integer", nullable: false),
condition_id = table.Column<int>(type: "integer", nullable: true),
skin_instance_id = table.Column<int>(type: "integer", nullable: true),
market_hash_name = table.Column<string>(type: "text", nullable: false),
quality = table.Column<string>(type: "text", nullable: true),
float_value = table.Column<decimal>(type: "numeric(20,18)", nullable: true),
paint_seed = table.Column<int>(type: "integer", nullable: true),
phase = table.Column<string>(type: "text", nullable: true),
is_stat_trak = table.Column<bool>(type: "boolean", nullable: false),
is_souvenir = table.Column<bool>(type: "boolean", nullable: false),
sticker_count = table.Column<int>(type: "integer", nullable: false),
price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
price_before_discount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
computed_price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
currency = table.Column<string>(type: "text", nullable: false),
inspect_link = table.Column<string>(type: "text", nullable: true),
first_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
removed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_cs_money_listings", x => x.id);
table.ForeignKey(
name: "fk_cs_money_listings_skin_conditions_condition_id",
column: x => x.condition_id,
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_cs_money_listings_skin_instances_skin_instance_id",
column: x => x.skin_instance_id,
principalSchema: "skintracker",
principalTable: "skin_instances",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_cs_money_listings_skins_skin_id",
column: x => x.skin_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_asset_id",
schema: "skintracker",
table: "cs_money_listings",
column: "asset_id");
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_condition_id",
schema: "skintracker",
table: "cs_money_listings",
column: "condition_id");
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_sell_order_id",
schema: "skintracker",
table: "cs_money_listings",
column: "sell_order_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_skin_id_condition_id",
schema: "skintracker",
table: "cs_money_listings",
columns: new[] { "skin_id", "condition_id" });
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_skin_instance_id",
schema: "skintracker",
table: "cs_money_listings",
column: "skin_instance_id");
migrationBuilder.CreateIndex(
name: "ix_cs_money_listings_status",
schema: "skintracker",
table: "cs_money_listings",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "cs_money_listings",
schema: "skintracker");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddMarketListingsView : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Cross-market read model: one row per active/removed listing across every
// per-market table, tagged with its marketplace. Column names/types are
// aligned to the MarketListing keyless entity (snake_case). A new market is
// added here as one more UNION ALL arm.
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
NULL::integer AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
'USD'::text AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
c.quality,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
c.price,
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP VIEW IF EXISTS skintracker.market_listings;");
}
}
}

View File

@@ -0,0 +1,146 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddPerSiteSweepCheckpoints : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins");
migrationBuilder.DropIndex(
name: "ix_skin_conditions_listings_swept_at",
schema: "skintracker",
table: "skin_conditions");
migrationBuilder.DropColumn(
name: "listings_swept_at",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "listings_swept_at",
schema: "skintracker",
table: "skin_conditions");
migrationBuilder.CreateTable(
name: "skin_condition_sweeps",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
skin_condition_id = table.Column<int>(type: "integer", nullable: false),
source = table.Column<string>(type: "text", nullable: false),
swept_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_skin_condition_sweeps", x => x.id);
table.ForeignKey(
name: "fk_skin_condition_sweeps_skin_conditions_skin_condition_id",
column: x => x.skin_condition_id,
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "skin_sweeps",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
skin_id = table.Column<int>(type: "integer", nullable: false),
source = table.Column<string>(type: "text", nullable: false),
swept_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_skin_sweeps", x => x.id);
table.ForeignKey(
name: "fk_skin_sweeps_skins_skin_id",
column: x => x.skin_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_skin_condition_sweeps_skin_condition_id_source",
schema: "skintracker",
table: "skin_condition_sweeps",
columns: new[] { "skin_condition_id", "source" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_skin_condition_sweeps_source_swept_at",
schema: "skintracker",
table: "skin_condition_sweeps",
columns: new[] { "source", "swept_at" });
migrationBuilder.CreateIndex(
name: "ix_skin_sweeps_skin_id_source",
schema: "skintracker",
table: "skin_sweeps",
columns: new[] { "skin_id", "source" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_skin_sweeps_source_swept_at",
schema: "skintracker",
table: "skin_sweeps",
columns: new[] { "source", "swept_at" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "skin_condition_sweeps",
schema: "skintracker");
migrationBuilder.DropTable(
name: "skin_sweeps",
schema: "skintracker");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "listings_swept_at",
schema: "skintracker",
table: "skins",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "listings_swept_at",
schema: "skintracker",
table: "skin_conditions",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins",
column: "listings_swept_at");
migrationBuilder.CreateIndex(
name: "ix_skin_conditions_listings_swept_at",
schema: "skintracker",
table: "skin_conditions",
column: "listings_swept_at");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinLandListings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "skin_land_listings",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
listing_id = table.Column<long>(type: "bigint", nullable: false),
skin_id = table.Column<int>(type: "integer", nullable: false),
condition_id = table.Column<int>(type: "integer", nullable: true),
market_hash_name = table.Column<string>(type: "text", nullable: false),
float_value = table.Column<decimal>(type: "numeric(20,18)", nullable: true),
is_stat_trak = table.Column<bool>(type: "boolean", nullable: false),
is_souvenir = table.Column<bool>(type: "boolean", nullable: false),
name_tag = table.Column<string>(type: "text", nullable: true),
sticker_count = table.Column<int>(type: "integer", nullable: false),
price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
currency = table.Column<string>(type: "text", nullable: false),
inspect_link = table.Column<string>(type: "text", nullable: true),
first_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
removed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_skin_land_listings", x => x.id);
table.ForeignKey(
name: "fk_skin_land_listings_skin_conditions_condition_id",
column: x => x.condition_id,
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "fk_skin_land_listings_skins_skin_id",
column: x => x.skin_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "ix_skin_land_listings_condition_id",
schema: "skintracker",
table: "skin_land_listings",
column: "condition_id");
migrationBuilder.CreateIndex(
name: "ix_skin_land_listings_listing_id",
schema: "skintracker",
table: "skin_land_listings",
column: "listing_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_skin_land_listings_skin_id_condition_id",
schema: "skintracker",
table: "skin_land_listings",
columns: new[] { "skin_id", "condition_id" });
migrationBuilder.CreateIndex(
name: "ix_skin_land_listings_status",
schema: "skintracker",
table: "skin_land_listings",
column: "status");
// Extend the cross-market read view with a skin.land arm. skin.land exposes no
// paint seed / asset id / instance fingerprint, so those columns are NULL; the
// wear comes from the joined condition row (the offer table doesn't store it).
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
NULL::integer AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
'USD'::text AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
-- Normalise cs.money's wear short code to the full wear name the
-- other arms emit (csfloat wear_name / skinland condition), so the
-- view's `wear` column is consistent across marketplaces.
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
c.price,
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c
UNION ALL
SELECT
'skinland'::text,
s.listing_id::text,
s.skin_id,
s.condition_id,
NULL::integer,
s.market_hash_name,
sc.condition,
s.float_value,
NULL::integer,
s.is_stat_trak,
s.is_souvenir,
s.sticker_count,
s.price,
s.currency,
s.inspect_link,
NULL::text,
s.status,
s.first_seen_at,
s.last_seen_at,
s.removed_at
FROM skintracker.skin_land_listings s
LEFT JOIN skintracker.skin_conditions sc ON sc.id = s.condition_id;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restore the pre-skin.land view (csfloat + csmoney) before dropping the table
// it references, so the view never points at a missing relation.
migrationBuilder.Sql("""
CREATE OR REPLACE VIEW skintracker.market_listings AS
SELECT
'csfloat'::text AS marketplace,
l.cs_float_listing_id AS external_id,
l.skin_id AS skin_id,
NULL::integer AS condition_id,
l.skin_instance_id AS skin_instance_id,
l.market_hash_name AS market_hash_name,
l.wear_name AS wear,
l.float_value AS float_value,
l.paint_seed AS paint_seed,
l.is_stat_trak AS is_stat_trak,
l.is_souvenir AS is_souvenir,
l.sticker_count AS sticker_count,
l.price AS price,
'USD'::text AS currency,
l.inspect_link AS inspect_link,
l.asset_id AS asset_id,
l.status AS status,
l.first_seen_at AS first_seen_at,
l.last_seen_at AS last_seen_at,
l.removed_at AS removed_at
FROM skintracker.listings l
UNION ALL
SELECT
'csmoney'::text,
c.sell_order_id::text,
c.skin_id,
c.condition_id,
c.skin_instance_id,
c.market_hash_name,
-- Normalise cs.money's wear short code to the full wear name the
-- other arms emit (csfloat wear_name / skinland condition), so the
-- view's `wear` column is consistent across marketplaces.
CASE lower(c.quality)
WHEN 'fn' THEN 'Factory New'
WHEN 'mw' THEN 'Minimal Wear'
WHEN 'ft' THEN 'Field-Tested'
WHEN 'ww' THEN 'Well-Worn'
WHEN 'bs' THEN 'Battle-Scarred'
ELSE c.quality
END,
c.float_value,
c.paint_seed,
c.is_stat_trak,
c.is_souvenir,
c.sticker_count,
c.price,
c.currency,
c.inspect_link,
c.asset_id,
c.status,
c.first_seen_at,
c.last_seen_at,
c.removed_at
FROM skintracker.cs_money_listings c;
""");
migrationBuilder.DropTable(
name: "skin_land_listings",
schema: "skintracker");
}
}
}

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