Compare commits
10 Commits
286d1366fe
...
edc649fc36
| Author | SHA1 | Date | |
|---|---|---|---|
| edc649fc36 | |||
| 15310f0fd0 | |||
| 763305ca89 | |||
| 8b0eb0db78 | |||
| 94177f9a8c | |||
| dc7c3f99ae | |||
| eb5fb0dac7 | |||
| d1752b1b07 | |||
| b51f1d9f5f | |||
| 6f3c0175cd |
22
.dockerignore
Normal file
22
.dockerignore
Normal 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
31
.editorconfig
Normal 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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
13
BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
Normal file
13
BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
Normal 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>
|
||||
37
BlueLaminate/BlueLaminate.C2/Contracts.cs
Normal file
37
BlueLaminate/BlueLaminate.C2/Contracts.cs
Normal 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);
|
||||
24
BlueLaminate/BlueLaminate.C2/Dockerfile
Normal file
24
BlueLaminate/BlueLaminate.C2/Dockerfile
Normal 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"]
|
||||
136
BlueLaminate/BlueLaminate.C2/JobQueue.cs
Normal file
136
BlueLaminate/BlueLaminate.C2/JobQueue.cs
Normal 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);
|
||||
}
|
||||
154
BlueLaminate/BlueLaminate.C2/Program.cs
Normal file
154
BlueLaminate/BlueLaminate.C2/Program.cs
Normal 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();
|
||||
23
BlueLaminate/BlueLaminate.C2/Properties/launchSettings.json
Normal file
23
BlueLaminate/BlueLaminate.C2/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
BlueLaminate/BlueLaminate.C2/appsettings.json
Normal file
17
BlueLaminate/BlueLaminate.C2/appsettings.json
Normal 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
|
||||
}
|
||||
30
BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
Normal file
30
BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
Normal 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>
|
||||
132
BlueLaminate/BlueLaminate.Cli/Commands/FetchListingsCommand.cs
Normal file
132
BlueLaminate/BlueLaminate.Cli/Commands/FetchListingsCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal file
267
BlueLaminate/BlueLaminate.Cli/Commands/FindTradeupsCommand.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
BlueLaminate/BlueLaminate.Cli/Commands/SweepListingsCommand.cs
Normal file
102
BlueLaminate/BlueLaminate.Cli/Commands/SweepListingsCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
98
BlueLaminate/BlueLaminate.Cli/Commands/SyncSkinsCommand.cs
Normal file
98
BlueLaminate/BlueLaminate.Cli/Commands/SyncSkinsCommand.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
88
BlueLaminate/BlueLaminate.Cli/Program.cs
Normal file
88
BlueLaminate/BlueLaminate.Cli/Program.cs
Normal 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);
|
||||
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal file
352
BlueLaminate/BlueLaminate.Cli/Tui/TradeupBrowser.cs
Normal 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)] + "…";
|
||||
}
|
||||
19
BlueLaminate/BlueLaminate.Cli/appsettings.json
Normal file
19
BlueLaminate/BlueLaminate.Cli/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
BlueLaminate/BlueLaminate.Core/BlueLaminate.Core.csproj
Normal file
22
BlueLaminate/BlueLaminate.Core/BlueLaminate.Core.csproj
Normal 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>
|
||||
386
BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
Normal file
386
BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
52
BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyJson.cs
Normal file
52
BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyJson.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
21
BlueLaminate/BlueLaminate.Core/CsMoney/Wear.cs
Normal file
21
BlueLaminate/BlueLaminate.Core/CsMoney/Wear.cs
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
780
BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
Normal file
780
BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
Normal 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();
|
||||
}
|
||||
36
BlueLaminate/BlueLaminate.Core/Options/SweepOptions.cs
Normal file
36
BlueLaminate/BlueLaminate.Core/Options/SweepOptions.cs
Normal 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);
|
||||
}
|
||||
122
BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
Normal file
122
BlueLaminate/BlueLaminate.Core/Options/TradeupOptions.cs
Normal 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;
|
||||
}
|
||||
205
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandIngestService.cs
Normal file
205
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandIngestService.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
35
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandJson.cs
Normal file
35
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandJson.cs
Normal 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; }
|
||||
}
|
||||
69
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
Normal file
69
BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
12
BlueLaminate/BlueLaminate.Core/Skins/SkinSyncResult.cs
Normal file
12
BlueLaminate/BlueLaminate.Core/Skins/SkinSyncResult.cs
Normal 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);
|
||||
196
BlueLaminate/BlueLaminate.Core/Skins/SkinSyncService.cs
Normal file
196
BlueLaminate/BlueLaminate.Core/Skins/SkinSyncService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal file
409
BlueLaminate/BlueLaminate.Core/Tradeups/MultiCollectionSearch.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal file
67
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupCandidate.cs
Normal 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;
|
||||
}
|
||||
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal file
476
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupFinder.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal file
37
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraph.cs
Normal 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);
|
||||
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal file
197
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupGraphBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal file
203
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupListingData.cs
Normal 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);
|
||||
}
|
||||
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal file
38
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupMath.cs
Normal 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;
|
||||
}
|
||||
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal file
278
BlueLaminate/BlueLaminate.Core/Tradeups/TradeupSelector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal file
75
BlueLaminate/BlueLaminate.Core/Tradeups/WeaponRarity.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal file
58
BlueLaminate/BlueLaminate.Core/Tradeups/WearBand.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.0–1.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
64
BlueLaminate/BlueLaminate.EFCore/Data/SweepCheckpoints.cs
Normal file
64
BlueLaminate/BlueLaminate.EFCore/Data/SweepCheckpoints.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs
Normal file
21
BlueLaminate/BlueLaminate.EFCore/Entities/Collection.cs
Normal 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>();
|
||||
}
|
||||
67
BlueLaminate/BlueLaminate.EFCore/Entities/CsMoneyListing.cs
Normal file
67
BlueLaminate/BlueLaminate.EFCore/Entities/CsMoneyListing.cs
Normal 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; }
|
||||
}
|
||||
104
BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
Normal file
104
BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
Normal 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; }
|
||||
}
|
||||
45
BlueLaminate/BlueLaminate.EFCore/Entities/MarketListing.cs
Normal file
45
BlueLaminate/BlueLaminate.EFCore/Entities/MarketListing.cs
Normal 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; }
|
||||
}
|
||||
19
BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs
Normal file
19
BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs
Normal 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; }
|
||||
}
|
||||
@@ -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.0–1.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>();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
54
BlueLaminate/BlueLaminate.EFCore/Entities/SkinLandListing.cs
Normal file
54
BlueLaminate/BlueLaminate.EFCore/Entities/SkinLandListing.cs
Normal 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}&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; }
|
||||
}
|
||||
20
BlueLaminate/BlueLaminate.EFCore/Entities/SkinSweep.cs
Normal file
20
BlueLaminate/BlueLaminate.EFCore/Entities/SkinSweep.cs
Normal 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; }
|
||||
}
|
||||
23
BlueLaminate/BlueLaminate.EFCore/Entities/SweepSource.cs
Normal file
23
BlueLaminate/BlueLaminate.EFCore/Entities/SweepSource.cs
Normal 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";
|
||||
}
|
||||
609
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs
generated
Normal file
609
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
680
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs
generated
Normal file
680
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529192841_AddSkinCatalogFields.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
676
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs
generated
Normal file
676
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529200100_MakeSkinFloatsNullable.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
692
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs
generated
Normal file
692
BlueLaminate/BlueLaminate.EFCore/Migrations/20260529211544_UseStaticSkinCatalog.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
829
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
generated
Normal file
829
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
836
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
generated
Normal file
836
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
868
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530030305_AddSkinInstanceDupeTrackingModelB.Designer.cs
generated
Normal file
868
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530030305_AddSkinInstanceDupeTrackingModelB.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
875
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.Designer.cs
generated
Normal file
875
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1031
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531022448_AddCsMoneyListing.Designer.cs
generated
Normal file
1031
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531022448_AddCsMoneyListing.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1123
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531025024_AddMarketListingsView.Designer.cs
generated
Normal file
1123
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531025024_AddMarketListingsView.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;");
|
||||
}
|
||||
}
|
||||
}
|
||||
1207
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531203937_AddPerSiteSweepCheckpoints.Designer.cs
generated
Normal file
1207
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531203937_AddPerSiteSweepCheckpoints.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1323
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531212842_AddSkinLandListings.Designer.cs
generated
Normal file
1323
BlueLaminate/BlueLaminate.EFCore/Migrations/20260531212842_AddSkinLandListings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user