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 }