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>
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>
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>