The five tutorial gifts every fresh viewer is given at signup used to live as a
static C# array in SVSim.Database/SeedData/TutorialPresents.cs — outside the
seed-JSON pipeline used by all 40+ other globals tables. Editing a gift required
a code change + rebuild instead of a JSON edit + bootstrap re-run.
Now authored in SVSim.Bootstrap/Data/seeds/tutorial-presents.json and loaded into
a new TutorialPresentEntries table via TutorialPresentsImporter (clear-and-rewrite,
mirroring HomeDialogs). ViewerRepository.RegisterAnonymousViewer reads the table
at signup and projects each row into a ViewerPresent with Source="tutorial".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors banners pattern: clear-and-rewrite from per-table JSON seed.
Ships one entry pointing at parent_gacha_id 80032 to match the
2026-06-03 prod capture.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.
Two layers of the same bug:
1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
of taking it from the do_matching request — verified against
data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
Signature changes to (viewerId, format, deckNo); DoMatchingInternal
passes req.DeckNo.
2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
request body is BaseRequest only, no deck_no on the wire. The fix uses
the MatchContext the bridge already stored at do_matching resolution time
(in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
viewer's pending battle for lookup. The store entry persists across
ai_start (idempotent reads are fine — the WS handler removes on connect).
No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
client.
Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
is the end-to-end regression: register Bot battle with deck #5, hit
/ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
AI-fallback resolution.
SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 shipped the AI rank battle bot pool as a hardcoded 8-entry list
inlined in SVSim.EmulatedEntrypoint/Matching/BotRoster.cs — editing meant
recompiling. Per PLAN.md 2026-06-02 item (d), move it to a Bootstrap
importer so the roster lives in seeds/bot-roster.json and the DB.
Shape mirrors PracticeOpponent end-to-end:
- BotRosterEntry (SVSim.Database/Models) — PK = AiId via the Id passthrough
pattern. DbSet<BotRosterEntry> BotRoster on SVSimDbContext.
- AddBotRoster migration (DDL only, per migrations-are-DDL-only rule).
- seeds/bot-roster.json — 8 rows preserving the current prod-verified
cosmetic ids (sleeve 704141010 / emblem 400001100 / degree 120027 /
field 5) and series-1 ai_ids from rm_ai_setting.csv (1111..1181).
- BotRosterSeed POCO + BotRosterImporter (idempotent upsert keyed by AiId,
leaves seed-missing rows intact). Wired into SVSim.Bootstrap/Program.cs
next to PracticeOpponentImporter.
- IGlobalsRepository.GetBotRoster() + impl.
IBotRoster.Pick → PickAsync because BotRoster now depends on the transient
IGlobalsRepository. RankBattleController awaits the new signature. The
deterministic hash-on-ctx invariant (same ctx → same bot, so /ai_<fmt>/start
retries pick the same opponent) is preserved.
DI: AddSingleton<IBotRoster> → AddTransient (matches IGlobalsRepository's
lifetime). Test fixture's SeedGlobalsAsync also runs the importer so
RankBattleControllerTests + the rewritten BotRosterTests both see seeded
rows.
Tests: 931 → 936 passing. Existing 3 BotRosterTests reshaped for the DB
backing + 1 new "throws on empty roster" guard; 4 new
BotRosterImporterTests mirror PracticeOpponentImporterTests
(round-trip / idempotent / seed-missing-row-intact / ai_id=0 skip).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
BattlePassRepository._curveCache and MissionCatalogRepository._maxLevelCache
were private-static fields populated lazily on first read from whatever
DbContext happened to be in scope. In production "one DbContext lineage
per process" makes that fine. Under parallel test execution each
SVSimTestFactory owns its own SQLite :memory: DB, so the first reader's
DB (often empty, in tests that don't seed BP) poisoned the cache for
concurrent readers from a seeded DB — assertions like "BP level info
must be present after seeding" failed because the process-static cache
returned an empty list populated by the other test's empty DB.
The first patch attempted a `BypassCacheForTests` static flag, which is
exactly the kind of test-only seam that rots the production code: future
caches get the same flag, repos accumulate hidden knobs, and the
underlying invariant ("a cache populated from arbitrary scope serves
arbitrary scope") goes unaddressed.
Instead, move both caches into the DI-registered IMemoryCache.
AddMemoryCache() registers it as singleton-per-service-provider:
production has one provider → one IMemoryCache → identical caching
semantics to before. Each WebApplicationFactory builds its own
provider → its own IMemoryCache → cache is naturally scoped per fixture,
no cross-test bleed possible.
The ResetLevelCurveCache() method and its three call sites
(SVSimTestFactory.SeedGlobalsAsync, BattlePassServiceTests,
LoadControllerTests) are deleted — a fresh factory owns a fresh empty
cache, no manual invalidation needed.
With this and the previous StoryService fixture-instance fix in place,
ParallelScope.All works: 776/776 in 57s wall clock (down from 59s on
Fixtures, 2m13s pre-parallelism).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The unit-test suite was spending most of its wall clock writing logs.
NUnit captures stdout per test and embeds it in the trx; with HttpLogging
emitting full request/response per controller call, EF Core SQL at
Information level, and ReferenceDataImporter banners running ~500x
(once per factory construction), the trx grew to 3.2 GB and the NUnit
result-XML serializer OOMed in StringBuilder.ToString() — which the
runner reported as one mysteriously failed test, masking a real
date-dependent failure underneath.
Three sources silenced under environment "Testing":
- appsettings.Testing.json drops Default + Microsoft.AspNetCore +
HttpLoggingMiddleware + EntityFrameworkCore to Warning.
- Program.cs skips app.UseHttpLogging() entirely (avoids the
middleware overhead, not just the log emission).
- ReferenceDataImporter takes optional TextWriters; the test factory
passes TextWriter.Null. Per-importer helpers become instance methods
so they can use the injected writer.
Result on a fresh run with ParallelScope.Fixtures already in place:
- Test duration: 1m46s -> 59s
- Wall clock: 2m23s -> 1m00s
- trx size: 3.2 GB -> 1.7 MB
The previously-masked date-dependent failure (PackControllerFullCatalog
.Info_returns_full_35_pack_catalog_from_production_seed asserting 35
active packs as of 2026-05-23 against a live clock) is now visible and
can be addressed separately.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Card grants produce a post-state-total entry and run the CardCosmeticReward
cascade (foil twin → id-1 lookup). Cascade additions are skipped when the
viewer already owns the cosmetic; missing-master-row failures are logged
and dropped without failing the parent grant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bootstrap Program.cs now calls PackDrawTableImporter after PackImporter.
Delete DbCardPoolProvider, ICardPoolProvider, and the DbCardPoolProvider
tests — the new IPackDrawTableRepository covers what GachaPointService
needed (legendary-tier card_ids per pack) and PackOpenService takes the
draw table directly.
GachaPointService now resolves the legendary catalog from
PackDrawTable.CardWeights filtered by Tier==Legendary, instead of
ICardPoolProvider.GetPool then a rarity filter. Same end set, no DB pool
walk.
Test fallout: tests that fabricate custom card sets for gacha-point
tests now call factory.SeedPackDrawTableFromSetAsync(packId, setId)
to install a matching legendary-tier stub. Full suite: 647/647 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sampler is now driven by PackDrawTable: roll DrawTier per slot by
cumulative slot-rate weights, then pick a card within tier by per-card
weights renormalized within the tier. Rate-less Guaranteed-Leader-Card
rows draw uniform over (pool minus owned), falling back to the full
pool when all are owned. Bonus slot fires once at the end of a 10-pack
open when HasBonusSlot is set.
Slot 8 falls back to the general slot's per-card weights for the rolled
tier when slot-8 has only a rarity-level rate quoted (the common shape
on normal packs).
PackController.Open loads the draw table + viewer owned card ids and
passes them to the sampler; the category-based forced-Legendary slot-8
override is gone. ICardFoilLookup replaces ICardPoolProvider for the
foil-twin heuristic.
Drops the test-fixture pack-draw seed overlay so the production seed
flows through the importer tests; controller tests that fabricate their
own card sets now call factory.SeedPackDrawTableAsync(...) to install a
matching stub draw table.
WeightedPick helper handles the cumulative-band roll for both stages.
Five sampler tests + four WeightedPick tests + five importer/repo
tests; full suite is 653/653 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a
new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the
FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The
ticket was never decremented and reward_list omitted the post-state entry —
on the next /tutorial/pack_info the pack stayed visible and the client
re-clicked into plain /pack/open, which 501s on type_detail=5.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(tests): SeedViewerAsync tutorialState param is no longer sentinel-overloaded
The previous `if (tutorialState != 0)` block silently dropped overrides for state 0,
so `SeedViewerAsync(tutorialState: 0)` returned whatever BuildDefaultViewer set
(state 1), not state 0. Tests that wanted a fresh-signup viewer were getting one
by accident, and the stale comment claimed the default was 0. Always override.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
POST /tutorial/update echoes tutorial_step back and saves it to
Viewer.MissionData.TutorialState. is_skip=1 is handled server-side
by honoring whatever tutorial_step value the client sends (client
already sends 100 when skipping). Adds TutorialUpdateRequest DTO,
TutorialUpdateResponse DTO, injects SVSimDbContext into
TutorialController, and adds GetViewerTutorialStateAsync helper to
SVSimTestFactory.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously RegisterAnonymousViewer auto-completed the tutorial, which
prevented the client from ever entering the tutorial flow. SeedViewerAsync
gains a tutorialState parameter (default 100) so existing tests keep
their pre-completed-tutorial assumption.
ItemEntry gains Type (client item_type enum, 1=challenge, 2=card-pack
ticket, 3=premium orb, 4=colosseum, 5=orb piece, 6=skin/event ticket,
7=other) and ThumbnailPath. ItemImporter mirrors PaymentItemImporter
shape: find-or-create per item_id, save once, idempotent. Wired into
Bootstrap.Program and SVSimTestFactory.SeedGlobalsAsync. Unblocks
/item_purchase/info (filters card-pack tickets by Type==2) and any
reward grant of UserGoodsType.Item, which previously threw because
the catalog was empty.
466 tests pass (was 461).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wire IBattlePassService.GetLevelCurveAsync into LoadController so /load/index
emits the 100-entry battle_pass_level_info dict when levels are seeded.
Also adds BattlePassRepository.ResetLevelCurveCache() to bust the process-level
static cache in tests that seed levels after earlier HTTP calls have primed it
with an empty list, and updates SVSimTestFactory.SeedGlobalsAsync + the stale
Index_surfaces_seeded_globals_after_bootstrap assertion accordingly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Final cleanup of the bootstrap-seed refactor (Task 10 of 10):
- Delete the GlobalsImporter no-op stub and its two remaining call sites
(Program.cs and SVSimTestFactory.cs). All work has moved to per-domain
importers since Task 9.
- Drop the --captures CLI flag and CapturesDir / shippedCaptures plumbing
from Program.cs (BootstrapOptions, ParseArgs, PrintUsage). Bootstrap input
is now cards.json + reference CSVs + per-table seed JSON; no more
prod-captures directory.
- Shrink ImporterBase from 141 to 23 lines: LoadCapture, Serialize,
Upsert<T,TKey>, GetInt/GetString/GetBool/GetLong/GetULong all had zero
callers after the seed migration. Only ParseWireDateTime survives (still
used by PaymentItemImporter and MyPageGlobalsImporter for prod-shaped
timestamp strings).
- Remove the prod-captures Content Include glob from SVSim.Bootstrap.csproj
and both prod-captures globs (production + test-fixture overlay) from
SVSim.UnitTests.csproj. Test fixtures now overlay production seeds at
Data/seeds/ via the Task 7 layout exclusively.
Build clean; 391/391 unit tests passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stage 9C of the bootstrap-seed-refactor:
- Add 6 seed DTOs for the card-id-keyed load-index tables (SpotCard,
ReprintedCard, UnlimitedRestriction, LoadingExclusionCard, MaintenanceCard,
FeatureMaintenance).
- Add CardListsImporter: idempotent upsert of the 6 tables, sharing one
Cards FK set for orphan-warning. FeatureMaintenances clear-and-rewrites
(synthetic ordinal Id; no natural key).
- Add RotationFlagUpdater: reads RotationConfig.RotationCardSetIds from the
GameConfigs section (populated by RotationConfigImporter) and flips
CardSet.IsInRotation to match.
- Add RotationConfig.RotationCardSetIds list property + wire it through
RotationConfigImporter. No migration needed (sections are JSON blobs).
- RotationConfigImporter: use legacy local-kind DateTime parse for schedule
windows so the JSON round-trip stays byte-equivalent to GlobalsImporter.
- Strip GlobalsImporter down to a no-op stub (Task 10 will delete it).
- Wire all 9 new importers into Program.cs and SVSimTestFactory.SeedGlobalsAsync,
in the order RotationConfigImporter -> ... -> CardListsImporter -> RotationFlagUpdater.
- Delete prod-captures/load-index-2026-05-23.json.
- Add CardListsImporterTests covering each sub-table, idempotency,
empty-seed handling, orphan-warning, and the clear-and-rewrite path.
Tests: 391 passing (382 baseline + 9 new).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extracts /deck/info's default_deck_list into seeds/default-decks.json
via the new extract-default-decks.ps1 PowerShell script and imports
through DefaultDeckImporter. The importer carries the same orphan-
card-id warning the old GlobalsImporter path emitted; production cards
yield 0 orphans. WarnOrphans stays inside GlobalsImporter for now —
SpotCards/ReprintedCards/UnlimitedRestrictions/LoadingExclusionCards
still use it until Task 9.
Part of the bootstrap seed refactor (Task 6).
Replaces GlobalsImporter's ImportPuzzleGroups/Puzzles/Missions methods (plus the
DeriveTargetPuzzleGroupId regex helper) with a dedicated PuzzleImporter that
reads three flat seed JSONs (puzzle-groups, puzzles, puzzle-missions) produced
by the Python extractor. Groups run before puzzles to satisfy the FK; missions
upsert by sequential id. Wired into Program.cs and SVSimTestFactory after
PaymentItemImporter so existing GlobalsImporterPuzzleTests continue to pass
unchanged via SeedGlobalsAsync. The original prod-capture JSONs are deleted now
that the seeds are authoritative.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lifts ImportPaymentItems out of GlobalsImporter into a dedicated
PaymentItemImporter driven by Data/seeds/payment-items.json. Wired
into Program.cs and SVSimTestFactory.SeedGlobalsAsync after
PracticeOpponentImporter. Drops the prod-capture file in favor of
the extractor pipeline.
Canonical 4-test suite (basic, idempotent, leave-untouched, skip-zero)
keeps the dict-in-sync upsert pattern Task 2 established.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move /practice/info handling out of GlobalsImporter into a dedicated
PracticeOpponentImporter that reads a normalized JSON seed file
generated by data_dumps/extract/extract-practice-opponents.ps1.