capsule AI-native Unix-like composition layer

src/server/internal/character/store.go

22,413 bytes · 855 lines · capsule://quake0day/[email protected] raw on github

package character

import (
	"encoding/json"
	"fmt"
	"log"
	"math/rand"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
)

// ImageInfo describes one image file inside a character's images/ directory.
type ImageInfo struct {
	Filename string `json:"filename"`
	OrigName string `json:"orig_name"`
	AddedAt  string `json:"added_at"`
}

type Character struct {
	ID             string      `json:"id"`
	Name           string      `json:"name"`
	Description    string      `json:"description"`
	AvatarImage    string      `json:"avatar_image"`
	UseFaceCrop    bool        `json:"use_face_crop"`
	Mode           string      `json:"mode"`
	VoiceProvider  string      `json:"voice_provider"`
	VoiceType      string      `json:"voice_type"`
	Components     Components  `json:"components"`
	SpeakingStyle  string      `json:"speaking_style"`
	Personality    string      `json:"personality"`
	WelcomeMessage string      `json:"welcome_message"`
	SystemPrompt   string      `json:"system_prompt"`
	Tags           []string    `json:"tags"`
	Images         []ImageInfo `json:"images"`
	ActiveImage    string      `json:"active_image"`
	ImageMode      string      `json:"image_mode"`
	CreatedAt      string      `json:"created_at"`
	UpdatedAt      string      `json:"updated_at"`
}

type Components struct {
	LLM string `json:"llm"`
	ASR string `json:"asr"`
	TTS string `json:"tts"`
}

const DefaultIdleVideoProfile = "breathing10s_v1"

func DefaultComponents() Components {
	return Components{LLM: "qwen", ASR: "qwen", TTS: "qwen"}
}

func normalizeMode(mode string, fallback string) string {
	switch strings.TrimSpace(mode) {
	case "omni", "voice_llm":
		return "omni"
	case "standard":
		return "standard"
	default:
		if fallback != "" {
			return fallback
		}
		return "standard"
	}
}

func NormalizeComponents(components Components, defaults Components) Components {
	if defaults.LLM == "" {
		defaults.LLM = "qwen"
	}
	if defaults.ASR == "" {
		defaults.ASR = "qwen"
	}
	if defaults.TTS == "" {
		defaults.TTS = "qwen"
	}
	if components.LLM == "" {
		components.LLM = defaults.LLM
	}
	if components.ASR == "" {
		components.ASR = defaults.ASR
	}
	if components.TTS == "" {
		components.TTS = defaults.TTS
	}
	return components
}

type Store struct {
	mu      sync.RWMutex
	baseDir string
	chars   map[string]*Character
	// dirNames caches id to directory name, for example "char_8981e0a1".
	dirNames map[string]string
}

// NewStore creates a store backed by per-character directories under baseDir.
func NewStore(baseDir string) (*Store, error) {
	s := &Store{
		baseDir:  baseDir,
		chars:    make(map[string]*Character),
		dirNames: make(map[string]string),
	}
	if err := os.MkdirAll(baseDir, 0755); err != nil {
		return nil, fmt.Errorf("create characters dir: %w", err)
	}
	if err := s.load(); err != nil {
		return nil, fmt.Errorf("load characters: %w", err)
	}
	return s, nil
}

func (s *Store) BaseDir() string {
	return s.baseDir
}

func (s *Store) List() []*Character {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make([]*Character, 0, len(s.chars))
	for _, c := range s.chars {
		result = append(result, c)
	}
	sort.Slice(result, func(i, j int) bool {
		return result[i].CreatedAt < result[j].CreatedAt
	})
	return result
}

func (s *Store) Get(id string) (*Character, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	c, ok := s.chars[id]
	if !ok {
		return nil, fmt.Errorf("character not found: %s", id)
	}
	return c, nil
}

