Files
gastown/internal/polecat/namepool.go
Steve Yegge 97e0535bfe Implement three-tier config architecture (gt-k1lr tasks 1-5)
**Architecture changes:**
- Renamed `.gastown/` → `.runtime/` for runtime state (gitignored)
- Added `settings/` directory for rig behavioral config (git-tracked)
- Added `mayor/config.json` for town-level config (MayorConfig type)
- Separated RigConfig (identity) from RigSettings (behavioral)

**File location changes:**
- Town runtime: `~/.gastown/*` → `~/.runtime/*`
- Rig runtime: `<rig>/.gastown/*` → `<rig>/.runtime/*`
- Rig config: `<rig>/.gastown/config.json` → `<rig>/settings/config.json`
- Namepool state: `namepool.json` → `namepool-state.json`

**New types:**
- MayorConfig: town-level behavioral config
- RigSettings: rig behavioral config (merge_queue, theme, namepool)
- RigConfig now identity-only (name, git_url, beads, created_at)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 01:22:43 -08:00

380 lines
9.6 KiB
Go

package polecat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
const (
// DefaultPoolSize is the number of reusable names in the pool.
DefaultPoolSize = 50
// DefaultTheme is the default theme for new rigs.
DefaultTheme = "mad-max"
)
// Built-in themes with themed polecat names.
var BuiltinThemes = map[string][]string{
"mad-max": {
"furiosa", "nux", "slit", "rictus", "dementus",
"capable", "toast", "dag", "cheedo", "valkyrie",
"keeper", "morsov", "ace", "warboy", "imperator",
"organic", "coma", "splendid", "angharad", "max",
"immortan", "bullet", "toecutter", "goose", "nightrider",
"glory", "scrotus", "chumbucket", "corpus", "dinki",
"prime", "vuvalini", "rockryder", "wretched", "buzzard",
"gastown", "bullet-farmer", "citadel", "wasteland", "fury",
"road-warrior", "interceptor", "blackfinger", "wraith", "witness",
"chrome", "shiny", "mediocre", "guzzoline", "aqua-cola",
},
"minerals": {
"obsidian", "quartz", "jasper", "onyx", "opal",
"topaz", "garnet", "ruby", "amber", "jade",
"pearl", "flint", "granite", "basalt", "marble",
"shale", "slate", "pyrite", "mica", "agate",
"malachite", "turquoise", "lapis", "emerald", "sapphire",
"diamond", "amethyst", "citrine", "zircon", "peridot",
"coral", "jet", "moonstone", "sunstone", "bloodstone",
"rhodonite", "sodalite", "hematite", "magnetite", "calcite",
"fluorite", "selenite", "kyanite", "labradorite", "amazonite",
"chalcedony", "carnelian", "aventurine", "chrysoprase", "heliodor",
},
"wasteland": {
"rust", "chrome", "nitro", "guzzle", "witness",
"shiny", "fury", "thunder", "dust", "scavenger",
"radrat", "ghoul", "mutant", "raider", "vault",
"pipboy", "nuka", "brahmin", "deathclaw", "mirelurk",
"synth", "institute", "enclave", "brotherhood", "minuteman",
"railroad", "atom", "crater", "foundation", "refuge",
"settler", "wanderer", "courier", "lone", "chosen",
"tribal", "khan", "legion", "ncr", "ranger",
"overseer", "sentinel", "paladin", "scribe", "initiate",
"elder", "lancer", "knight", "squire", "proctor",
},
}
// NamePool manages a bounded pool of reusable polecat names.
// Names are drawn from a themed pool (mad-max by default).
// When the pool is exhausted, overflow names use rigname-N format.
type NamePool struct {
mu sync.RWMutex
// RigName is the rig this pool belongs to.
RigName string `json:"rig_name"`
// Theme is the current theme name (e.g., "mad-max", "minerals").
Theme string `json:"theme"`
// CustomNames allows overriding the built-in theme names.
CustomNames []string `json:"custom_names,omitempty"`
// InUse tracks which pool names are currently in use.
// Key is the name itself, value is true if in use.
InUse map[string]bool `json:"in_use"`
// OverflowNext is the next overflow sequence number.
// Starts at MaxSize+1 and increments.
OverflowNext int `json:"overflow_next"`
// MaxSize is the maximum number of themed names before overflow.
MaxSize int `json:"max_size"`
// stateFile is the path to persist pool state.
stateFile string
}
// NewNamePool creates a new name pool for a rig.
func NewNamePool(rigPath, rigName string) *NamePool {
return &NamePool{
RigName: rigName,
Theme: DefaultTheme,
InUse: make(map[string]bool),
OverflowNext: DefaultPoolSize + 1,
MaxSize: DefaultPoolSize,
stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"),
}
}
// NewNamePoolWithConfig creates a name pool with specific configuration.
func NewNamePoolWithConfig(rigPath, rigName, theme string, customNames []string, maxSize int) *NamePool {
if theme == "" {
theme = DefaultTheme
}
if maxSize <= 0 {
maxSize = DefaultPoolSize
}
return &NamePool{
RigName: rigName,
Theme: theme,
CustomNames: customNames,
InUse: make(map[string]bool),
OverflowNext: maxSize + 1,
MaxSize: maxSize,
stateFile: filepath.Join(rigPath, ".runtime", "namepool-state.json"),
}
}
// getNames returns the list of names to use for the pool.
func (p *NamePool) getNames() []string {
// Custom names take precedence
if len(p.CustomNames) > 0 {
return p.CustomNames
}
// Look up built-in theme
if names, ok := BuiltinThemes[p.Theme]; ok {
return names
}
// Fall back to default theme
return BuiltinThemes[DefaultTheme]
}
// Load loads the pool state from disk.
func (p *NamePool) Load() error {
p.mu.Lock()
defer p.mu.Unlock()
data, err := os.ReadFile(p.stateFile)
if err != nil {
if os.IsNotExist(err) {
// Initialize with empty state
p.InUse = make(map[string]bool)
p.OverflowNext = p.MaxSize + 1
return nil
}
return err
}
var loaded NamePool
if err := json.Unmarshal(data, &loaded); err != nil {
return err
}
// Load theme and custom names from disk (overrides constructor defaults)
if loaded.Theme != "" {
p.Theme = loaded.Theme
}
if len(loaded.CustomNames) > 0 {
p.CustomNames = loaded.CustomNames
}
p.InUse = loaded.InUse
if p.InUse == nil {
p.InUse = make(map[string]bool)
}
p.OverflowNext = loaded.OverflowNext
if p.OverflowNext < p.MaxSize+1 {
p.OverflowNext = p.MaxSize + 1
}
if loaded.MaxSize > 0 {
p.MaxSize = loaded.MaxSize
}
return nil
}
// Save persists the pool state to disk.
func (p *NamePool) Save() error {
p.mu.RLock()
defer p.mu.RUnlock()
dir := filepath.Dir(p.stateFile)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(p.stateFile, data, 0644)
}
// Allocate returns a name from the pool.
// It prefers names in order from the theme list, and falls back to overflow names
// when the pool is exhausted.
func (p *NamePool) Allocate() (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
names := p.getNames()
// Try to find first available name from the theme
for i := 0; i < len(names) && i < p.MaxSize; i++ {
name := names[i]
if !p.InUse[name] {
p.InUse[name] = true
return name, nil
}
}
// Pool exhausted, use overflow naming
name := p.formatOverflowName(p.OverflowNext)
p.OverflowNext++
return name, nil
}
// Release returns a pooled name to the pool.
// For overflow names, this is a no-op (they are not reusable).
func (p *NamePool) Release(name string) {
p.mu.Lock()
defer p.mu.Unlock()
// Check if it's a themed name
if p.isThemedName(name) {
delete(p.InUse, name)
}
// Overflow names are not reusable, so we don't track them
}
// isThemedName checks if a name is in the theme pool.
func (p *NamePool) isThemedName(name string) bool {
names := p.getNames()
for _, n := range names {
if n == name {
return true
}
}
return false
}
// IsPoolName returns true if the name is a pool name (themed or numbered).
func (p *NamePool) IsPoolName(name string) bool {
return p.isThemedName(name)
}
// ActiveCount returns the number of names currently in use from the pool.
func (p *NamePool) ActiveCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.InUse)
}
// ActiveNames returns a sorted list of names currently in use from the pool.
func (p *NamePool) ActiveNames() []string {
p.mu.RLock()
defer p.mu.RUnlock()
var names []string
for name := range p.InUse {
names = append(names, name)
}
sort.Strings(names)
return names
}
// MarkInUse marks a name as in use (for reconciling with existing polecats).
func (p *NamePool) MarkInUse(name string) {
p.mu.Lock()
defer p.mu.Unlock()
if p.isThemedName(name) {
p.InUse[name] = true
}
}
// Reconcile updates the pool state based on existing polecat directories.
// This should be called on startup to sync pool state with reality.
func (p *NamePool) Reconcile(existingPolecats []string) {
p.mu.Lock()
defer p.mu.Unlock()
// Clear current state
p.InUse = make(map[string]bool)
// Mark all existing polecats as in use
for _, name := range existingPolecats {
if p.isThemedName(name) {
p.InUse[name] = true
}
}
}
// formatOverflowName formats an overflow sequence number as a name.
func (p *NamePool) formatOverflowName(seq int) string {
return fmt.Sprintf("%s-%d", p.RigName, seq)
}
// GetTheme returns the current theme name.
func (p *NamePool) GetTheme() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.Theme
}
// SetTheme sets the theme and resets the pool.
// Existing in-use names are preserved if they exist in the new theme.
func (p *NamePool) SetTheme(theme string) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := BuiltinThemes[theme]; !ok {
return fmt.Errorf("unknown theme: %s (available: mad-max, minerals, wasteland)", theme)
}
// Preserve names that exist in both themes
newNames := BuiltinThemes[theme]
newInUse := make(map[string]bool)
for name := range p.InUse {
for _, n := range newNames {
if n == name {
newInUse[name] = true
break
}
}
}
p.Theme = theme
p.InUse = newInUse
p.CustomNames = nil
return nil
}
// ListThemes returns the list of available built-in themes.
func ListThemes() []string {
themes := make([]string, 0, len(BuiltinThemes))
for theme := range BuiltinThemes {
themes = append(themes, theme)
}
sort.Strings(themes)
return themes
}
// GetThemeNames returns the names in a specific theme.
func GetThemeNames(theme string) ([]string, error) {
if names, ok := BuiltinThemes[theme]; ok {
return names, nil
}
return nil, fmt.Errorf("unknown theme: %s", theme)
}
// AddCustomName adds a custom name to the pool.
func (p *NamePool) AddCustomName(name string) {
p.mu.Lock()
defer p.mu.Unlock()
// Check if already in custom names
for _, n := range p.CustomNames {
if n == name {
return
}
}
p.CustomNames = append(p.CustomNames, name)
}
// Reset clears the pool state, releasing all names.
func (p *NamePool) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.InUse = make(map[string]bool)
p.OverflowNext = p.MaxSize + 1
}