Use this skill when working on rpg-api codebase - provides layered architecture patterns, outside-in development, and integration with rpg-toolkit
Use this skill when working on the rpg-api project to ensure consistency with established patterns and architecture.
See also:
/home/kirk/personal/.claude/agents/golang-architect/ - Go patterns and best practices/home/kirk/personal/rpg-toolkit/.claude/skills/rpg-toolkit-development/ - Toolkit integration patternsrpg-api stores data. rpg-toolkit handles rules. rpg-dnd5e-web renders data.
This three-layer separation is fundamental:
rpg-dnd5e-web (React) โ Pure renderer, makes visual decisions from data
โ (references + intent)
rpg-api โ Data-driven orchestrator, game-agnostic
โ (toolkit calls)
rpg-toolkit โ Rules engine, knows D&D 5e mechanics
API is Data-Driven
"dnd5e:features:rage"feature.Activate(ref, context)API is NOT a Rules Engine
Return Data for Rendering
damage_breakdown: [{source: "rage", amount: 2}] โ Client shows red glowmonster_status: "bloodied" โ Client changes sprite colorUse Toolkit Types Directly
character.Data, combat.AttackResult from toolkitSee /home/kirk/personal/ARCHITECTURE.md for complete architectural vision
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Handlers (gRPC) โ
โ - Validate requests โ
โ - Call service layer โ
โ - Convert responses to proto โ
โ - NO business logic โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Services (Interfaces) โ
โ - Define business logic contracts โ
โ - Input/Output types โ
โ - Generated mocks for testing โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Orchestrators (Implementation) โ
โ - Coordinate repositories โ
โ - Integrate with rpg-toolkit โ
โ - Handle workflows and state transitions โ
โ - Transform data between layers โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Repositories (Storage) โ
โ - Storage abstraction โ
โ - Redis, in-memory, etc. โ
โ - Own ID generation and timestamps โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/cmd/server/ # Cobra commands, server startup
/internal/
โโโ entities/ # Simple data models (just structs)
โโโ handlers/ # gRPC handlers (API layer)
โ โโโ dnd5e/
โ โโโ v1alpha1/ # Proto version naming
โ โโโ character/
โ โ โโโ handler.go
โ โ โโโ converters.go
โ โโโ encounter/
โโโ orchestrators/ # Service interfaces + implementations
โ โโโ character/
โ โ โโโ service.go # Interface with Input/Output types
โ โ โโโ orchestrator.go # Implementation
โ โ โโโ mock/ # Generated mocks
โ โโโ encounter/
โโโ repositories/ # Storage interfaces and implementations
โโโ character/
โ โโโ repository.go # Interface + types
โ โโโ redis.go # Implementation
โโโ encounters/
Always work from the API inward, ONE LAYER AT A TIME.
Each layer writes tests with mocked dependencies BEFORE implementing the next layer inward.
Handler (tests with mock service)
โ defines contract
Service Interface
โ implemented by
Orchestrator (tests with mock repos)
โ defines contract
Repository Interface
โ implemented by
Repository (tests with real storage)
Return codes.Unimplemented initially:
func (h *Handler) Attack(ctx context.Context, req *Request) (*Response, error) {
return nil, status.Error(codes.Unimplemented, "not implemented")
}
Why: Validates proto definitions work, server can start.
Based on what handler needs:
//go:generate mockgen -destination=mock/mock_service.go -package=encountermock github.com/KirkDiggler/rpg-api/internal/orchestrators/encounter Service
type Service interface {
ResolveAttack(ctx context.Context, input *ResolveAttackInput) (*ResolveAttackOutput, error)
}
type ResolveAttackInput struct {
EncounterID string
AttackerID string
TargetID string
}
type ResolveAttackOutput struct {
Result *combat.AttackResult // Toolkit type
MonsterHP int
}
Generate mocks: go generate ./internal/orchestrators/encounter/
Update handler to call service:
func (h *Handler) Attack(ctx context.Context, req *AttackRequest) (*AttackResponse, error) {
// Validate
if req.GetEncounterId() == "" {
return nil, status.Error(codes.InvalidArgument, "encounter_id required")
}
// Call service
output, err := h.encounterService.ResolveAttack(ctx, &encounter.ResolveAttackInput{
EncounterID: req.EncounterId,
AttackerID: req.AttackerId,
TargetID: req.TargetId,
})
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
// Convert to proto
return &AttackResponse{
Success: true,
Result: convertAttackResult(output.Result),
}, nil
}
Write tests with mocked service:
func (s *HandlerTestSuite) TestAttack_Success() {
// Mock service behavior
s.mockService.EXPECT().
ResolveAttack(gomock.Any(), &encounter.ResolveAttackInput{
EncounterID: "enc-1",
AttackerID: "char-1",
TargetID: "goblin-1",
}).
Return(&encounter.ResolveAttackOutput{
Result: &combat.AttackResult{
Hit: true,
TotalDamage: 10,
},
MonsterHP: 5,
}, nil)
// Call handler
resp, err := s.handler.Attack(ctx, &AttackRequest{
EncounterId: "enc-1",
AttackerId: "char-1",
TargetId: "goblin-1",
})
// Assert handler behavior
s.Require().NoError(err)
s.Assert().True(resp.Success)
s.Assert().Equal(int32(10), resp.Result.Damage)
}
Handler tests MUST pass before moving to orchestrator.
Now implement the service interface:
type Orchestrator struct {
charRepo character.Repository
encRepo encounters.Repository
}
func (o *Orchestrator) ResolveAttack(ctx context.Context, input *ResolveAttackInput) (*ResolveAttackOutput, error) {
// Implementation using mocked repos
}
Write orchestrator tests with mocked repos:
func (s *OrchestratorTestSuite) TestResolveAttack_Success() {
// Mock repo behavior
s.mockCharRepo.EXPECT().Get(...)
s.mockEncRepo.EXPECT().Get(...)
// Call orchestrator
output, err := s.orchestrator.ResolveAttack(ctx, input)
// Assert orchestrator behavior
}
Orchestrator tests MUST pass before implementing repos.
Last layer - actual storage implementation.
1. Create handler stub (Unimplemented)
2. Define service interface (what handler needs)
3. Generate mocks
4. Implement handler + write handler tests (with mock service)
โ
Handler tests pass
5. Implement orchestrator + write orchestrator tests (with mock repos)
โ
Orchestrator tests pass
6. Implement repositories + write repo tests
โ
All tests pass
โ WRONG: Create handler, define interface, implement orchestrator all at once
โ RIGHT:
Each layer proves its contract through tests BEFORE the next layer is implemented.
This is the #1 principle. Every function at every layer:
// โ BAD
func CreateSession(name string, dmID string, maxPlayers int) (*Session, error)
// โ
GOOD
func CreateSession(ctx context.Context, input *CreateSessionInput) (*CreateSessionOutput, error)
Benefits:
Handlers are thin translation layers:
func (h *Handler) Attack(ctx context.Context, req *AttackRequest) (*AttackResponse, error) {
// 1. Validate request
if req.GetEncounterId() == "" {
return nil, status.Error(codes.InvalidArgument, "encounter_id is required")
}
// 2. Create service input
input := &encounter.ResolveAttackInput{
EncounterID: req.EncounterId,
AttackerID: req.AttackerId,
TargetID: req.TargetId,
}
// 3. Call service
output, err := h.encounterService.ResolveAttack(ctx, input)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
// 4. Convert to proto response
return &AttackResponse{
Success: true,
Result: convertAttackResultToProto(output.Result),
}, nil
}
Key rules:
converters.go filestatus.Error() for gRPC error codesEvery component uses config struct:
type Config struct {
CharacterRepo character.Repository
EncounterRepo encounters.Repository
}
func (c *Config) Validate() error {
if c.CharacterRepo == nil {
return errors.New("CharacterRepo is required")
}
if c.EncounterRepo == nil {
return errors.New("EncounterRepo is required")
}
return nil
}
func New(cfg *Config) (*Orchestrator, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &Orchestrator{
charRepo: cfg.CharacterRepo,
encRepo: cfg.EncounterRepo,
}, nil
}
Following rpg-toolkit's pattern:
mock/ subdirectory next to interface<parent>mock (e.g., charactermock, encountermock)mock_<interface>.go//go:generate above interface// In service.go:
//go:generate mockgen -destination=mock/mock_service.go -package=charactermock github.com/KirkDiggler/rpg-api/internal/orchestrators/character Service
type Service interface {
// ...
}
// Usage in tests:
mockService := charactermock.NewMockService(ctrl)
type Repository interface {
Get(ctx context.Context, input *GetInput) (*GetOutput, error)
Save(ctx context.Context, input *SaveInput) (*SaveOutput, error)
Update(ctx context.Context, input *UpdateInput) (*UpdateOutput, error)
Delete(ctx context.Context, input *DeleteInput) (*DeleteOutput, error)
}
type GetInput struct {
ID string
}
type GetOutput struct {
CharacterData *character.Data
}
Repository responsibilities:
Don't create API-specific entity types. Use toolkit types directly.
// โ WRONG: Creating internal entities
// /internal/entities/character.go
type Character struct {
ID string
Name string
Level int
}
// โ
RIGHT: Use toolkit types
import "github.com/KirkDiggler/rpg-toolkit/rulebooks/dnd5e/character"
type CharacterRepository interface {
Get(ctx, input) (*GetOutput, error)
}
type GetOutput struct {
CharacterData *character.Data // Toolkit type
}
// Store toolkit type in Redis as JSON
func (r *RedisRepo) Save(ctx, input) error {
json, _ := json.Marshal(input.CharacterData) // character.Data
r.client.Set(key, json)
}
Why:
Separate file for proto conversions:
// converters.go
func convertAttackResultToProto(result *combat.AttackResult) *proto.AttackResult {
return &proto.AttackResult{
Hit: result.Hit,
AttackRoll: int32(result.AttackRoll),
AttackTotal: int32(result.TotalAttack),
Damage: int32(result.TotalDamage),
DamageType: result.DamageType,
Critical: result.Critical,
}
}
func convertCharacterDataToProto(data *character.Data) *proto.Character {
// ... conversion logic
}
Why separate file:
import (
"github.com/KirkDiggler/rpg-toolkit/events"
"github.com/KirkDiggler/rpg-toolkit/rulebooks/dnd5e/character"
"github.com/KirkDiggler/rpg-toolkit/rulebooks/dnd5e/combat"
"github.com/KirkDiggler/rpg-toolkit/rulebooks/dnd5e/monster"
)
func (o *Orchestrator) ResolveAttack(ctx context.Context, input *ResolveAttackInput) (*ResolveAttackOutput, error) {
// 1. Create EventBus for combat interaction
bus := events.NewEventBus()
// 2. Load character with features
charData, err := o.charRepo.Get(ctx, &character.GetInput{ID: input.AttackerID})
if err != nil {
return nil, err
}
// LoadFromData reconstructs Character with features subscribed to events
char, err := character.LoadFromData(ctx, charData.CharacterData, bus)
if err != nil {
return nil, err
}
// 3. Reconstruct monster
mon := monster.NewGoblin(input.TargetID)
// 4. Call toolkit combat
result, err := combat.ResolveAttack(ctx, &combat.AttackInput{
Attacker: char,
Defender: mon,
Weapon: weapon,
AttackerScores: char.AbilityScores(),
DefenderAC: mon.AC(),
ProficiencyBonus: char.ProficiencyBonus(),
EventBus: bus,
Roller: dice.NewRoller(),
})
// 5. Persist updates
if result.Hit {
// Update monster HP in encounter state
}
return &ResolveAttackOutput{Result: result}, nil
}
Critical: Combat requires EventBus for event-driven features like Rage.
Pattern:
character.LoadFromData(ctx, data, bus)combat.ResolveAttack()Why not persist EventBus:
Key finding: Features ARE persisted in character.Data:
type Data struct {
// ... other fields
Features []json.RawMessage // e.g., Rage feature
Conditions []json.RawMessage // e.g., Raging condition
}
// When saved
func (c *Character) ToData() *Data {
data.Features = make([]json.RawMessage, 0, len(c.features))
for _, feature := range c.features {
jsonData, _ := feature.ToJSON()
data.Features = append(data.Features, jsonData)
}
return data
}
// When loaded
func LoadFromData(ctx context.Context, d *Data, bus events.EventBus) (*Character, error) {
// Reconstructs Feature objects from JSON
for _, rawFeature := range d.Features {
feature, _ := features.LoadJSON(rawFeature)
char.features = append(char.features, feature)
}
// Character subscribes to events with features
char.subscribeToEvents(ctx)
return char, nil
}
Implication: Loading character from repo automatically includes Rage and other features, ready for combat.
Always use testify suites:
type OrchestratorTestSuite struct {
suite.Suite
ctrl *gomock.Controller
mockCharRepo *charactermock.MockRepository
mockEncRepo *encountermock.MockRepository
orchestrator *Orchestrator
}
func (s *OrchestratorTestSuite) SetupTest() {
s.ctrl = gomock.NewController(s.T())
s.mockCharRepo = charactermock.NewMockRepository(s.ctrl)
s.mockEncRepo = encountermock.NewMockRepository(s.ctrl)
s.orchestrator = New(&Config{
CharacterRepo: s.mockCharRepo,
EncounterRepo: s.mockEncRepo,
})
}
func (s *OrchestratorTestSuite) TearDownTest() {
s.ctrl.Finish()
}
func TestOrchestratorSuite(t *testing.T) {
suite.Run(t, new(OrchestratorTestSuite))
}
func (s *HandlerTestSuite) TestAttack_Success() {
// Arrange
expectedOutput := &encounter.ResolveAttackOutput{
Result: &combat.AttackResult{
Hit: true,
AttackRoll: 15,
TotalAttack: 20,
TotalDamage: 10,
},
}
s.mockService.EXPECT().
ResolveAttack(gomock.Any(), &encounter.ResolveAttackInput{
EncounterID: "enc-1",
AttackerID: "char-1",
TargetID: "mon-1",
}).
Return(expectedOutput, nil)
// Act
resp, err := s.handler.Attack(context.Background(), &proto.AttackRequest{
EncounterId: "enc-1",
AttackerId: "char-1",
TargetId: "mon-1",
})
// Assert
s.Require().NoError(err)
s.Assert().True(resp.Success)
s.Assert().Equal(int32(10), resp.Result.Damage)
}
func (s *OrchestratorTestSuite) TestResolveAttack_CharacterWithRage() {
// Arrange - Mock character repo
charData := createTestCharacterWithRage()
s.mockCharRepo.EXPECT().
Get(gomock.Any(), &character.GetInput{ID: "char-1"}).
Return(&character.GetOutput{CharacterData: charData}, nil)
// Arrange - Mock encounter repo
encData := createTestEncounter()
s.mockEncRepo.EXPECT().
Get(gomock.Any(), &encounters.GetInput{EncounterID: "enc-1"}).
Return(&encounters.GetOutput{Data: encData}, nil)
// Arrange - Expect HP update
s.mockEncRepo.EXPECT().
Update(gomock.Any(), gomock.Any()).
Return(nil)
// Act
output, err := s.orchestrator.ResolveAttack(ctx, &ResolveAttackInput{
EncounterID: "enc-1",
AttackerID: "char-1",
TargetID: "goblin-1",
})
// Assert
s.Require().NoError(err)
s.Assert().True(output.Result.Hit)
// Rage should add +2 damage
s.Assert().GreaterOrEqual(output.Result.DamageBonus, 2)
}
// โ BAD - Never do this
if input == nil {
return nil, nil
}
// โ
GOOD - Return error
if input == nil {
return nil, errors.New("input is required")
}
// โ
GOOD - Return empty/default if valid
if items == nil {
return &ListOutput{Items: []*Item{}, Total: 0}, nil
}
var (
ErrSessionNotFound = errors.New("session not found")
ErrCharacterNotFound = errors.New("character not found")
ErrEncounterNotFound = errors.New("encounter not found")
)
// Wrap with context
return fmt.Errorf("failed to get session %s: %w", id, ErrSessionNotFound)
import "google.golang.org/grpc/codes"
import "google.golang.org/grpc/status"
// Map internal errors to gRPC codes
if errors.Is(err, ErrCharacterNotFound) {
return nil, status.Error(codes.NotFound, err.Error())
}
if errors.Is(err, ErrInvalidInput) {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
// Generic internal error
return nil, status.Error(codes.Internal, err.Error())
ALWAYS run before committing:
make pre-commit # Runs fmt, tidy, lint, test
NEVER use git commit --no-verify - CI will fail anyway
ALWAYS run before pushing:
make ci-check # Detect CI failures locally
make ci-fix # Auto-fix what can be fixed
Start from latest main
gcm # git checkout main
gl # git pull
Create feature branch
git checkout -b feat/attack-endpoint
Follow outside-in development
Run tests continuously
go test ./...
go test ./internal/orchestrators/encounter -v
Run CI checks before push
make ci-check
make ci-fix
Create PR
git push origin feat/attack-endpoint
gh pr create
/internal/handlers/dnd5e/v1alpha1/<service>//internal/orchestrators/<service>/service.go//go:generate and go generate/internal/orchestrators/<service>/orchestrator.go/cmd/server/server.goLoadFromData(ctx, data, bus)