diff --git a/internal/phraser/deck.go b/internal/phraser/deck.go new file mode 100644 index 0000000..1f53745 --- /dev/null +++ b/internal/phraser/deck.go @@ -0,0 +1,181 @@ +package phraser + +// 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" +) + +// deckEntry — one line she can say, in as many wordings as the file gives. +type deckEntry 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 deckFile struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Entries map[string]deckEntry `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 deckFile + 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 loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) { + var f deckFile + 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 deckEntry) 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 +}