c35979d9f9
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.
185 lines
5.5 KiB
Go
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
|
|
}
|