Skip to content

Commit 0c6d284

Browse files
committed
feat: NPC Viewer / Hall plugin with Foundry visibility sync (Sprint F-5)
New NPC gallery plugin at internal/plugins/npcs/ — a view layer over the existing entities system that shows revealed character entities as a visual directory. Includes gallery page with search/sort/pagination, reveal toggle for DMs, npc_gallery layout block, and bidirectional Foundry VTT visibility sync (Chronicle is_private ↔ Foundry prototypeToken.hidden). Added TogglePrivate/UpdatePrivate to entity service/repository. New REST API endpoint POST /entities/:id/reveal for external tool integration. 7 tests. https://claude.ai/code/session_01PeB1HsjEYNPSY2iqa7sqqR
1 parent 4371e0c commit 0c6d284

19 files changed

Lines changed: 1238 additions & 4 deletions

File tree

.ai/status.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@
88
<!-- ====================================================================== -->
99

1010
## Last Updated
11-
2026-03-12 -- **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling.**
11+
2026-03-12 -- **Sprint F-5: NPC Viewer / Hall.**
12+
13+
39. **Sprint F-5: NPC Viewer / Hall (DONE).**
14+
- **NPC plugin** — New `internal/plugins/npcs/` with model/repo/service/handler/templates/routes. View-layer plugin — no new database tables, queries existing `entities` table filtered by character type + visibility.
15+
- **Gallery page**`/campaigns/:id/npcs` shows a responsive grid of revealed character entities. Search by name (debounced HTMX), sort (name/updated/created), tag filter, pagination. Players see non-private characters; Scribes/Owners see all.
16+
- **NPC card** — Portrait (aspect 3:4 or placeholder), name, type label/race/class, tags. Responsive grid (2→6 columns).
17+
- **Reveal toggle** — Eye icon on each card (Scribe+). Toggles `is_private` via `POST /npcs/:eid/reveal` with HTMX swap. Green eye = visible, red eye-slash = hidden.
18+
- **Entity service** — Added `TogglePrivate(entityID)` to `EntityService` interface. New `UpdatePrivate(entityID, isPrivate)` on `EntityRepository`. Publishes entity "updated" event via WebSocket.
19+
- **npc_gallery layout block** — Registered in block registry (no addon required). Shows compact 3-4 column grid of up to 8 NPCs with "View all" link. Configurable via `{"limit": N}`.
20+
- **Foundry sync** — Bidirectional NPC visibility sync. Chronicle→Foundry: entity `is_private` change updates actor's `prototypeToken.hidden`. Foundry→Chronicle: actor hidden toggle calls `POST /entities/:id/reveal` API.
21+
- **Sync API** — New `POST /api/v1/campaigns/:id/entities/:entityID/reveal` endpoint. Accepts `{"is_private": bool}` or toggles if omitted. Verifies entity belongs to campaign.
22+
- **Tests** — 7 tests: `ListNPCs_ReturnsCards`, `ListNPCs_EmptyWhenNoCharacterType`, `CountNPCs`, `NPCListOptions_Offset`, `NPCListOptions_OrderByClause`, `NPCCard_FieldString`, `TogglePrivate_EntityService`. All pass.
23+
- **Wiring**`npcEntityTypeFinderAdapter` and `npcVisibilityTogglerAdapter` in `app/routes.go`. Routes registered with `RequireRole(Player)` for view, `RequireRole(Scribe)` for reveal.
1224

1325
38. **Sprint F-QoL: Foundry Sync Diagnostics (DONE).**
1426
- **Validation report** — New `ValidationReport` type and `BuildValidationReport()` on `SystemManifest`. Analyzes categories, fields, presets, Foundry compatibility, mapped/writable fields, and generates warnings. Shown in custom system section after upload.