func (s *Store) Create(c *Character) (*Character, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	c.ID = uuid.New().String()
	now := time.Now().UTC().Format(time.RFC3339)
	c.CreatedAt = now
	c.UpdatedAt = now
	if c.Tags == nil {
		c.Tags = []string{}
	}
	if c.Images == nil {
		c.Images = []ImageInfo{}
	}
	c.Mode = normalizeMode(c.Mode, "standard")
	c.Components = NormalizeComponents(c.Components, DefaultComponents())
	if c.VoiceProvider == "" {
		c.VoiceProvider = c.Components.TTS
	}
	if c.VoiceType == "" && c.Components.TTS == "qwen" {
		c.VoiceType = "Momo"
	}

	dirName := charDirName(c.Name, c.ID)
	charDir := filepath.Join(s.baseDir, dirName)

	// Create directory structure
	for _, sub := range []string{"", "images", "sessions"} {
		if err := os.MkdirAll(filepath.Join(charDir, sub), 0755); err != nil {
			return nil, fmt.Errorf("create character dir: %w", err)
		}
	}

	s.chars[c.ID] = c
	s.dirNames[c.ID] = dirName

	if err := s.saveOne(c); err != nil {
		// Cleanup on failure
		os.RemoveAll(charDir)
		delete(s.chars, c.ID)
		delete(s.dirNames, c.ID)
		return nil, err
	}
	return c, nil
}

func (s *Store) Update(id string, c *Character) (*Character, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	existing, ok := s.chars[id]
	if !ok {
		return nil, fmt.Errorf("character not found: %s", id)
	}

	oldDirName := s.dirNames[id]

	c.ID = id
	c.CreatedAt = existing.CreatedAt
	c.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	if c.Tags == nil {
		c.Tags = []string{}
	}
	// Preserve images/active_image from existing if not provided
	if c.Images == nil {
		c.Images = existing.Images
	}
	if c.ActiveImage == "" {
		c.ActiveImage = existing.ActiveImage
	}
	if c.ImageMode == "" {
		c.ImageMode = existing.ImageMode
	}
	c.Mode = normalizeMode(c.Mode, normalizeMode(existing.Mode, "standard"))
	c.Components = NormalizeComponents(c.Components, existing.Components)
	if c.VoiceProvider == "" {
		c.VoiceProvider = c.Components.TTS
	}
	if c.VoiceType == "" && c.Components.TTS == "qwen" {
		c.VoiceType = "Momo"
	}
	// Preserve avatar_image if caller sent empty (e.g. frontend strips blob: URLs)
	if c.AvatarImage == "" && existing.AvatarImage != "" {
		c.AvatarImage = existing.AvatarImage
	}

	newDirName := charDirName(c.Name, c.ID)

	// Rename directory if name changed
	if oldDirName != newDirName {
		oldPath := filepath.Join(s.baseDir, oldDirName)
		newPath := filepath.Join(s.baseDir, newDirName)
		if err := os.Rename(oldPath, newPath); err != nil {
			return nil, fmt.Errorf("rename character dir: %w", err)
		}
		s.dirNames[id] = newDirName
	}

	s.chars[id] = c
	if err := s.saveOne(c); err != nil {
		return nil, err
	}
	return c, nil
}

func (s *Store) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.chars[id]; !ok {
		return fmt.Errorf("character not found: %s", id)
	}

	dirName := s.dirNames[id]
	if dirName != "" {
		os.RemoveAll(filepath.Join(s.baseDir, dirName))
	}

	delete(s.chars, id)
	delete(s.dirNames, id)
	return nil
}

// CharDir returns the full path to a character's directory.
func (s *Store) CharDir(id string) string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	dirName, ok := s.dirNames[id]
	if !ok {
		return ""
	}
	return filepath.Join(s.baseDir, dirName)
}

// ImagesDir returns the full path to a character's images directory.
func (s *Store) ImagesDir(id string) string {
	d := s.CharDir(id)
	if d == "" {
		return ""
	}
	return filepath.Join(d, "images")
}

// SessionsDir returns the full path to a character's sessions directory.
func (s *Store) SessionsDir(id string) string {
	d := s.CharDir(id)
	if d == "" {
		return ""
	}
	return filepath.Join(d, "sessions")
}

func (s *Store) UserSessionsDir(id, ownerID string) string {
	return s.sessionsDirForOwner(id, ownerID)
}

