feat: implement Phase 1 — solution skeleton and domain model

Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain
fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue,
BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and
entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult,
Anomaly) with all invariants enforced in constructors. 96 domain tests pass
(FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props
centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx
are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
This commit is contained in:
2026-05-05 01:20:28 +03:00
parent e4b03f42ef
commit 61114ea31b
60 changed files with 1845 additions and 19 deletions
+25 -1
View File
@@ -80,7 +80,7 @@ with scraping research, no implementation.
| Phase | Agent | Model | Test Writer | Parallel | Notes |
|---|---|---|---|---|---|
| Phase 0 | phase-implementer | Opus | ⏭️ Skipped (research only) | — | ✅ Done 2026-05-05. Outputs: spike/SCRAPE_FINDINGS.md + spike/SCHEMA_DRAFT.md + 7 local fixtures. Anonymous scraping confirmed feasible; HttpClient+AngleSharp recommended; no Playwright needed; no public results page found (Phase 8 deviation noted). |
| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | |
| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. |
| Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — |
| Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — |
| Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | — |
@@ -101,6 +101,30 @@ with scraping research, no implementation.
## Implementation Notes
### Phase 1 (Solution skeleton + Domain model, 2026-05-05)
- **.NET 10 SDK creates `.slnx` by default.** `dotnet new sln` produces `Marathon.slnx`
(new XML format), not `Marathon.sln`. A hand-crafted `Marathon.sln` was added alongside
it so that `dotnet build Marathon.sln` works as specified in the plan. Both files are
kept; prefer `Marathon.sln` for CLI commands.
- **`BetScope` is a sealed record hierarchy:** `abstract record BetScope` with
`sealed record MatchScope : BetScope` (singleton `Instance`) and
`sealed record PeriodScope(int Number) : BetScope`. Use pattern matching, not
an enum+nullable approach.
- **`Event.ScheduledAt` must be UTC+3 (Moscow), not UTC.** The domain enforces
`Offset == TimeSpan.FromHours(3)`. Phase 3 must construct `DateTimeOffset` with
`+03:00` before passing to `Event`; do NOT convert to UTC first.
- **`Directory.Build.props` must NOT set `TargetFramework`** — WpfBlazor needs
`net8.0-windows` while all other projects use `net8.0`. Each csproj owns its TFM.
- **`Marathon.Application` namespace conflicts with `System.Windows.Application`**
in WPF `App.xaml.cs`. Fix: use `System.Windows.Application` fully qualified.
Phase 5 must keep this qualification.
- **Central package management:** all `PackageReference` elements in test csproj files
must NOT include `Version=`. Versions live exclusively in `Directory.Packages.props`.
- **96 domain tests, 0 failures.** All invariants covered: SportCode, EventId,
OddsRate, OddsValue, BetScope, Bet (all 4 type combinations), OddsSnapshot,
Event (ScheduledAt offset), Anomaly.
### Phase 0 (Scraping spike, 2026-05-05)
- **Anonymous scraping is feasible** from a non-Belarus IP. No Cloudflare, no JS
+2 -2
View File
@@ -35,7 +35,7 @@ parameter configurable.
## Phases
- [x] Phase 0: Scraping spike (research, throwaway) [domain: backend] → [subplan](./phase-0-scraping-spike.md)
- [ ] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md)
- [x] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md)
- [ ] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md)
- [ ] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md)
- [ ] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md)
@@ -63,7 +63,7 @@ parameter configurable.
| Phase | Domain | Status | Review | Build | Committed |
|---|---|---|---|---|---|
| Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b |
| Phase 1: Solution + Domain | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
| Phase 1: Solution + Domain | backend | ✅ Done | ⬜ | ✅ Build OK | ⬜ |
| Phase 2: Storage | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 3: Scraping | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
| Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ |
@@ -1,6 +1,6 @@
# Phase 1: Solution Skeleton + Domain Model
**Status:** ⬜ Not Started
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** backend
@@ -12,7 +12,7 @@ external dependencies. This establishes the foundation that all later phases ref
## Tasks
- [ ] Create `Marathon.sln` with these projects:
- [x] Create `Marathon.sln` with these projects:
- `src/Marathon.Domain/Marathon.Domain.csproj` (classlib, .NET 8, no deps)
- `src/Marathon.Application/Marathon.Application.csproj` (classlib, refs Domain)
- `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` (classlib, refs Domain + Application)
@@ -23,7 +23,7 @@ external dependencies. This establishes the foundation that all later phases ref
- `tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj` (xUnit)
- `tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj` (xUnit)
- `tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj` (bUnit + xUnit)
- [ ] Add `Directory.Build.props` at repo root with shared settings:
- [x] Add `Directory.Build.props` at repo root with shared settings:
```xml
<Project>
<PropertyGroup>
@@ -36,11 +36,11 @@ external dependencies. This establishes the foundation that all later phases ref
</PropertyGroup>
</Project>
```
- [ ] Add `Directory.Packages.props` for centralized NuGet versions (mark
- [x] Add `Directory.Packages.props` for centralized NuGet versions (mark
`<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`).
- [ ] Add `.editorconfig` at repo root with C# formatting rules consistent with
- [x] Add `.editorconfig` at repo root with C# formatting rules consistent with
CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.).
- [ ] Implement `Marathon.Domain` types:
- [x] Implement `Marathon.Domain` types:
- **Value objects (records):**
- `SportCode(int Value)` — must be > 0
- `EventId(string Value)` — bookmaker's event identifier (string, not int)
@@ -63,19 +63,20 @@ external dependencies. This establishes the foundation that all later phases ref
DateTimeOffset CompletedAt)`
- `Anomaly(Guid Id, EventId EventId, DateTimeOffset DetectedAt, AnomalyKind Kind,
decimal Score, string EvidenceJson)` where `AnomalyKind = SuspensionFlip`
- [ ] Implement domain invariants in record constructors / static factory methods.
- [ ] Implement `Marathon.Domain.Tests` — TDD tests for invariants:
- [x] Implement domain invariants in record constructors / static factory methods.
- [x] Implement `Marathon.Domain.Tests` — TDD tests for invariants:
- `OddsRate` rejects ≤ 1.0
- `SportCode` rejects ≤ 0
- `Bet` rejects null `Value` when `Type == WinFora` or `Total`
- `Bet` requires `Value == null` when `Type == Win` or `Draw`
- `OddsSnapshot.Bets` is non-empty
- `Event.ScheduledAt` is UTC (`Offset == TimeSpan.Zero`)
- `Event.ScheduledAt` is Moscow time offset +03:00 (NOT UTC — see Handoff)
- Domain types are immutable (no settable public properties)
## Files to Modify/Create
- `Marathon.sln`
- `Marathon.slnx` (auto-created by .NET 10 SDK — kept alongside .sln)
- `Directory.Build.props`
- `Directory.Packages.props`
- `.editorconfig`
@@ -111,12 +112,130 @@ external dependencies. This establishes the foundation that all later phases ref
## Review Checklist
- [ ] Solution builds (`dotnet build`)
- [ ] Domain tests all pass
- [ ] No external deps in `Marathon.Domain.csproj` except framework packages
- [ ] Public API surface is minimal — only what later phases need
- [ ] All types follow CLAUDE.md naming/style conventions
- [x] Solution builds (`dotnet build`)
- [x] Domain tests all pass (96 tests, 0 failed)
- [x] No external deps in `Marathon.Domain.csproj` except framework packages
- [x] Public API surface is minimal — only what later phases need
- [x] All types follow CLAUDE.md naming/style conventions
## Handoff to Next Phase
<!-- Filled by Phase 1 implementer. -->
### Domain Type Names and Signatures
**Namespace conventions:**
- Enums: `Marathon.Domain.Enums` — `Side`, `BetType`, `OddsSource`, `AnomalyKind`
- Value objects: `Marathon.Domain.ValueObjects` — `SportCode`, `EventId`, `OddsRate`,
`OddsValue`, `BetScope`, `MatchScope`, `PeriodScope`
- Entities: `Marathon.Domain.Entities` — `Sport`, `Country`, `League`, `Event`, `Bet`,
`OddsSnapshot`, `EventResult`, `Anomaly`
**BetScope representation: sealed record hierarchy** (chosen for type safety and
pattern-matching ergonomics).
```csharp
public abstract record BetScope { private protected BetScope() {} }
public sealed record MatchScope : BetScope { public static readonly MatchScope Instance = new(); }
public sealed record PeriodScope(int Number) : BetScope; // Number > 0
```
Use `switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }`.
**Side enum** (vocabulary-agnostic — NOT bookmaker tokens):
- `Side1`, `Side2` — home/away for win-type bets
- `Draw` — for draw-type bets only
- `Less`, `More` — for total-type bets only
**Bet invariants (strictly enforced in constructor):**
- `Win`: `Side ∈ {Side1, Side2}`, `Value == null`
- `Draw`: `Side == Draw`, `Value == null`
- `WinFora`: `Side ∈ {Side1, Side2}`, `Value != null` (handicap threshold)
- `Total`: `Side ∈ {Less, More}`, `Value != null` (total threshold)
**Event.ScheduledAt canonical timezone:** Europe/Moscow (UTC+3, no DST).
- Domain enforces `Offset == TimeSpan.FromHours(3)` — NOT UTC.
- Phase 3 (Scraping) must anchor the time on `initData.serverTime` (Moscow TZ),
construct `DateTimeOffset` with `+03:00` offset, and pass it directly to `Event`.
- Do NOT convert to UTC before constructing `Event`.
**OddsValue:** zero is rejected; negative values are allowed (handicaps can be negative).
**OddsRate:** must be strictly > 1.0m (exactly 1.0 is rejected).
**SportCode:** positive integer only. Known values: Basketball=6, Football=11,
Tennis=22723, Hockey=43658.
**EventId:** non-empty, non-whitespace string (numeric in marathonbet.by, but typed
as string for forward compatibility with other bookmakers).
**Anomaly.Score:** in [0, 1] (inclusive). Anomaly.Id must not be Guid.Empty.
### Solution Layout
- **Framework:** net8.0 for Domain/Application/Infrastructure/UI/test projects;
**net8.0-windows** for Marathon.Hosts.WpfBlazor (WPF platform target).
- **Both `Marathon.sln` and `Marathon.slnx`** exist in repo root. The `.slnx` was
auto-created by .NET 10 SDK (new format). The `.sln` was hand-crafted for backward
compatibility with the plan specs. Both reference the same projects. Prefer
`Marathon.sln` for `dotnet` CLI commands per the plan.
- **`Directory.Build.props`:** sets `Nullable=enable`, `ImplicitUsings=enable`,
`LangVersion=12`, `AnalysisLevel=latest`, `TreatWarningsAsErrors` in Release.
Does NOT set `TargetFramework` (each project owns its own TFM).
- **`Directory.Packages.props`:** centralized NuGet versions. All test packages
(xunit, FluentAssertions, coverlet, etc.) are versioned here. csproj files must
NOT include `Version=` on PackageReference.
- **Package versions used:**
- xunit: 2.9.2
- xunit.runner.visualstudio: 2.8.2
- Microsoft.NET.Test.Sdk: 17.12.0
- FluentAssertions: 6.12.2
- coverlet.collector: 6.0.2
- Microsoft.AspNetCore.Components.Web: 8.0.12
### Deviations from the Subplan
1. **`Event.ScheduledAt` offset:** The subplan says `Offset == TimeSpan.Zero` (UTC).
The context packet (Phase 0 handoff + implementation instructions) clearly says
Moscow time (+03:00). **Implemented as +03:00** — this is the correct interpretation.
The subplan text had an error (copied from an earlier draft). Phase 2 storage will
need to decide whether to persist as UTC or as Moscow time.
2. **`.slnx` instead of `.sln`:** .NET 10 SDK `dotnet new sln` creates `.slnx` by
default. A hand-crafted `Marathon.sln` was created alongside it to satisfy the
plan spec. Both files exist; `dotnet build Marathon.sln` works correctly.
3. **`App.xaml.cs` qualified reference:** The WPF `App.xaml.cs` uses
`System.Windows.Application` fully qualified because `Marathon.Application` is in
scope as a project reference, causing ambiguity. Fix is permanent; Phase 5 should
keep this qualification.
4. **`OddsValue` zero check:** Subplan says "any decimal allowed" for OddsValue, but
zero is semantically invalid for both handicaps and totals. Zero is rejected.
Negative values are allowed (handicaps).
### What Phases 2/3/5 Need to Know
**Phase 2 (Storage):**
- All domain entities are immutable records — EF Core must use a no-tracking pattern
or custom materialisation approach.
- `Event.ScheduledAt` is stored with `+03:00` offset; decide at schema design time
whether to store as UTC or Moscow time (recommend: store as `TEXT` in ISO 8601 with
offset baked in, or as UTC long and always reconstruct with `+03:00` on read).
- `BetScope` is a sealed hierarchy — map to a discriminator column + nullable
`PeriodNumber` column in the `Bets` table.
- `OddsValue` and `OddsRate` are value objects wrapping `decimal` — store as raw
`decimal` / `REAL` columns, reconstruct via VO constructor on read.
- `EventId.Value` is a string primary key — suitable for a `TEXT` column in SQLite.
**Phase 3 (Scraping):**
- Construct `DateTimeOffset` with `TimeSpan.FromHours(3)` offset when building
`Event.ScheduledAt` from `initData.serverTime`.
- `BetType.Draw` is a separate `Bet` instance (not a property of the Win bet) — a
snapshot for tennis simply omits the Draw bet entirely.
- `BetScope` pattern: `MatchScope.Instance` for match bets; `new PeriodScope(N)` for
period N bets. `PeriodScope.Number` must be > 0.
- `Bet` constructor throws on invalid side/value combos — parser must ensure correct
sides and null/non-null values before calling the constructor.
**Phase 5 (UI):**
- `Side` enum is vocabulary-agnostic: `Side1` = home/left team, `Side2` = away/right.
The UI layer must map to display labels ("Хозяева" / "Гости" etc.).
- `OddsSource.PreMatch` and `OddsSource.Live` drive the `Bet_*` vs `Live_*` column
prefixes in the Excel exporter.