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 Deck with no entries answers // from the floor, which is what an unloadable file leaves behind (FloorDeck). 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, fillable(e.Variants, vars)) } } 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 } // VariantsOf returns the wordings the file gives for one key, for a test that // has something to say about every one of them. func (d *Deck) VariantsOf(key string) []string { if d == nil { return nil } return d.file.Entries[key].Variants } // UnfixedSingles lists the keys with exactly one variant that are not marked // fixed. Nothing breaks on one — the picker has nothing to pick either way — but // the flag is what a reader goes by, and parallel entries disagreeing about it // is how a family stops being readable. Load already rejects the other half of // the rule, fixed with more than one variant, so this is the pair to it. func (d *Deck) UnfixedSingles() []string { if d == nil { return nil } var out []string for _, k := range d.keys { if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed { out = append(out, k) } } return out } // fillable narrows variants to the ones this call can actually say, which is // the rule an optional placeholder needs: a caller with nothing to put in // {tail} must not be handed a variant that has one. Two passes, because both // halves matter. The first keeps only variants whose every placeholder has a // non-empty value, so an absent optional never reaches him as braces. The // second prefers, among those, the variants using the most of what the caller // supplied, so a caveat he was given is not dropped for a shorter wording. // Nothing fillable leaves the list alone, and the unfilled placeholder shows // up in the answer rather than turning it into silence. func fillable(variants []string, vars map[string]string) []string { if len(variants) < 2 { return variants } best, bestUsed := make([]string, 0, len(variants)), -1 for _, v := range variants { used := 0 ok := true for _, ph := range placeholders(v) { if vars[ph] == "" { ok = false break } used++ } if !ok || used < bestUsed { continue } if used > bestUsed { best, bestUsed = best[:0], used } best = append(best, v) } if len(best) == 0 { return variants } return best } // placeholders lists the {name}s in tmpl, in order. func placeholders(tmpl string) []string { var out []string for { i := strings.IndexByte(tmpl, '{') if i < 0 { return out } j := strings.IndexByte(tmpl[i:], '}') if j < 0 { return out } out = append(out, tmpl[i+1:i+j]) tmpl = tmpl[i+j+1:] } } // pick chooses at random, skipping whatever this entry said last time. func (d *Deck) pick(key string, variants []string) string { d.mu.Lock() defer d.mu.Unlock() choices := 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. Every deck carries its own family's // map, including the floor-only deck an unloadable file leaves behind, so no // lookup ever crosses families. It used to go through one global map keyed by // bare entry name, which two families both calling an entry query_unknown // silently shared: whichever registered last answered for both (Vikunja #521). func floorOf(d *Deck, key string) string { if d == nil { return "" } return d.floor[key] } // FloorDeck — the deck a family falls back to when its file will not load. It // has no entries, so every read drops through to the floor literals, and it is // a real *Deck so no accessor has to know which case it is in. func FloorDeck(floor map[string]string) *Deck { return &Deck{last: map[string]string{}, floor: 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 }