func (s *Store) sessionsDirForOwner(id, ownerID string) string {
	ownerID = strings.TrimSpace(ownerID)
	if ownerID == "" {
		return s.SessionsDir(id)
	}
	d := s.CharDir(id)
	if d == "" {
		return ""
	}
	return filepath.Join(d, "users", ownerDirName(ownerID), "sessions")
}

// IdleVideosDir returns the full path to a character's idle video cache directory.
func (s *Store) IdleVideosDir(id string) string {
	d := s.CharDir(id)
	if d == "" {
		return ""
	}
	return filepath.Join(d, "idle_videos")
}

// IdleVideosForImageDir returns the per-image subdirectory under idle_videos/.
// e.g. {charDir}/idle_videos/img_003/
func (s *Store) IdleVideosForImageDir(id, imageFilename string) string {
	dir := s.IdleVideosDir(id)
	if dir == "" {
		return ""
	}
	base := strings.TrimSuffix(filepath.Base(imageFilename), filepath.Ext(imageFilename))
	if base == "" {
		base = "avatar"
	}
	return filepath.Join(dir, base)
}

func idleVideoBaseName(imageFilename string) string {
	base := strings.TrimSuffix(filepath.Base(imageFilename), filepath.Ext(imageFilename))
	if base == "" {
		base = "avatar"
	}
	return base
}

func idleVideoProfileName(profile string) string {
	if profile == "" {
		return DefaultIdleVideoProfile
	}
	return profile
}

func idleVideoSizeDirName(width, height int) string {
	if width <= 0 || height <= 0 {
		return ""
	}
	return fmt.Sprintf("%dx%d", width, height)
}

// IdleVideosForSizeDir returns the per-image, per-resolution cache directory.
// e.g. {charDir}/idle_videos/img_003/320x480/
func (s *Store) IdleVideosForSizeDir(id, imageFilename string, width, height int) string {
	dir := s.IdleVideosForImageDir(id, imageFilename)
	sizeDir := idleVideoSizeDirName(width, height)
	if dir == "" || sizeDir == "" {
		return ""
	}
	return filepath.Join(dir, sizeDir)
}

// IdleVideoFilename returns a stable MP4 filename for one source image + profile.
func (s *Store) IdleVideoFilename(imageFilename, profile string) string {
	return fmt.Sprintf("%s__%s.mp4", idleVideoBaseName(imageFilename), idleVideoProfileName(profile))
}

// IdleVideoPath returns the absolute path for a cached idle video inside the
// per-image, per-resolution subdirectory.
func (s *Store) IdleVideoPath(id, imageFilename, profile string, width, height int) string {
	dir := s.IdleVideosForSizeDir(id, imageFilename, width, height)
	if dir == "" {
		return ""
	}
	return filepath.Join(dir, s.IdleVideoFilename(imageFilename, profile))
}

// ListIdleVideos returns all .mp4 filenames in the target resolution directory, sorted.
func (s *Store) ListIdleVideos(id, imageFilename string, width, height int) ([]string, error) {
	dir := s.IdleVideosForSizeDir(id, imageFilename, width, height)
	if dir == "" {
		return nil, fmt.Errorf("idle video dir unavailable for character %s", id)
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var files []string
	for _, e := range entries {
		if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".mp4") {
			files = append(files, e.Name())
		}
	}
	sort.Strings(files)
	return files, nil
}

// NextImageFilename scans the images/ directory and returns the next sequential filename
// (without extension), e.g. "img_003".
func (s *Store) NextImageFilename(id string) string {
	imgDir := s.ImagesDir(id)
	if imgDir == "" {
		return "img_001"
	}

	entries, err := os.ReadDir(imgDir)
	if err != nil {
		return "img_001"
	}

	maxNum := 0
	re := regexp.MustCompile(`^img_(\d+)`)
	for _, e := range entries {
		if m := re.FindStringSubmatch(e.Name()); m != nil {
			if n, err := strconv.Atoi(m[1]); err == nil && n > maxNum {
				maxNum = n
			}
		}
	}
	return fmt.Sprintf("img_%03d", maxNum+1)
}

