Files
Maven/internal/say/deck.go
T
claude c35979d9f9 say: move the copy deck into a package memory can import (V-506)
The summaries family is spoken by internal/memory, internal/morning and
internal/tasks. internal/phraser already imports internal/memory, so the
deck cannot stay in phraser without a cycle.

internal/say is a leaf: embed, json, math/rand, strings, sync. The four
phraser families keep their files and their floors and now call say.Load,
*say.Deck, Text, Matches, Variants, RequirePlaceholder and RegisterFloor.
No copy changed and no behaviour changed.
2026-08-04 01:42:20 +04:00

185 lines
5.5 KiB
Go

package say
// Package say holds the mechanics every family of hand-written Russian lines
// shares. The families themselves live next to the code that speaks them.
//
// Deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
// under it so a broken file cannot take her words away. fallbacks.go was the
// first family (Vikunja #501) and acks.go the second, at which point copying
// eighty lines of loader per family stopped being defensible.
//
// What stays per family: the file, the keys, the floor literals, the accessor
// names, and any validation only that family can state.
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"sync"
"time"
)
// Entry — one line she can say, in as many wordings as the file gives.
type Entry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type file struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]Entry `json:"entries"`
}
// deck picks a line. Safe for concurrent use. A nil *Deck answers from the
// floor, which is what an unloadable file leaves behind.
type Deck struct {
mu sync.Mutex
rnd *rand.Rand
last map[string]string
file file
keys []string
floor map[string]string
}
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
var f file
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if f.SchemaVersion != version {
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
}
for _, k := range keys {
e, ok := f.Entries[k]
if !ok || len(e.Variants) == 0 {
return nil, fmt.Errorf("entry %q is missing or empty", k)
}
if e.Fixed && len(e.Variants) != 1 {
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
}
}
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *Deck) RequirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
}
}
return nil
}
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
func (d *Deck) Text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, e)
}
}
if tmpl == "" {
tmpl = floorOf(d, key)
}
return fill(tmpl, vars)
}
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
if d == nil {
return false
}
for _, v := range d.file.Entries[key].Variants {
if fill(v, vars) == text {
return true
}
}
return false
}
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *Deck) Variants() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
out = append(out, d.file.Entries[k].Variants...)
}
return out
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *Deck) pick(key string, e Entry) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := e.Variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
if v != d.last[key] {
fresh = append(fresh, v)
}
}
if len(fresh) > 0 {
choices = fresh
}
}
got := choices[d.rnd.Intn(len(choices))]
d.last[key] = got
return got
}
// floorOf reads the Go literal behind key, and works on a nil deck because that
// is exactly the case it exists for. The per-family map is the source of truth.
func floorOf(d *Deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
}
return deckFloors[key]
}
// deckFloors — every family's floor literals in one map, so a nil deck still
// finds them. Families register at init; the keys are namespaced by family.
var deckFloors = map[string]string{}
func RegisterFloor(floor map[string]string) map[string]string {
for k, v := range floor {
deckFloors[k] = v
}
return floor
}
// fill substitutes {name} for each var. A placeholder with no value is left
// alone rather than blanked, so a missing value is visible instead of silent.
func fill(tmpl string, vars map[string]string) string {
for k, v := range vars {
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
}
return tmpl
}