.ai/todo.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ _WASM-sandboxed backend logic via Extism/wazero. See ADR-021._
279279
- [x] **Sprint R-3: Write Host Functions** — 6 write host functions (update_entity_fields, create_event, set_entity_tags, get_entity_tags, create_relation, send_message). 5 new capabilities. 4 write adapters. Plugin-to-plugin async messaging. 10 new tests (48 total).
280280
- [x] **Sprint R-4: Plugin SDK & Developer Tools** — Example WASM plugins (Rust auto-tagger, Go session-logger). Go SDK with MockHost test harness (9 tests). Plugin development guide. 7 new manifest tests. **Phase R complete.**
281281

282-
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-5 next)
282+
### Phase F: Foundry Sync Enhancements & Character Integration ← CURRENT (F-6 next)
283283

284284
_Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build toward inventory/NPC features._
285285

@@ -289,7 +289,7 @@ _Improve Foundry VTT sync fidelity. Add system-aware character sheet sync. Build
289289
- [x] **Sprint F-4: Actor ↔ Entity Sync**`actor-sync.mjs` with bidirectional Actor ↔ entity sync. System adapters: `dnd5e-adapter.mjs` (15 fields), `pf2e-adapter.mjs` (HP/name back only). Dashboard Characters tab with Push button. Registered in module.mjs. TESTING.md updated. **Note: adapters and SYSTEM_MAP are hardcoded — see F-4.5.**
290290
- [x] **Sprint F-4.5: Generic System Adapter & Dynamic Matching** — Added `foundry_system_id` to manifest, `foundry_path`/`foundry_writable` to FieldDef. New `GET /systems/:id/character-fields` API. `_detectSystem()` now API-driven (matches by `foundry_system_id`). New `generic-adapter.mjs` auto-generates field mappings from API. dnd5e (15 fields), pf2e (15 fields, most read-only), drawsteel annotated. actor-sync.mjs falls back to generic adapter. 7 new tests.
291291
- [x] **Sprint F-QoL: Foundry Sync Diagnostics & Error Handling**`ValidationReport` type with `BuildValidationReport()` analyzing categories, fields, presets, Foundry compatibility, warnings. Templ component shows capability badges + warnings after upload. API client health metrics (success/error counts, uptime, reconnect attempts). Structured error log. Retry queue for failed writes (processes on reconnect, max 3 retries). Dashboard Status tab: diagnostics grid, error log, field mapping debug info. 3 new tests.
292-
- [ ] **Sprint F-5: NPC Viewer / Hall (Plugin + Widget + Foundry Sync)** — Full NPC plugin at `internal/plugins/npcs/` with handler/service/repo/templates. Campaign route `/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private character entities). Portrait, name, description, location, faction. Filters by location/organization/relation. "Reveal" = DM toggles visibility. `npc_gallery` layout block type for entity pages and dashboards. Foundry sync: NPC visibility changes sync bidirectionally — DM reveals/hides NPC in Foundry → Chronicle entity visibility updates and vice versa via actor-sync. Long-term: NPC relationship map (filtered relation graph).
292+
- [x] **Sprint F-5: NPC Viewer / Hall (Plugin + Widget + Foundry Sync)** — Full NPC plugin at `internal/plugins/npcs/` with handler/service/repo/templates. Campaign route `/campaigns/:id/npcs` — gallery/grid of revealed NPCs (non-private character entities). Portrait, name, type label, tags. Search/sort/pagination. "Reveal" = Scribe toggles `is_private` via eye icon. `npc_gallery` layout block type for entity pages and dashboards. Foundry sync: NPC visibility changes sync bidirectionally — Chronicle `is_private`Foundry `prototypeToken.hidden`. REST API endpoint `POST /entities/:id/reveal`. 7 tests.
293293
- [ ] **Sprint F-6: Armory / Inventory System** — Items as entities with game-mechanic fields (weight, cost, rarity, damage, properties). Character "Inventory" tab/block via entity relations. Relation metadata: equipped, quantity, attunement. System-specific item templates (dnd5e ≠ pf2e). Foundry sync: Actor inventory ↔ Chronicle inventory relations. "Armory" campaign page showing all catalogued items.
294294
- [ ] **Sprint F-7: Shop / Marketplace Enhancement** — Transaction logging (who bought what, when). Currency tracking per character. Stock management (auto-deplete on purchase). Foundry: purchase from shop window → update character inventory on both sides.
295295

foundry-module/scripts/actor-sync.mjs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,17 @@ export class ActorSync {
254254
await actor.update(fieldUpdate);
255255
}
256256

257+
// Sync visibility: Chronicle is_private → Foundry actor hidden.
258+
// A private entity means the NPC is hidden from players.
259+
const shouldBeHidden = entity.is_private === true;
260+
if (actor.hidden !== shouldBeHidden) {
261+
// Update the actor's default token hidden state and active tokens.
262+
await actor.update({ 'prototypeToken.hidden': shouldBeHidden });
263+
console.log(
264+
`Chronicle: ${shouldBeHidden ? 'Hid' : 'Revealed'} actor "${actor.name}" (visibility sync)`
265+
);
266+
}
267+
257268
// Update sync timestamp.
258269
await actor.setFlag(FLAG_SCOPE, 'lastSync', new Date().toISOString());
259270

@@ -364,7 +375,27 @@ export class ActorSync {
364375
const entityId = actor.getFlag(FLAG_SCOPE, 'entityId');
365376
if (!entityId) return;
366377

367-
// Only push if system data or name changed.
378+
// Sync visibility: Foundry prototypeToken.hidden → Chronicle is_private.
379+
const hiddenChanged =
380+
change.prototypeToken?.hidden !== undefined ||
381+
change.token?.hidden !== undefined;
382+
383+
if (hiddenChanged) {
384+
try {
385+
const isHidden =
386+
change.prototypeToken?.hidden ?? change.token?.hidden ?? false;
387+
await this._api.post(`/entities/${entityId}/reveal`, {
388+
is_private: isHidden,
389+
});
390+
console.log(
391+
`Chronicle: ${isHidden ? 'Hid' : 'Revealed'} entity for actor "${actor.name}" (Foundry → Chronicle)`
392+
);
393+
} catch (err) {
394+
console.error('Chronicle: Failed to sync visibility to Chronicle', err);
395+
}
396+
}
397+
398+
// Only push field/name changes if system data or name changed.
368399
if (!change.system && !change.name) return;
369400

370401
try {

internal/app/routes.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import (
3333
"github.com/keyxmakerx/chronicle/internal/templates/layouts"
3434
"github.com/keyxmakerx/chronicle/internal/templates/pages"
3535
ws "github.com/keyxmakerx/chronicle/internal/websocket"
36+
"github.com/keyxmakerx/chronicle/internal/plugins/npcs"
3637
"github.com/keyxmakerx/chronicle/internal/widgets/notes"
3738
"github.com/keyxmakerx/chronicle/internal/widgets/posts"
3839
"github.com/keyxmakerx/chronicle/internal/widgets/relations"
@@ -664,6 +665,33 @@ func (a *entityTypeListerForGraphAdapter) ListEntityTypesForGraph(ctx context.Co
664665
return result, nil
665666
}
666667

668+
// npcEntityTypeFinderAdapter wraps entities.EntityService to implement the
669+
// npcs.EntityTypeFinder interface. Resolves the "characters" entity type ID
670+
// for the NPC gallery without creating a circular import.
671+
type npcEntityTypeFinderAdapter struct {
672+
svc entities.EntityService
673+
}
674+
675+
// FindCharacterTypeID looks up the "characters" entity type for a campaign.
676+
func (a *npcEntityTypeFinderAdapter) FindCharacterTypeID(ctx context.Context, campaignID string) (int, error) {
677+
et, err := a.svc.GetEntityTypeBySlug(ctx, campaignID, "characters")
678+
if err != nil {
679+
return 0, err
680+
}
681+
return et.ID, nil
682+
}
683+
684+
// npcVisibilityTogglerAdapter wraps entities.EntityService to implement the
685+
// npcs.VisibilityToggler interface for the reveal toggle.
686+
type npcVisibilityTogglerAdapter struct {
687+
svc entities.EntityService
688+
}
689+
690+
// TogglePrivate flips an entity's is_private flag.
691+
func (a *npcVisibilityTogglerAdapter) TogglePrivate(ctx context.Context, entityID string) (bool, error) {
692+
return a.svc.TogglePrivate(ctx, entityID)
693+
}
694+
667695
// RegisterRoutes sets up all application routes. It registers public routes
668696
// directly and delegates to each plugin's route registration function.
669697
//
@@ -983,6 +1011,13 @@ func (a *App) RegisterRoutes() {
9831011
tagHandler := tags.NewHandler(tagService)
9841012
tags.RegisterRoutes(e, tagHandler, campaignService, authService)
9851013

1014+
// NPC plugin: gallery/hub view for revealed character entities.
1015+
npcRepo := npcs.NewNPCRepository(a.DB)
1016+
npcSvc := npcs.NewNPCService(npcRepo, &npcEntityTypeFinderAdapter{svc: entityService})
1017+
npcHandler := npcs.NewHandler(npcSvc)
1018+
npcHandler.SetVisibilityToggler(&npcVisibilityTogglerAdapter{svc: entityService})
1019+
npcs.RegisterRoutes(e, npcHandler, campaignService, authService)
1020+
9861021
// Notes widget: personal floating note-taking panel (Google Keep-style).
9871022
noteRepo := notes.NewNoteRepository(a.DB)
9881023
attRepo := notes.NewAttachmentRepository(a.DB)
@@ -1049,6 +1084,19 @@ func (a *App) RegisterRoutes() {
10491084
return maps.BlockMapPreview(ctx.CC, entities.BlockConfigString(ctx.Block.Config, "map_id"))
10501085
})
10511086

1087+
// NPC gallery block — embeds a compact NPC grid on entity pages/dashboards.
1088+
blockRegistry.Register(entities.BlockMeta{
1089+
Type: "npc_gallery", Label: "NPC Gallery", Icon: "fa-users",
1090+
Description: "Grid of revealed NPCs",
1091+
}, func(bctx entities.BlockRenderContext) templ.Component {
1092+
limit := entities.BlockConfigLimit(bctx.Block.Config, "limit", 8)
1093+
cards, err := npcHandler.GalleryBlock(context.Background(), bctx.CC.Campaign.ID, int(bctx.CC.MemberRole), "", limit)
1094+
if err != nil {
1095+
return templ.NopComponent
1096+
}
1097+
return npcs.BlockNPCGallery(bctx.CC, cards, limit)
1098+
})
1099+
10521100
// Set the registry on the entity service (validation) and as the global (rendering).
10531101
// The addon checker lets Render() skip blocks whose addon is disabled.
10541102
blockRegistry.SetAddonChecker(addonService)

internal/plugins/entities/repository.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,9 @@ type EntityRepository interface {
488488
// scanning entry_html for data-mention-id attributes. Each result is a
489489
// source→target pair. Used by the graph visualization to show mention edges.
490490
FindAllMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error)
491+
492+
// UpdatePrivate sets an entity's is_private flag. Used by the NPC reveal toggle.
493+
UpdatePrivate(ctx context.Context, entityID string, isPrivate bool) error
491494
}
492495

493496
// entityRepository implements EntityRepository with MariaDB queries.
@@ -1100,6 +1103,23 @@ func (r *entityRepository) UpdateSortOrder(ctx context.Context, entityID string,
11001103
return nil
11011104
}
11021105

1106+
// UpdatePrivate sets an entity's is_private flag. Used by the NPC reveal toggle.
1107+
func (r *entityRepository) UpdatePrivate(ctx context.Context, entityID string, isPrivate bool) error {
1108+
query := `UPDATE entities SET is_private = ?, updated_at = NOW() WHERE id = ?`
1109+
result, err := r.db.ExecContext(ctx, query, isPrivate, entityID)
1110+
if err != nil {
1111+
return fmt.Errorf("updating entity privacy: %w", err)
1112+
}
1113+
rows, err := result.RowsAffected()
1114+
if err != nil {
1115+
return fmt.Errorf("checking rows affected: %w", err)
1116+
}
1117+
if rows == 0 {
1118+
return apperror.NewNotFound("entity not found")
1119+
}
1120+
return nil
1121+
}
1122+
11031123
// FindBacklinks returns entities that mention the given entity via @mention links
11041124
// in their entry_html. Searches for the data-mention-id="<entityID>" attribute
11051125
// pattern. Respects visibility filtering.

internal/plugins/entities/service.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ type EntityService interface {
8686
// relations graph. Each link is a source→target pair extracted from entry_html.
8787
GetMentionLinks(ctx context.Context, campaignID string, role int, userID string) ([]MentionLink, error)
8888

89+
// TogglePrivate flips an entity's is_private flag and returns the new state.
90+
// Used by the NPC gallery reveal toggle.
91+
TogglePrivate(ctx context.Context, entityID string) (newPrivate bool, err error)
92+
8993
// Seeder (satisfies campaigns.EntityTypeSeeder interface).
9094
SeedDefaults(ctx context.Context, campaignID string) error
9195

@@ -536,6 +540,25 @@ func (s *entityService) Delete(ctx context.Context, entityID string) error {
536540
return nil
537541
}
538542

543+
// TogglePrivate flips an entity's is_private flag and returns the new state.
544+
func (s *entityService) TogglePrivate(ctx context.Context, entityID string) (bool, error) {
545+
entity, err := s.entities.FindByID(ctx, entityID)
546+
if err != nil {
547+
return false, err
548+
}
549+
550+
newPrivate := !entity.IsPrivate
551+
if err := s.entities.UpdatePrivate(ctx, entityID, newPrivate); err != nil {
552+
return false, err
553+
}
554+
555+
// Publish event so WebSocket clients see the visibility change.
556+
entity.IsPrivate = newPrivate
557+
s.events.PublishEntityEvent("updated", entity.CampaignID, entityID, entity)
558+
559+
return newPrivate, nil
560+
}
561+
539562
// --- Listing and Search ---
540563

541564
// List returns entities with pagination, optional type filter, and visibility enforcement.

internal/plugins/entities/service_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ type mockEntityRepo struct {
125125
updateParentFn func(ctx context.Context, entityID string, parentID *string) error
126126
findBacklinksFn func(ctx context.Context, entityID string, role int, userID string) ([]Entity, error)
127127
setAliasesFn func(ctx context.Context, entityID string, aliases []string) error
128+
updatePrivateFn func(ctx context.Context, entityID string, isPrivate bool) error
128129
}
129130

130131
func (m *mockEntityRepo) Create(ctx context.Context, entity *Entity) error {
@@ -282,6 +283,13 @@ func (m *mockEntityRepo) UpdateCoverImage(_ context.Context, _, _ string) error
282283
return nil
283284
}
284285

286+
func (m *mockEntityRepo) UpdatePrivate(ctx context.Context, entityID string, isPrivate bool) error {
287+
if m.updatePrivateFn != nil {
288+
return m.updatePrivateFn(ctx, entityID, isPrivate)
289+
}
290+
return nil
291+
}
292+
285293
// --- Test Helpers ---
286294

287295
// mockPermissionRepo implements EntityPermissionRepository for testing.

internal/plugins/npcs/.ai.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# NPC Gallery Plugin
2+
3+
## Purpose
4+
5+
The NPC gallery provides a dedicated hub for revealed (non-private) character entities
6+
in a campaign. It gives players a visual directory of NPCs they've encountered, and
7+
DMs a quick way to reveal/hide characters from player view.
8+
9+
## Architecture
10+
11+
This is a **view plugin** — it has no database tables. All data comes from the existing
12+
`entities` table filtered by the campaign's "characters" entity type and visibility.
13+
14+
### Files
15+
16+
| File | Purpose |
17+
|------|---------|
18+
| `model.go` | `NPCCard`, `NPCListOptions`, `NPCTagInfo` view models |
19+
| `repository.go` | SQL queries against `entities` + `entity_types` tables |
20+
| `service.go` | Business logic: resolves character type, delegates to repo, decorates with tags |
21+
| `handler.go` | HTTP handlers: gallery page, reveal toggle, count API, block data |
22+
| `gallery.templ` | Full gallery page and HTMX fragment with search/sort/pagination |
23+
| `npc_card.templ` | Individual NPC card component with portrait and reveal toggle |
24+
| `block.templ` | `npc_gallery` layout block for entity page embedding |
25+
| `routes.go` | Route registration (Player+ view, Scribe+ reveal) |
26+
| `service_test.go` | 7 tests covering service, model, and interface contracts |
27+
28+
### Dependencies
29+
30+
- **entities.EntityService** — via `EntityTypeFinder` interface (resolves character type ID)
31+
and `VisibilityToggler` interface (toggles `is_private`)
32+
- **tags.TagService** — via `TagLister` interface (batch tag decoration, optional)
33+
- No direct import of entity or tag packages (all via interfaces in `app/routes.go` adapters)
34+
35+
### Interfaces (implemented by adapters in app/routes.go)
36+
37+
```go
38+
// Resolves the "characters" entity type for a campaign.
39+
type EntityTypeFinder interface {
40+
FindCharacterTypeID(ctx, campaignID) (int, error)
41+
}
42+
43+
// Toggles entity is_private flag.
44+
type VisibilityToggler interface {
45+
TogglePrivate(ctx, entityID) (newPrivate bool, err error)
46+
}
47+
48+
// Optional: batch tag fetching.
49+
type TagLister interface {
50+
ListTagsForEntities(ctx, entityIDs) (map[string][]TagInfo, error)
51+
}
52+
```
53+
54+
## Routes
55+
56+
| Method | Path | Handler | Role | Description |
57+
|--------|------|---------|------|-------------|
58+
| GET | `/campaigns/:id/npcs` | Index | Player+ | Gallery page (full/HTMX) |
59+
| GET | `/campaigns/:id/npcs/count` | CountAPI | Player+ | NPC count JSON |
60+
| POST | `/campaigns/:id/npcs/:eid/reveal` | ToggleReveal | Scribe+ | Toggle visibility |
61+
62+
### Sync API Route
63+
64+
| Method | Path | Permission | Description |
65+
|--------|------|------------|-------------|
66+
| POST | `/api/v1/campaigns/:id/entities/:entityID/reveal` | write | Set/toggle visibility (Foundry sync) |
67+
68+
## Layout Block
69+
70+
Type: `npc_gallery`
71+
Config: `{"limit": 8}` (default 8 cards)
72+
Always available (no addon requirement).
73+
74+
## Foundry VTT Sync
75+
76+
### Chronicle → Foundry
77+
When an entity's `is_private` changes (via WebSocket event), the linked actor's
78+
`prototypeToken.hidden` is updated to match.
79+
80+
### Foundry → Chronicle
81+
When a Foundry actor's `prototypeToken.hidden` changes via hook, the module calls
82+
`POST /entities/:id/reveal` with `{"is_private": true|false}`.
83+
84+
## Business Rules
85+
86+
1. NPCs = character entities (entity type slug "characters")
87+
2. Players see only non-private characters (`is_private = false`)
88+
3. Scribes/Owners see all characters and can toggle visibility
89+
4. Gallery supports search (name), sort (name/updated/created), tag filter, pagination
90+
5. NPC cards show portrait, name, type label (or race/class field), tags
91+
6. Reveal badge: eye icon (green = visible, red = hidden) with HTMX toggle

0 commit comments

Comments
 (0)