Add /profile/index endpoint that returns user_rank_match_total_win (stubbed 0)
and user_class_list built from viewer Classes + owned LeaderSkins. Six NUnit
integration tests cover zero wins, all classes present, level/exp/default skin,
leader_skin_id_list population, is_random_leader_skin round-trip, and 401 on
unauthenticated access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the bool column (defaults false) to the [Owned] ViewerClassData
entity and a two-test persistence suite that verifies round-trip and
default-value behaviour via SQLite/EnsureCreated.
Wires MyPageController.Index to read Viewer.MyPageBgId/SelectType/BgRotation
instead of emitting an empty MyPageBgSetting placeholder; adds Include for
MyPageBgRotation in ViewerRepository.GetViewerByShortUdid; adds fresh-viewer
zero-defaults test and write→read round-trip test (9/9 controller tests pass).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a comment above MyPageBgRotation.Clear() explaining the EF OwnsMany
delete-on-clear semantics. Add a 7th test covering garbage/"" entries in
mypage_id_list falling back to BgId=0 while adjacent valid entries are
unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prod capture (traffic_prod_misc_clicking.ndjson) shows all three
MyPageBgSetting fields arrive as decimal strings, not ints. Update the
DTO from int/int/List<int> to string/string/List<string> with "0"/empty
defaults, and add a literal wire-shape round-trip test pinning the
exact JSON against the capture.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds BGType persistence (0=Deck/1=CustomBG/2=RandomBG) to Viewer via two scalar
columns and an owned collection keyed (ViewerId, Slot). Two persistence tests
confirm round-trip and zero-defaults on fresh viewers.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Commit_writes_history_row_with_Unknown_source_when_caller_omits_Source
to InventoryHistoryTests — asserts that BeginAsync without a configure callback
writes AcquireType=0 / Message="Unknown", per spec §10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ItemAcquireHistoryController (POST /item_acquire_history/info) with its
three DTOs and two integration tests (ordering + empty-viewer). The endpoint
reads ViewerAcquireHistory rows written by InventoryTransaction.CommitAsync,
ordered newest-first, capped at 300. Tests access doc.RootElement.histories
directly (no envelope wrapper in the test path — middleware skips non-UnityPlayer UA).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PruneAcquireHistoryAsync to InventoryTransaction.CommitAsync; runs
inside the open DB transaction after history rows are flushed, keeping at
most 300 rows per viewer (oldest discarded). Adds a covering test that
verifies the cap and per-viewer isolation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop IsWalletCurrency (duplicate of IsCurrency); use IsCurrency in WriteAcquireHistory.
- Add comment on first SaveChangesAsync in CommitAsync explaining the two-phase flush.
- Guard WriteAcquireHistory loop with grant.Num == 0 check so synthetic DebitItem post-state ops do not produce history rows.
- Add InventoryHistoryTests.Commit_writes_no_history_row_for_item_debit to lock in the fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CommitAsync now calls WriteAcquireHistory() between the two SaveChanges
calls: iterates _ops, skips SpendOps, writes one ViewerAcquireHistoryEntry
per GrantOp. Cascade rows get GrantSource.CardCosmeticCascade; wallet
currencies zero RewardDetailId; all rows in a single commit share one
DateTime.UtcNow timestamp. Closes _source plumbing from Task 5.
5 new tests added (46 total inventory, 0 failures).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the ViewerAcquireHistoryEntry model (8 fields: Id, ViewerId,
RewardType, RewardDetailId, RewardCount, AcquireType, Message,
AcquireTime), registers DbSet<ViewerAcquireHistoryEntry> on
SVSimDbContext, configures model (PK, FK cascade to Viewer, MaxLength
64 on Message, composite index on ViewerId/AcquireTime/Id), and adds a
DbSet round-trip integration test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a `GrantSource Source { get; set; }` property (defaults to
`GrantSource.Unknown`) to `InventoryLoadConfig`. Plumbing-only — no
behavior change; callers that don't set `Source` get Unknown rows,
greppable via `acquire_type=0` in dev.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces GrantSource (17 values, DB-persisted) and GrantSourceMessages.For()
for the item-acquire-history feature. Values 1/2 mirror prod captures;
coverage test verifies every enum value has a non-empty message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
[Timestamp] byte[] doesn't work under SQLite (the test backend) — EF
expects the DB to populate it on insert, but SQLite has no equivalent
of Postgres's xmin. The WHERE Status = Unclaimed filter plus
IInventoryService's viewer-level concurrency is the practical defense;
RowVersion was only a backstop. Regenerated the migration without the
RowVersion column.
Wire reward_type on the gift endpoint uses a gift-specific scheme that
diverges from UserGoodsType for currencies: wire 1 = Crystal (enum=2),
wire 9 = Rupy (enum=9), wire 4 = Item (enum=4). A naked cast resolves
wire 1 to UserGoodsType.RedEther and silently grants the wrong wallet
— restored the explicit WireRewardTypeToUserGoodsType map from the old
tutorial controller.
Retrofits existing GiftControllerTests to call SeedTutorialPresentsAsync
on the new helper (RegisterViewer doesn't auto-seed; only the prod
signup path does). All 7 existing tests pass.
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>
Walk-down behavior: each call emits the highest-priority unfired
active dialog; subsequent calls walk to the next-priority entry.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Singleton keyed by ShortUdid; lock on per-viewer set to avoid
cross-viewer contention. Process lifetime — restart re-fires.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Window is [begin, end) — exclusive upper bound. Ordered priority-DESC
then Id-ASC so the controller can break on the first match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 7 of multi-instancing migration. Residual SVSim.UnitTests that touch
engine code directly are wrapped in TestBattleScope. EngineSessionGate is
deleted along with the _engineOwned bookkeeping in BattleSession; engine
setup is unconditional now that per-battle state is isolated on the ambient.
Gate-specific fallback branches in BattleSession.ShadowIngest are simplified.
Suite fully green (SVSim.UnitTests, SVSim.BattleEngine.Tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Six distinct fixes accumulated over live-test iterations against four bids
(654473755566, 806245601092, 283192092460, 131549100204, 799755786270) — together
they take the shadow engine from "throws on the first non-mulligan play" to
"survives a full PvP battle, only weird-edge-case Unity touches still left to whack".
1. Engine StableRandom seed aligned with clients' Matched.seed
(BattleSession.EnsureEngineSetup, NodeNativeBattleHarness.Create). Clients seed
_stableRandom with BattleSeeds.Stable(masterSeed) (the value the node ships in
Matched.seed); we were passing the RAW masterSeed to engine.Setup, so every
StableRandom call diverged from call #1 onward — every turn-1+ draw picked a
different deck position than the clients. Verified Stable(1184631275)=1543475792
matches the wire on bid 654473755566.
2. SeedDeck advances cardTotalNum to deck.Count+1 + pins BattleStartDeckCardList.
Mirrors SBattleLoad.InitPlayer's tail (SBattleLoad.cs:1292). Without it,
skill-generated tokens auto-assigned Index 0,1,... and COLLIDED with deck-loaded
indices 1..40 — silent until something addressed the deck card with the
colliding Index (Hoverboarder at deck idx 1 + a token at engine Index 1 made
GetBattleCardIdx's SingleOrDefault throw on bid 806245601092).
3. BattleCardView.GameObject lazily non-null in the shim (ViewUiTouchStubs.cs).
The IsRecovery card-create delegate (NetworkBattleManagerBase.cs:379) passes
null cardGameObject; Skill_metamorphose.cs:147 in the in-play branch then NRE'd
on `metamorphosedCard.BattleCardView.GameObject.transform.rotation = identity`,
a purely cosmetic touch with no game-state implication. Bid 283192092460:
Petrification on a board follower.
4. TranslateChoiceKeyAction unwraps wrapped selectCard on shadow ingest
(SessionBattleEngine.cs, sibling to TranslateTargetOwners). Live sender-send
wires Choice plays as selectCard:{cardId:[...], open:0}; engine's
ConvertToListInt does `value as List<object>` — a Dict casts to null and
foreach NREs. The receiver's swallow-all catch (NetworkBattleReceiver.cs:1255)
logs to Debug.LogError + LocalLog — both shimmed/no-op'd headlessly — and
returns false, but Receive calls ReceivedMessage with checkBreakData:false so
the false isn't propagated. The play continues with choiceIdList=[], the chosen
branch never resolves, the played card stays in hand; a later targeted play
(A's bounce on B's "board" idx 20) then can't find the target → NRE on null in
ActionProcessor.PlayCard:407. Bid 131549100204: B's Resonance + A's bounce.
Opponent-relay path is unaffected — node strips selectCard from broadcasts.
5. HeadlessHandViewStub overrides HandUnfocus/HandFocus/FocusRearrangeHandHand
to return NullVfx. CreateHandControl returns null in headless; the base
methods unconditionally deref `_handControl.SetHandState(...)`. A follower
with a when_spell_play Heal trigger fired on its leader for amount 0 — even
a 0-heal drives ApplyHealing → CreatePullHandInVfx → HandUnfocus → NRE.
Bid 799755786270: two consecutive spell plays both crashed this stack.
Added InternalsVisibleTo("SVSim.BattleEngine.Tests") so the shim-level
regression tests can pin the no-op contracts directly.
Plus the previous-session fixes carried in this same uncommitted state
(see docs/superpowers/plans/2026-06-07-shadow-engine-desync-handoff.md):
- doesPlayerGoFirst:true + mgr.IsFirst:true (turn-1 draw count correct
per seat)
- RecoveryOperationCollection.PlayHandCardOperation routes all type:30
through PlaySkillSelectHandCardOperation (skips the two-phase user-select
guard that aborts targeted spells in recovery)
- ShadowFeed + ToRawBody: server-generated typed bodies (DealBody, etc.)
converted to RawBody before engine.Receive (`env.Body as RawBody`
returned null for typed bodies)
- Ready idxChangeSeed seeds A's XorShift via the receiver; B's seed is
injected via SeedOppoIdxChange (BattleSeeds.IdxChange + viewerId)
- ReadySpin defaulted to 0 (was 243) — non-zero double-cranks the shadow
which ingests BOTH sides' Ready frames on one stream
Test counts: SVSim.UnitTests 1054/1054, SVSim.BattleEngine.Tests 34/34.
Open: known-residual Unity touches are individual whack-a-mole now (per-card
skill edge cases), not the structural divergences fixed here.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Source the played card's opponent-facing knownList[].cardId off the shadow engine
(SessionBattleEngine.PlayedCardId -> BattleCardBase.CardId), engine-first with the
wire-mined idx->cardId map as the fallback. PROVEN engine-resolved (each backed by a
HeadlessConductorTests PlayedCardId_* test): deck cards and receive-path substituted/
revealed tokens (engine seats the wire id at the wire idx).
PARTIAL retirement: the wire-mining bookkeeping (MineAddOps/MineChoicePicks/MineCopyTokens
+ Record*From) is KEPT as the load-bearing fallback. The choice/Discover, copy/clone and
cross-side (isSelf:0) token cases are NOT proven to resolve at a wire idx headless — the
autonomous token_draw path seats a chosen token at engine Index 0 (would collide with the
leader), and copy/cross-side aren't cheaply fixturable. Deleting their mining on faith
would silently corrupt opponent reveals, so it stays behind a TODO(M-HC-4f) gate.
- SessionBattleEngine.PlayedCardId: new accessor mirroring PlayedCardClan/Tribe.
- BuildPlayedCard: signature deckMap->explicit cardId; null on cardId==0 (no engine id AND
no mined/deck-map fallback).
- PlayActionsHandler: cardId = engine.PlayedCardId(seat, idx, fallback: mapped) ; mining retained.
- Tests: PlayedCardId_* (deck/substituted/degrade pass; choice-gap [Explicit] documents the
Index-0 finding). KnownListBuilder + CaptureConformance call-sites updated to new signature.
Full BattleNode suite 263/263 green; HeadlessConductorTests 27/27; drift clean; no Engine edits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prod always emits clan (int ClanType) + tribe (comma-joined int TribeType
string, "0" for none) on every knownList entry (battle-traffic_tk2_regular
.ndjson). Source both off the resolved engine (SessionBattleEngine.PlayedCardClan/
PlayedCardTribe -> BattleCardBase.Clan/Tribe), so skill-applied clan/tribe
changes ride the wire rather than the static card-master value. Thread through
KnownListBuilder.BuildPlayedCard + PlayActionsHandler; add clan/tribe to the
KnownCardEntry DTO (always present, non-null). Node-side only; no engine edits,
drift clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A targeted hand-play (PLAY_HAND_SELECT, opcode 31) and a choice play (PLAY_HAND +
keyAction type Choice) both resolve headless through the recovery receive conductor
with NO new shim/view fills — the 4a/4b view seeds (DetailPanelControl, _inPlayFrameEffect,
_playerInfoPair, HeadlessConductorVfxMgr) already cover the target/choice surface, because
the recovery path resolves targets/choices from the wire frame without the interactive
select UI, and the damage/token VFX execute through the existing top-level InstantVfx path.
Fixtures (cards.json, full skill mechanics):
- single-target: 100414020 (cost-1 Dragoncraft spell, when_play damage=2 to a selected
enemy unit). Asserts the enemy 1/4 (101411060) drops to life 2 — exact magnitude, survives.
- choice: 127011010 (cost-1 Neutral choice follower, choose 1 of 2 tokens to add to hand).
Asserts the chosen token (B) lands in hand and the un-chosen token (A) does not — decisive
about WHICH branch resolved. Wire keyAction shape cross-checked against a real capture of
this exact card (battle_test/rng/battle-traffic_cl1.ndjson); the receiver consumes a flat
selectCard list (ConvertToListInt).
Drivers: NodeNativeBattleHarness.TargetedPlayBody (reuses the {targetIdx,vid,selectSkillIndex}
target shape proven by AttackBody) + ChoicePlayBody. Zero Engine/*.cs edits (drift clean).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive ATTACK frames through the headless receive conductor and assert on engine
board state (node-native harness). Two cases: follower -> enemy leader (leader
life drops by atk, attacker spent) and a lethal follower-vs-follower trade (both
removed). ATTACK opcode confirmed = 10 (NetworkBattleDefine.PlayActionType).
Headless view-untangle (no Engine logic edits; drift clean):
- IBattlePlayerView.AttackSelectControl -> non-null HeadlessAttackSelectControl
(no-op RegisterAttackPair/ResetCardAfterAttack); IsCardTranslatable left to base.
- IBattleCardView.CardInfo -> backing card via BuildInfo (so IsCardTranslatable
reads authentic IsClass); class/null view ctors now chain : base(buildInfo).
- IBattleCardView._inPlayFrameEffect -> non-null no-op control.
- Seed Certification.viewer_id headless so the IsRecovery target parse
(vid != UserViewerID) does not throw inside SavedataManager and silently drop
the parsed targetList.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The headless engine accumulates spell-charge for real on the receive path
(each spell play runs the played card's own AddSpellChargeCount) and resolves
the discounted cost by construction, so the wire-derived spellboost-count
bookkeeping is redundant. Engine-source the knownList spellboost COUNT too
(prod-faithful) via a new SessionBattleEngine.PlayedCardSpellboost, using the
same persist-post-play zone search as PlayedCardCost (SpellChargeCount survives
PlayCard; only ctor/ReturnCard zero it).
- Delete IdxToSpellboost/SpellboostMap/GetSpellboostMap/RecordSpellboostFrom
(BattleSessionState) and MineAlterSpellboosts (KnownListBuilder); token/choice/
copy identity maps are untouched.
- BuildPlayedCard takes an engine-sourced spellboost int (drops spellboostMap).
- Seed BattleLogManager fusion lists headless (the per-frame filter cleanup
NREs on null EnemyFusionCard when a fanfare card registers a CalledCreateFilter)
so real spell-charge grantor plays resolve.
- Add committed real-charge regression tests (no SeedHandCardSpellboostCost seam):
one grantor play accumulates +1 on the reducer -> cost 5->4, count 1, persisting
post-play; handler emits cost 4 + spellboost 1 engine-sourced.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The opponent-facing PlayActions knownList now carries the engine-RESOLVED
play-time cost (KnownCardEntry.cost), sourced from the headless shadow engine's
PlayedCost on the just-resolved card. This closes the spellboost cost-desync BY
CONSTRUCTION: the engine already knows the true discounted cost (spellboost +
board modifiers folded in), so no bookkeeping is needed.
- DTO: add non-nullable cost to KnownCardEntry (prod emits cost 45/45).
- SessionBattleEngine.PlayedCardCost(seat, idx, fallback): finds the resolved
card by engine Index across in-play/cemetery/hand zones and returns PlayedCost
(captured by PlayCard at resolution == discounted Cost), degrading to fallback
when the engine is not owned/ready.
- PlayActionsHandler sources the played card's cost from ctx.Engine (ShadowIngest
already resolved the play before the handler runs). Spellboost-map plumbing
stays for now; Task 6 (M-HC-3b) retires it.
- Validation: engine-read test (charge-seeded reducer 101314020: base 5, cost
5/1/0 at charge 0/4/5) + handler-emit test asserting knownList[0].cost == 1
(discounted, not base 5) with non-vacuity. Board-dependent (when_evolve_other)
case deferred to M-HC-4 (evolve not yet headless); cost is read off the resolved
engine so board modifiers are captured by construction once their ops resolve.
- Harness: promote alt vanilla follower id (101211120) to AltVanillaFollowerId.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drive a node-native battle to seat B's turn, then ingest an opponent
PlayActions reveal frame (knownList[{idx,cardId,to:Field}], isPlayerSeat:false)
matching battle_test_cl2's wire shape. The engine's ReplaceReceivedCard.ReplaceCard
-> CreateActualCard -> CreateBattleCardWithGameObject path resolves headless and
seats the substituted card on seat B's board with the wire cardId. No Engine/ logic
edits and no new view shims were needed — the card-creation view surface is fully
covered by the BackGround/icon-anim/play-queue/hand stubs from Tasks 2/3.
Adds InPlayCardId(seat, boardPos) accessor (SessionBattleEngine + harness) to read a
seated in-play follower's true identity, leader-excluded like BoardCount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>