// SaveConversation persists a session's data (messages + metadata) into the character's sessions dir.
func (s *Store) SaveConversation(characterID, sessionID string, startedAt, endedAt time.Time, messages []map[string]any) error {
	return s.SaveConversationForOwner(characterID, "", sessionID, startedAt, endedAt, messages)
}

func (s *Store) SaveConversationForOwner(characterID, ownerID, sessionID string, startedAt, endedAt time.Time, messages []map[string]any) error {
	sessDir := s.sessionsDirForOwner(characterID, ownerID)
	if sessDir == "" {
		return fmt.Errorf("character not found: %s", characterID)
	}

	dirName := startedAt.Format("20060102-150405") + "_" + shortID(sessionID)
	fullDir := filepath.Join(sessDir, dirName)
	if err := os.MkdirAll(fullDir, 0755); err != nil {
		return err
	}

	record := map[string]any{
		"session_id":   sessionID,
		"character_id": characterID,
		"started_at":   startedAt.UTC().Format(time.RFC3339),
		"ended_at":     endedAt.UTC().Format(time.RFC3339),
		"messages":     messages,
	}

	data, err := json.MarshalIndent(record, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(fullDir, "session.json"), data, 0644)
}

// LoadRecentMessages loads recent conversation messages for a character, paginated by cursor.
// before: cursor (session directory name) — empty string means start from newest.
// limit: max number of messages to return.
// Returns: messages (chronological order), next cursor, hasMore.
func (s *Store) LoadRecentMessages(characterID string, before string, limit int) ([]map[string]any, string, bool, error) {
	return s.LoadRecentMessagesForOwner(characterID, "", before, limit)
}

func (s *Store) LoadRecentMessagesForOwner(characterID, ownerID string, before string, limit int) ([]map[string]any, string, bool, error) {
	sessDir := s.sessionsDirForOwner(characterID, ownerID)
	if sessDir == "" {
		return nil, "", false, fmt.Errorf("character not found: %s", characterID)
	}

	entries, err := os.ReadDir(sessDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, "", false, nil
		}
		return nil, "", false, err
	}

	// Sort descending by name (timestamp prefix ensures chronological order)
	sort.Slice(entries, func(i, j int) bool {
		return entries[i].Name() > entries[j].Name()
	})

	// Apply cursor: skip entries >= before
	if before != "" {
		idx := 0
		for idx < len(entries) && entries[idx].Name() >= before {
			idx++
		}
		entries = entries[idx:]
	}

	var allMessages []map[string]any
	var nextCursor string
	collected := 0

	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		sessionFile := filepath.Join(sessDir, entry.Name(), "session.json")
		data, err := os.ReadFile(sessionFile)
		if err != nil {
			continue // skip sessions without session.json
		}

		var record struct {
			SessionID string           `json:"session_id"`
			StartedAt string           `json:"started_at"`
			Messages  []map[string]any `json:"messages"`
		}
		if err := json.Unmarshal(data, &record); err != nil {
			continue
		}
		if strings.TrimSpace(record.SessionID) == "" {
			record.SessionID = entry.Name()
		}
		if len(record.Messages) == 0 {
			continue
		}

		// Annotate messages with session metadata
		for _, msg := range record.Messages {
			msg["session_id"] = record.SessionID
			if msg["timestamp"] == nil {
				msg["timestamp"] = record.StartedAt
			} else if ts, ok := msg["timestamp"].(string); ok && strings.TrimSpace(ts) == "" {
				msg["timestamp"] = record.StartedAt
			}
		}

		// These messages are in chronological order within the session,
		// but we're iterating sessions newest-first, so prepend
		allMessages = append(record.Messages, allMessages...)
		collected += len(record.Messages)
		nextCursor = entry.Name()

		if collected >= limit {
			break
		}
	}

	// Trim to limit (keep the most recent messages)
	if len(allMessages) > limit {
		allMessages = allMessages[len(allMessages)-limit:]
	}

	// Check if there are more sessions after the cursor
	hasMore := false
	if nextCursor != "" {
		for _, entry := range entries {
			if entry.IsDir() && entry.Name() < nextCursor {
				// Check if this directory has a session.json
				sessionFile := filepath.Join(sessDir, entry.Name(), "session.json")
				if _, err := os.Stat(sessionFile); err == nil {
					hasMore = true
					break
				}
			}
		}
	}

	return allMessages, nextCursor, hasMore, nil
}

