feat(cg): the cast — members, ordering, mute, per-member chains, one likeness at a time #219
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/cg-cast"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 4 of the event-CG arc: the Cast tab fills in (ADR 0047/0048).
The model
CgCastMember— a live link (ADR 0046) to a character and optionally one of their outfits, ordered by position (which is the prompt order), mutable without loss viaEnabled, with own tags and a disable list scoped to its own resolved chain — ADR 0047's "the slot is the row", so two members carrying the same tag never collide. The chain arrives resolved (outfit.EffectiveTags(character.BaseTags), or the base tags bare) on the DTO, because the editor must show the effective set or the checkboxes mean nothing.Likeness, at most one (ADR 0048). Each member carries the outfit's controls (default off, 0.65/1.0). Enabling one member's flag clears every sibling's in the same store save — the invariant lives in the domain path, so an assistant tool call setting two flags still yields one, and the blend ADR 0035 warned about cannot happen. The reducer mirrors the rule so the swap is visible: tick Ren, watch Aoi's badge clear.
The generator
Each enabled member becomes its own V4 group in cast order — curated body attributes lead it, framing never applies (a CG composes its own). Every live link re-checks at enqueue: a trashed character, a genderless one, a vanished outfit, or a likeness whose sprite was never generated each block with a named reason before anything hits the queue. The one reference rides as ADR 0043's single
character&styleentry (dressed sprite when an outfit is linked, base sprite when not), read fresh in the worker's scope, with the project style image stepping back. The subjects freeze into the generation's snapshot (ADR 0049), so a render stays answerable after the cast moves on.The Cast tab (ADR 0054)
DragReorderListof collapsible member cards: outfit picker, "in the shot" mute, per-memberTagAuthoringover the resolved chain, likeness checkbox + sliders with an in-context warning when the reference sprite doesn't exist yet, and inline warnings for trashed/genderless characters. The Tags tab also gains the variation's style-reference controls (ADR 0045) — they had no home since the redesign.CgCastMemberjoinedEntityKinds; cast changes reload an idle editor cross-session.Contracts
add/update/delete_cg_cast_member(the partial edit threads the likeness tuning — the silent-reset edge), andget_cg_variationreports the full cast with resolved chains.Tests (all layers, in the PR from the start this time)
CgCastMemberTests(real SQLite): profile round-trip incl. likeness scalars + EF defaults on a fresh read, the exclusivity persisted across sibling rows in one save, position order + stale-reorder refusal, soft-delete + trashed-character stub.Found by the browser pass, not by tests
person_addicon isn't in Kagaku.UI's catalog — the completeness guard caught it; it's the cataloguedaddnow.TagAuthoring's row grid had a minimum. Both stretchy columns now carry a floor, so names truncate instead of vanishing; wide layouts are unchanged.Full suite: 1760 green (312 / 798 / 650).
🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagura.BlazorAdapter - 88.5%
Kagura.Domain - 94.5%
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
Kagura.Infrastructure - 97.7%
n
on
ng
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ArtifactTimestampRegex_2
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__ProjectRoute_0
C689CA5BBE6D1849B2CA79581BBB07D40636D85790C0E51A9__TabQuery_1
Kagura.Kernel - 90%
Kagura.Server - 80.9%
Kagura.UseCases - 95.2%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Oh! A cast of characters, each their own prompt group, likeness exclusivity enforced in the domain AND mirrored in the reducer, live links that survive their character's trash trip, subjects frozen onto the render — this is a beautifully composed slice~ ♡ I read every line of the diff and every changed file's full context, and Jibril is pleased.
Verdict: ✅ Looks good to me~
No blockers. Not one~ ♪ This is dense, architecturally consistent work that honors its ADRs at every seam. Let me show you what I checked~
🔍 What I scrutinized hard (and it held firm)
The likeness exclusivity (ADR 0048) — the crown jewel of this PR. I traced it through every layer:
EfCgCastMemberStore.ApplyAsync(lines 39-48): clears siblings in the sameSaveChangeswhenprofile.UseLikeness && !member.UseLikeness. The!member.UseLikenessguard means re-saving an already-on member doesn't pointlessly clear siblings. Correct~OnCastMemberSaved, line 238):action.Member.UseLikeness && m.UseLikeness ? m with { UseLikeness = false }— clears the visible siblings so the swap is instant on screen. The domain owns the invariant; the reducer just reflects it. Sharp~Enabling_one_members_likeness_clears_the_siblings_in_the_same_saveasserts[false, true]across two members in a fresh scope. Through the assistant tools too:The_cg_cast_is_read_edited_and_pruned_through_the_agentdrives exclusivity viaupdate_cg_cast_memberand confirms[false, true, false]. Every path covered~The partial-edit silent-reset edge (the sharpest blade) —
UpdateCgCastMemberTool(lines 98-105) threads all seven profile fields through?? current.X, includingLikenessStrengthandLikenessFidelity. The comment names the danger explicitly. The testA_partial_edit_keeps_everythingmutes Ren and assertsUseLikeness,DisabledInheritedTags, ANDOwnTagsall survive. This is exactly the discipline AGENTS.md demands~The generator's precondition gauntlet —
CgVariationImageGenerator.GenerateAsyncre-checks every live link at enqueue time: trashed character (line 70-72), genderless character (74-78), vanished/mismatched outfit (84-88), likeness without a sprite (107-111). Each blocks with a named reason before anything hits the queue. Four integration tests pin each branch. The one likeness reference rides as ADR 0043's singlecharacter&styleentry (line 160), project style steps back (line 156), subjects freeze into the snapshot (line 188). Textbook~The migration — I compared
CgCastMemberConfigurationfield-by-field against the migration AND the model snapshot. All 16 columns match, the fourHasDefaultValues (Enabled=true,UseLikeness=false,LikenessStrength=0.65m,LikenessFidelity=1.0m) are present in both the migration and snapshot, the two indexes (CgVariationId,ProjectId) are correct, and the deliberate no-FK decision is documented with ADR 0046/0047 justification in the configuration (lines 24-28). Ticks converter on timestamps matches every sibling. Clean~The live-link survival —
CgCastMemberDto.From(line 36-67) handles a null character gracefully: stub name(missing character), empty inherited tags,CharacterMissing: true. The editor shows warnings (card lines 19-26), generation blocks with a reason, and the row survives the trash trip. Tested:A_removed_member_vanishes_and_a_trashed_character_leaves_a_visible_stub. ♡💡 Little ideas (non-blocking)~
AddCgCastMemberposition assignment (CgCastMemberUseCases.cs:33) —position: cast.Countmeans a member added after a soft-delete takes a position that may collide with or precede a later member's (e.g., cast [A@0, B@1], delete A, add C → C gets position 1, same as B).OrderBy(Position)keeps them ordered so it's cosmetic, and the reorder path canonicalizes — but the siblingAddSceneStepusesexisting.Max(s => s.SortOrder) + 1which avoids the collision. Consider mirroring that pattern for consistency~ TheFakeCgCastMemberStore.ReorderAsyncalso has a subtleIndexOfquirk (line 58) but it's test-only and harmless.ListCgCastMembersN+1 (CgCastMemberUseCases.cs:50-55) — oneFindAsyncper member for character + outfit. The cast is always small (a CG has a handful of subjects), so it's negligible in practice — but if the cast ever grows, a batched read or a single join would be kinder. Sibling list use cases (ListOutfits,ListCharacters) are single-query, but they don't resolve cross-aggregate live links, so the pattern difference is inherent to this design.CgCastMemberCardoutfit picker shows outfits forMember.CharacterId— but if the character is trashed (CharacterMissing), the outfit dropdown still renders with whatever was loaded. Harmless (the outfit was chosen before the trash trip and persists), but a disabled picker with a note might be kinder. Truly a nicety~✅ What I liked~
detailsopen-when-muted trick (CgCastMemberCard.razor:16) —open="@(!Member.Enabled ? null : "")"— a muted member starts collapsed so the warning is scannable. Elegant~Automated review by Jibril · 2026-08-05
CI/CD: absent for head
443babd(no coverage bot yet) · Local checks: build 0 warnings/0 errors (.NET 10), 87 relevant tests pass (12 CgCastMember+CgVariationImage + 42 adapter + 33 AssistantWriteTools/SeedDevData)Took 💡1 and 💡3 in
e866afd:💡 1 —
AddCgCastMemberassignsMax(Position) + 1(theAddSceneSteppattern) instead ofCount, with a comment naming the collision. Pinned byA_member_added_after_a_removal_never_collides_with_a_survivors_position— delete the first of two, cast a third, assert distinct positions and the newcomer at the end. The fake store'sReorderAsyncalso lost its per-iterationToList().IndexOfin favour of the same index dictionary the real store uses.💡 3 — the outfit picker gets
disabled="@Member.CharacterMissing"— the stored choice still shows, but a picker over a trashed character's outfits no longer invites edits the generator would refuse.💡 2 (the N+1) — leaving as is, agreed with your own framing: the cast is bounded by how many people fit in one composition, and the per-member
FindAsyncpair is the price of resolving cross-aggregate live links at read time — the thing this design exists to do. If a batched read ever matters, the seam to widen is the store, not the use case.CG suites re-ran green (5 cast integration / 118 adapter).
🔮 fufu~ Jibril reviewed your code!
Back so soon~? ♡ The author returns with a tidy little surgical commit that picks up exactly two of my little ideas and leaves the third on the table with a reason. This is how non-blocking suggestions should be answered — precise, scoped, tested. Jibril approves~
Verdict: ✅ Looks good to me~
No blockers. The increment is +27/-3 across 4 files (2 src, 2 test), zero scope creep. Let me show you what I traced~
🔍 What I scrutinized (and it held firm)
💡 1 — the position collision fix (
CgCastMemberUseCases.cs:30-34) — beautifully done.cast.Count→cast.Count == 0 ? 0 : cast.Max(m => m.Position) + 1mirrors theAddSceneStepsibling exactly (existing.Count == 0 ? 0 : existing.Max(s => s.SortOrder) + 1), same empty-guard, sameMax+1. I confirmed the collision is real, not cosmetic:CgCastMemberConfigurationcarriesHasQueryFilter(m => !m.IsDeleted), so soft-deleted members are excluded from both theCountand theMax— meaning the old Count-based logic genuinely handed a newcomer a position an existing survivor already held. The comment names the danger. ♡The new test is directional, not a tautology —
A_member_added_after_a_removal_never_collides_with_a_survivors_positionseeds [Aoi@0, Ren@1], soft-deletes Aoi, re-adds Aoi, then assertscast.Count == cast.Select(m => m.Position).Distinct().Count()(distinct positions) AND the newcomer lands at the end. Without the fix, Count=1 → position 1 → collides with Ren@1 → the distinct-count assertion fails. I traced it both ways. Genuine~The fake store's
ReorderAsyncrefactor (FakeCgCastMemberStore.cs:56-62) — the per-iterationorderedIds.ToList().IndexOf(member.Id)(O(n) lookup, O(n²) per reorder, and a freshToListallocation every loop iteration) is replaced by the same pre-builtmemberId → indexdictionary the realEfCgCastMemberStore.ReorderAsyncuses (lines 64-65). Cleaner, faster, and the test double now mirrors the production store's algorithm instead of improvising its own. The guard above it is preserved (members.Count != orderedIds.Count || members.Any(m => !orderedIds.Contains(m.Id))). All 16 Cast adapter tests still pass — no regression in the fake's consumers~💡 3 — the disabled outfit picker (
CgCastMemberCard.razor:44-45) —disabled="@Member.CharacterMissing"on the<select>. The storedvaluestill renders (so the last-chosen outfit remains visible), but the picker no longer invites edits the generator would refuse anyway. Elegant and minimal — one attribute, no JS, no wrapper div. Exactly the shape a trashed-character guard should take~💡 2 (the N+1) declined with reason — and I agree with the author's framing. The cast is bounded by how many subjects fit in one composition; the per-member
FindAsyncpair is the inherent price of resolving cross-aggregate live links at read time — the very thing ADR 0046 exists to do. A batched read, if it ever matters, widens the store port, not the use case. Sound architectural judgment~✅ What I liked~
Max+1, byte-for-byte theAddSceneStepshape. Consistency is love~ ♡IndexOfimprovisation. The test double is honest again~Automated review by Jibril · 2026-08-05
CI/CD: stale for
e866afd(coverage bot 5769 covers prior443babdonly) · Local checks: build 0 warnings/0 errors (.NET 10), 13/13 CgCastMember+CgVariationImage integration tests pass (incl. new collision test), 16/16 Cast adapter tests pass