C1: replay_detail — flatten stored payload to data level. ChatReplayDetailTask.Parse() calls
new ReplayDetailInfo(data) which unguardedly accesses data[battleId], data[seed], data[vid1],
etc. Wrapping under replay_info key crashes the client. Controller now returns Ok(JsonElement)
directly so stored battle fields are at data root. Wire-shape test added.
C2: Guild.LeaderViewerId long to long?; add HasOne<Viewer> FK with OnDelete=SetNull; migration
AddGuildLeaderViewerIdFk; all consumers null-guarded with ?? 0L.
C3: BreakupAsync — wrap 6 destructive ops in IDbContextTransaction with InMemory fallback.
C4: CommitJoinAsync — wrap member-add + viewer-guildId-set + invite/request cleanup in
IDbContextTransaction with InMemory fallback. Chat event emitted after commit.
I1: GuildController — remove SVSimDbContext field; inject IViewerRepository + IGuildMemberRepository.
Add IGuildMemberRepository.GetViewerIdsInAGuildAsync (batch guild-membership check).
All _db.Viewers / _db.GuildMembers queries replaced with repo calls.
I2: GuildService — extract 3 _db.Viewers queries: GetEquippedEmblemIdAsync (CreateAsync),
LoadGuildProfileBatchAsync (ListOutgoingInvitesAsync + ListPendingJoinRequestsForMyGuildAsync).
Add GuildMemberProfile record with IsOfficialMarkDisplayed. GetEmblemListAsync for EmblemList.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove StringifiedIntConverter from GuildChatMessagesResponse.WaitInterval so the
wire emits a raw JSON number (3) matching prod, not a quoted string ("3").
Update GuildChatPollTests (2 assertions) and GuildCaptureReplayTests (1 assertion +
comment) to expect JsonValueKind.Number. Fix last_message_id → start_message_id typo
in GuildChatMessages_Member_ReturnsProdShape request body.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
5 integration tests in GuildCaptureReplayTests replay the prod guild/info (pre-join +
post-create), guild/search_guild, guild/emblem_list, and guild_chat/messages flows
against the in-process WebApplicationFactory server.
Assertions are limited to always-present, session-independent fields:
- guild/info pre-join: guild_status=0, max_member_num=30,
max_sub_leader_num=2, usable_stamp_list=[100001..100020] (exact match)
- guild/info post-create: guild_status=2, guild object present
- guild/search_guild: list field is a JSON array
- guild/emblem_list: guild_emblem_list field is a JSON array
- guild_chat/messages: chat_message[] + maintenance_card_list[] arrays present,
wait_interval > 0 (accepts either raw int or stringified form)
Bodies are AES-encrypted in the capture, so requests are re-issued as plain JSON
to the test server (same shape, different auth context). Manual end-to-end (live
Steam client + clean Postgres) deferred to operator — can't automate in this session.
1534 → 1539 tests; 0 failures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace GuildNotification TODO stub in MyPageController.Index with
BuildGuildNotificationAsync: populates guild_id, guild_room_message_id,
is_join_request, is_invited from real DB state.
- Inject IGuildService, IGuildInviteRepository, IGuildJoinRequestRepository,
IGuildChatMessageRepository into MyPageController.
- Add GetMaxMessageIdSafelyAsync to IGuildChatMessageRepository (returns null
when no messages exist, vs GetMaxMessageIdAsync which returns 0).
- LoadDetail.cs audit: grep returned no guild fields — no LoadController changes.
- Add GuildCrossSurfaceTests (4 tests): guild member, pending invite, pending
join request, no-guild viewer all verified green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement all five guild_chat attachment endpoints:
- add_deck: server looks up deck by (viewerId, format, deckNo) and snapshots
the current state into a DECK-type chat message DeckPayload column.
- delete_deck: author OR leader/sub-leader may clear; returns refreshed
deck_log. Extended IGuildChatMessageRepository.ClearDeckAsync with
leaderOverride flag; added GetDeckMessagesAsync + GetByMessageIdAsync.
- add_replay: stores {"battle_id": N} in ReplayPayload; full shape is
TODO(post-merge) pending replay-subsystem capture.
- replay_detail: returns the stored ReplayPayload as JsonElement passthrough.
- deck_log: returns all active DECK messages grouped by API-side Format string
key ("1"=Rotation, "2"=Unlimited, "3"=PreRotation always present;
"4"=Crossover, "5"=MyRotation only when non-empty).
Adds DeckLogDataDto (typed) for deck_log / delete_deck responses.
Replay response uses JsonElement passthrough (TODO(post-merge)).
8 new integration tests — all green. Full suite 1530/1530 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements PostTextOrStampAsync: validates membership (NotInGuild),
validates stamp id against UsableStampList (PermissionDenied), rejects
type other than 0 or 1, allocates next per-guild message_id, appends row.
Wires /guild_chat/post controller action mapping failures to result_code=2.
Response is GuildChatPostResponse (empty body per spec). 7 integration tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add ChatUserProfile record and IViewerRepository.LoadChatProfilesAsync (AsNoTracking,
projects Name/EmblemId/CountryCode/Rank/DegreeId from Viewer+Info nav refs).
GuildChatController.Messages now injects IViewerRepository instead of SVSimDbContext;
calls LoadChatProfilesAsync to populate users[] (removes direct db access from controller).
GuildChatMessageRepository direction=2 (NEW): change >= start to > start (exclusive).
GuildChatPollTests updated to assert the start message is NOT returned by direction=NEW.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GuildChatService.EmitSystemEventAsync: replaces no-op stub with real
chat-row insertion via _msgs.AppendAsync (per-guild monotonic id).
- GuildChatService.GetWindowAsync: real implementation — verifies caller
membership, delegates to IGuildChatMessageRepository.GetWindowAsync
(direction 1=OLD/2=NEW/3=BOTH, limit 50), returns adaptive wait interval
(ChatPollActiveSeconds when messages returned, ChatPollIdleSeconds otherwise).
- Drop unused repo injections from GuildChatService (guilds, invites, joinRequests).
- GuildChatController.Messages: wires the full response — chat_message[],
users[] (deduplicated author profiles with Emblem+Degree nav includes),
maintenance_card_list (empty until maintenance service lands), wait_interval.
- GuildChatPollTests (7 integration tests): fresh-guild CreateGuild event,
sequenced Create+Join ordering, direction=NEW filter, direction=BOTH window,
users[] deduplication, empty result for viewer-not-in-guild, active vs idle
interval comparison. All 7 pass; full suite 1515/1515.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GuildFriendListTask.Parse() reads base.ResponseData[data][i] directly —
data must be a bare JSON array, not {friends:[...]}. Delete the
GuildFriendListResponse wrapper DTO; promote GuildInviteCandidateDto to its
own file. Controller now returns List<GuildInviteCandidateDto> so the
middleware wraps it as data:[…]. Update wire-shape and integration tests to
assert array-at-root.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ChangeRoleAsync: Leader-only role management with SubLeader cap check.
Atomic leader transfer loads both member rows + Guild.LeaderViewerId in
one _db.SaveChangesAsync call (no split saves). Emits ChangeLeader (6)
or ChangeSubLeader (7) chat events. Same-role is no-op / returns Ok.
- /guild/change_role controller: calls ChangeRoleAsync, returns full updated
member list (GuildChangeRoleResponse.Members[]).
- /guild/friend_list controller: calls IFriendService.GetFriendsAsync, then
annotates each friend with is_join_guild from Viewer.GuildId column.
- IGuildRepository.UpdateLeaderViewerIdAsync: added for completeness even
though the atomic transfer path bypasses it via direct EF mutation.
- Tests: 8 GuildServiceChangeRoleTests (role changes, cap, atomic transfer,
no-op, permission); 3 GuildChangeRoleFriendListFlowTests (HTTP roundtrip,
is_join_guild); 2 GuildWireShape tests (literal JSON shape); 1508 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LeaveAsync: regular/subleader removes self; leader blocked if members remain;
sole-member leader auto-routes to BreakupAsync.
- RemoveAsync: leader-only kick (SubLeader has no remove authority per
GuildManager.cs decompile); self-remove and cross-guild target rejected.
- GuildController: wired /guild/leave (BaseRequest) and /guild/remove
(GuildRemoveRequest.remove_viewer_id field).
- SpyGuildChatService: extended Emissions with EmissionsWithBody tuple to
allow body-content assertions in leave/remove tests.
- 8 new integration tests; all 1493 pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GuildJoinTask.Parse() reads data[guild_status].ToInt() directly; hardcoding 2
for the APPROVAL path was wrong. GuildOpResult gains nullable GuildStatus; JoinAsync
sets 1 (APPLYING) for the pending-request path and 2 (JOINING) for instant joins.
Controller reads r.GuildStatus. Two new tests assert the wire value per path.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements three join-path state machine endpoints:
- /guild/join: FREE (instant), APPROVAL (pending row + idempotent re-apply),
ONLY_INVITE (pending invite required); CommitJoin consumes invites + cancels
join requests; MaxMemberNum cap enforced.
- /guild/cancel_join_request: cancels all pending requests for caller (no
request_id on wire per GuildJoinRequestCancelTask decompile, composite PK kept).
- /guild/join_request_list: leader/subleader sees pending applicants with
viewer profile (name/emblem/degree/request_time unix secs).
IGuildService.CancelJoinRequestAsync signature changed from (viewerId, guildId)
to (viewerId) since the client sends no guildId. GuildJoinRequestEntry enriched
record added to GuildServiceTypes. Re-apply after cancel resets existing row
in-place to avoid composite-PK conflict. 11 integration tests, all green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Partial index (filter: "Status" = 0) replaces the unconditional unique
index on (GuildId, InviteeViewerId). Old Rejected/Canceled rows no
longer conflict when a new Pending invite is inserted for the same pair.
Adds regression tests: Reinvite_after_reject_succeeds and
Reinvite_after_cancel_succeeds.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add IViewerRepository.LoadDisplayNamesAsync (batch AsNoTracking select),
extend GuildSearchEntry with LeaderName, enrich GuildService.SearchAsync
with a leader-id batch lookup, and thread the name through the controller's
ToDetailDto call. Adds a leader_name assertion to GuildSearchFlowTests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GuildUpdateTask.Parse() reads data[guild] as GuildDetailInfo directly; the previous
GuildDetailSubTree wrapper (data[guild][detail]) caused json[guild_id].ToInt() to
crash on the client. Split GuildUpdateResponse (flat) from GuildUpdateEmblemResponse
(nested, correct for GuildEmblemUpdateTask.Parse). Also surfaces guild_name from the
request through UpdateGuildRequest.Name -> service -> repo so the client rename is honoured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GuildEmblemListTask.Parse reads jsonData[i][emblem_id].ToLong(),
confirming shape (b): array of objects with emblem_id. The existing
List<GuildEmblemEntry> DTO was already correct. Add
EmblemList_serializes_as_array_of_objects_with_emblem_id test to
GuildWireShape to lock the shape in.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace double-correlated COUNT subqueries in the member-bucket switch
with a project-filter-project pattern that computes the count once per
row. Add SearchAsync_bucket_2_matches_guilds_with_11_to_25_members to
cover the previously untested medium-bucket range.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When missions/achievements shrink between re-imports, Pass B and Pass C
now compute the set of (EventKey, Period) counter keys from OLD rows
(before Clear()) and delete any that are not referenced by the new
payload. Prevents stale counters accumulating across import cycles.
TDD: two new failing tests written first (RED), then the fix (GREEN).
All 1383 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pre-materialize ViewerEventCounters before the mission loop so in-flight
Adds are visible to subsequent iterations sharing the same (EventKey, Period);
add optional ImportMission.Slot field so callers can supply weekly slot identity
(2 or 3) rather than relying on the LotType-derived default of 1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Injects ILoginBonusService into MyPageController and sets
CanGiveDailyLoginBonus from IsDue(viewer) in the Index response.
Adds two integration tests: fresh viewer → true, post-load-index → false.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GrantIfDueAsync advances streak (1..15 cycle), grants the day's reward via
inventory tx, returns wire-shape DailyLoginBonus. IsDue helper for MyPage.
Also fixes GameConfigurationJsonbTests section-count (15→16) which was broken
since Task 3 added the LoginBonus [ConfigSection] but the assertion wasn't updated.
- numeric fields ship as quoted strings (campaign_id, img, effect_id,
reward_type, reward_detail_id, reward_number)
- DailyLoginBonus.Campaign is a List<>, not a single object
- LoginBonusCampaign.Img replaces Image (string, asset-key)
- add IsOneDayMultiRewards (SpecialData-only on client, byte-faithful)
The captured shape ({"1":[], "3":[], "4":[]}) is the empty-period
wire echo; the client parser (LoadDetail.cs:553) only reads normal/total/
campaign. Table, entity, seed, and importer were never wired to a code path.
Replaced by config-section catalog + service in the follow-up commits.
Three fixups the Phase 1-3 work missed (would bite the first time an admin
flipped on a real event):
* IsDisplayTips + TipsId added to ColosseumSeasonConfig. Drives
Wizard/ColosseumEntryInfoTask.cs:129 (NeedsFirstTips = is_display_tips == 1).
The wire DTO already had the properties; the season config didn't carry
them, so admins had no way to gate the first-time tutorial tip popup.
* colosseum_id is now emitted from SeasonId. Wire field was always-absent
before (DTO had the property but builder never set it); client fell back
to 0 and couldn't disambiguate cups.
* ColosseumLobbyInfoBuilder.Build now takes ColosseumRoundsConfig + nowUtc
and derives the wire is_round_period / now_round / next_round / start_time
/ end_time from the rounds schedule against the current clock. Previous
hardcode (is_round_period: true, now_round: 1, event-window dates) would
have shown "Round 1 ends 2026-07-31" instead of the actual round's end.
Per the client parser at ColosseumEntryInfoTask.cs:111-126:
- inside a round → emit that round's window + is_round_period=true
- between rounds → emit next round's start as start_time + next_round
- pre-event → next_round=1, start_time=event start
- post-event → anchor to last round so the client doesn't NRE
- no rounds configured → fall back to event-level window
Why these were missed: I implemented ColosseumSeasonConfig from the plan's
explicit field list rather than cross-checking against the captured
colosseum.json wire shape; I added ColosseumId to the wire DTO without
wiring the builder side; and no test exercised a mid-event scenario with
realistic dates — Phase 2's E2E hand-edits run.RoundId via
PromoteRunToRoundAsync instead of letting the controller derive it.
* 10 new builder unit tests cover off-season / mid-round / between-rounds /
pre-event / post-event / empty-rounds-fallback + the new field surfacing.
Full suite: 1355/1355 (was 1345 + 10).
/mypage/index data.colosseum_info and /arena_colosseum/{top,get_fee_info}
now both project through ColosseumLobbyInfoBuilder.Build(ColosseumSeasonConfig).
One source of truth for the home-tab gating and the lobby reads — admins
flip the season on by writing to GameConfigs (per the runbook), no second
table to keep in sync.
* New ColosseumLobbyInfoBuilder — static projection from ColosseumSeasonConfig
to the wire ColosseumLobbyInfo. Replaces ArenaColosseumController's private
BuildColosseumInfo and MyPageController's BuildColosseumInfo(ColosseumConfig).
* MyPageController reads ColosseumSeasonConfig via IGameConfigService; the
IGlobalsRepository.GetCurrentColosseum() call goes away.
* MyPageIndexResponse.ColosseumInfo retyped to the new ColosseumLobbyInfo.
* ColosseumSalesPeriodInfo relocated into the ArenaColosseum namespace
alongside the rest of the family DTOs.
* Drops: Colosseums DB table (migration DropColosseumsTable), ColosseumConfig
entity, the captured-shape Models.Dtos.ColosseumInfo DTO, ColosseumSeed,
MyPageGlobalsImporter.ImportColosseumAsync + seed file colosseum.json,
IGlobalsRepository.GetCurrentColosseum + impl, the two related tests.
Net: home-screen Grand Prix tab now hidden by default because the season
ships with IsColosseumPeriod=false. Full suite: 1345/1345 (was 1347 — the
2 dropped tests covered the now-dead surface).