// SessionRecordingDir returns the full path for a session's recording directory,
// creating it if needed. Format: {charDir}/sessions/{timestamp}_{sessionID8}/
// Uses createdAt so that recordings land in the same directory as SaveConversation.
func (s *Store) SessionRecordingDir(characterID, sessionID string, createdAt time.Time) string {
	return s.SessionRecordingDirForOwner(characterID, "", sessionID, createdAt)
}

func (s *Store) SessionRecordingDirForOwner(characterID, ownerID, sessionID string, createdAt time.Time) string {
	sessDir := s.sessionsDirForOwner(characterID, ownerID)
	if sessDir == "" {
		return ""
	}
	dirName := createdAt.Format("20060102-150405") + "_" + shortID(sessionID)
	fullDir := filepath.Join(sessDir, dirName)
	// Resolve to absolute path so that recording functions (BeginTurn/SaveRawAudio)
	// don't prepend cfg.OutputDir when they see a relative path.
	if abs, err := filepath.Abs(fullDir); err == nil {
		fullDir = abs
	}
	os.MkdirAll(fullDir, 0755)
	return fullDir
}

// ── internal helpers ──

func (s *Store) load() error {
	entries, err := os.ReadDir(s.baseDir)
	if err != nil {
		return err
	}

	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		jsonPath := filepath.Join(s.baseDir, e.Name(), "character.json")
		data, err := os.ReadFile(jsonPath)
		if err != nil {
			log.Printf("skip character dir %s: %v", e.Name(), err)
			continue
		}
		var c Character
		if err := json.Unmarshal(data, &c); err != nil {
			log.Printf("skip character dir %s: bad JSON: %v", e.Name(), err)
			continue
		}
		if c.ID == "" {
			log.Printf("skip character dir %s: no id", e.Name())
			continue
		}
		if c.Tags == nil {
			c.Tags = []string{}
		}
		if c.Images == nil {
			c.Images = []ImageInfo{}
		}
		if c.ImageMode == "" {
			c.ImageMode = "fixed"
		}
		// Legacy character.json files had no mode field; default to omni (prior voice_llm behavior).
		c.Mode = normalizeMode(c.Mode, "omni")
		c.Components = NormalizeComponents(c.Components, DefaultComponents())
		if c.VoiceProvider == "" {
			c.VoiceProvider = c.Components.TTS
		}
		if c.VoiceType == "" && c.Components.TTS == "qwen" {
			c.VoiceType = "Momo"
		}
		s.chars[c.ID] = &c
		s.dirNames[c.ID] = e.Name()
	}
	return nil
}

func (s *Store) saveOne(c *Character) error {
	dirName, ok := s.dirNames[c.ID]
	if !ok {
		return fmt.Errorf("no directory mapping for character %s", c.ID)
	}
	jsonPath := filepath.Join(s.baseDir, dirName, "character.json")
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(jsonPath, data, 0644)
}

// sanitizeName replaces filesystem-unsafe characters and truncates to 50 chars.
func sanitizeName(name string) string {
	unsafe := regexp.MustCompile(`[/\\:*?"<>|]`)
	s := unsafe.ReplaceAllString(name, "_")
	s = strings.TrimSpace(s)
	// Truncate by rune to avoid breaking multi-byte chars
	runes := []rune(s)
	if len(runes) > 50 {
		runes = runes[:50]
	}
	s = string(runes)
	if s == "" {
		s = "unnamed"
	}
	return s
}

