using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Domain;
///
/// A single entry in a user's game library. Mirrors the columns of the original
/// MySQL `games` table so the 2018 dump imports without transformation, with the
/// addition of ownership and audit fields.
///
public class Game
{
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
/// Console/platform the game runs on, e.g. "SNES", "PS2".
[MaxLength(50)]
public string? System { get; set; }
[MaxLength(50)]
public string? Genre { get; set; }
///
/// Release year. Kept as a string rather than an int: the original column was
/// varchar(50) and holds values like "" and "1996" — some entries were never
/// filled in, and a few real-world cases want ranges.
///
[MaxLength(50)]
public string? Year { get; set; }
[MaxLength(100)]
public string? Developer { get; set; }
[MaxLength(100)]
public string? Publisher { get; set; }
/// Filename of the uploaded box art, relative to the owner's upload folder.
[MaxLength(200)]
public string? Art { get; set; }
public string? Description { get; set; }
public bool Own { get; set; }
public bool Dumped { get; set; }
public bool Played { get; set; }
public bool Finished { get; set; }
// ---- collector fields ------------------------------------------------
/// Personal score out of 10. Null means unrated, which is not zero.
[Range(1, 10)]
public int? Rating { get; set; }
///
/// Free-form personal notes. Kept separate from Description, which is
/// derived from an external source and may be overwritten by the enricher.
///
public string? Notes { get; set; }
public GameCondition Condition { get; set; } = GameCondition.Unspecified;
public GameRegion Region { get; set; } = GameRegion.Unspecified;
/// What was paid for this copy. A fixed historical fact.
public decimal? PurchasePrice { get; set; }
public DateOnly? PurchaseDate { get; set; }
// ---- market value ----------------------------------------------------
//
// Distinct from PurchasePrice: an estimate of what a copy sells for now,
// expected to be refreshed from a price feed. Stored with the moment it was
// captured and where it came from, because a figure with neither is not
// something you can reason about — a total is only as good as its staleness.
///
/// The figure used for totals, sorting and display: the tier matching this
/// copy's condition when tiers are known, otherwise whatever was entered by
/// hand. Denormalised deliberately — SQLite can sort and SUM a column, and
/// recomputing a CASE across three nullable columns in every query is worse
/// than keeping one value in step via .
///
public decimal? MarketValue { get; set; }
public DateTimeOffset? MarketValueUpdatedAt { get; set; }
/// Provenance, e.g. a price feed's name, or "manual".
[MaxLength(100)]
public string? MarketValueSource { get; set; }
// Price sources quote per condition, and the spread between them is
// routinely a multiple. Keeping all three means changing a copy's condition
// re-prices it without another lookup, and the dashboard can answer both
// "what is this worth" and "what would it be worth complete".
///
/// The price source's identifier for this game, kept after the first match.
/// Later refreshes look it up directly instead of repeating a fuzzy search,
/// which makes them both cheaper and stable — a title search that drifts to
/// a different edition next month would silently re-price the wrong thing.
///
[MaxLength(100)]
public string? PriceSourceId { get; set; }
public decimal? ValueLoose { get; set; }
public decimal? ValueCib { get; set; }
public decimal? ValueNew { get; set; }
/// The tier that applies to a given condition, if it is known.
public decimal? TierFor(GameCondition condition) => condition switch
{
GameCondition.Sealed => ValueNew,
GameCondition.Cib => ValueCib,
GameCondition.Loose => ValueLoose,
// Digital has no physical tier, and an unspecified condition is most
// often a loose cart or disc, which is also the conservative estimate.
_ => ValueLoose,
};
///
/// Brings back in step with the tiers. A hand-typed
/// figure survives: it is only replaced once a source has supplied tiers.
///
public void RecalculateEffectiveValue()
{
var tier = TierFor(Condition);
if (tier is not null)
{
MarketValue = tier;
}
}
///
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
/// it is never accepted from the client.
///
[Required]
public string OwnerId { get; set; } = string.Empty;
public AppUser? Owner { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}