func ownerDirName(ownerID string) string {
	ownerID = strings.TrimSpace(ownerID)
	if ownerID == "" {
		return "anonymous"
	}
	var b strings.Builder
	for _, r := range ownerID {
		switch {
		case r >= 'a' && r <= 'z':
			b.WriteRune(r)
		case r >= 'A' && r <= 'Z':
			b.WriteRune(r)
		case r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '.' || r == '_' || r == '-':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
	}
	if b.Len() == 0 {
		return "anonymous"
	}
	return b.String()
}

// charDirName returns the directory name for a character: "{sanitizedName}_{id[:8]}"
func charDirName(name, id string) string {
	return sanitizeName(name) + "_" + shortID(id)
}

func shortID(id string) string {
	clean := strings.ReplaceAll(id, "-", "")
	if len(clean) > 8 {
		clean = clean[:8]
	}
	return clean
}

// ListImages returns images with the active image first, then by filename.
func (s *Store) ListImages(id string) ([]ImageInfo, error) {
	s.mu.RLock()
	c, ok := s.chars[id]
	s.mu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("character not found: %s", id)
	}
	imgs := make([]ImageInfo, len(c.Images))
	copy(imgs, c.Images)
	sort.Slice(imgs, func(i, j int) bool {
		if imgs[i].Filename == c.ActiveImage {
			return true
		}
		if imgs[j].Filename == c.ActiveImage {
			return false
		}
		return imgs[i].Filename < imgs[j].Filename
	})
	return imgs, nil
}

// AddImage adds an image entry to the character and persists.
func (s *Store) AddImage(id string, info ImageInfo) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	c, ok := s.chars[id]
	if !ok {
		return fmt.Errorf("character not found: %s", id)
	}

	c.Images = append(c.Images, info)

	// Auto-activate first image
	if c.ActiveImage == "" {
		c.ActiveImage = info.Filename
		c.AvatarImage = fmt.Sprintf("/api/v1/characters/%s/images/%s", id, info.Filename)
	}

	c.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	return s.saveOne(c)
}

// RemoveImage removes an image entry. If it was active, switches to the first remaining.
func (s *Store) RemoveImage(id, filename string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	c, ok := s.chars[id]
	if !ok {
		return fmt.Errorf("character not found: %s", id)
	}

	found := false
	newImages := make([]ImageInfo, 0, len(c.Images))
	for _, img := range c.Images {
		if img.Filename == filename {
			found = true
			continue
		}
		newImages = append(newImages, img)
	}
	if !found {
		return fmt.Errorf("image not found: %s", filename)
	}

	c.Images = newImages

	if c.ActiveImage == filename {
		if len(c.Images) > 0 {
			c.ActiveImage = c.Images[0].Filename
			c.AvatarImage = fmt.Sprintf("/api/v1/characters/%s/images/%s", id, c.ActiveImage)
		} else {
			c.ActiveImage = ""
			c.AvatarImage = ""
		}
	}

	c.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	return s.saveOne(c)
}

// ActivateImage sets a specific image as the active avatar.
func (s *Store) ActivateImage(id, filename string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	c, ok := s.chars[id]
	if !ok {
		return fmt.Errorf("character not found: %s", id)
	}

	found := false
	activeIndex := -1
	for i, img := range c.Images {
		if img.Filename == filename {
			found = true
			activeIndex = i
			break
		}
	}
	if !found {
		return fmt.Errorf("image not found: %s", filename)
	}

	if activeIndex > 0 {
		active := c.Images[activeIndex]
		copy(c.Images[1:activeIndex+1], c.Images[0:activeIndex])
		c.Images[0] = active
	}
	c.ActiveImage = filename
	c.AvatarImage = fmt.Sprintf("/api/v1/characters/%s/images/%s", id, filename)
	c.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	return s.saveOne(c)
}

// RandomizeImage picks a random image and sets it as the active avatar.
func (s *Store) RandomizeImage(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	c, ok := s.chars[id]
	if !ok {
		return fmt.Errorf("character not found: %s", id)
	}
	if len(c.Images) == 0 {
		return nil
	}

	idx := rand.Intn(len(c.Images))
	c.ActiveImage = c.Images[idx].Filename
	c.AvatarImage = fmt.Sprintf("/api/v1/characters/%s/images/%s", id, c.ActiveImage)
	c.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
	return s.saveOne(c)
}