mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-19 04:16:17 +00:00
feat(dictionary): build Vietnamese wordlist from upstream dictionary
Reduce the 179 MB minhqnd/dictionary SQLite release to a ~3 MB game wordlist: 48,216 Vietnamese words of two or more syllables, indexed by first and last syllable with an out-degree table for dead-end detection. Source schema is auto-detected rather than hardcoded, since it is someone else's release artifact; explicit flags override it and are validated against the real tables, because SQLite silently reads an unknown double-quoted column as a string literal. Accept a word only if every syllable fits Vietnamese phonotactics. An alphabet check is not enough: "credit card" and "come out" use only letters Vietnamese has, and the multilingual source tags them as Vietnamese. Onset matching backtracks so the gi digraph does not swallow the nucleus of common words like "gi", "gin" and "gi" (rust). Record accepted spelling variants in an alias table rather than solving tone placement at runtime. Tone shifting applies only to open oa/oe/uy syllables, since "hoan" and "hoai" have a single correct spelling, and "qu" is a consonant onset. The i/y alternation uses an onset allowlist plus explicit pairs, because it is lexical rather than productive. Variants are generated as a cross product over syllables so a word with two variable syllables still offers the fully modern spelling. Build to a temporary file and rename only after commit, so a failed run cannot leave an empty database where a good one was, then re-open the result and verify its invariants on disk. Licensing: the derived data is CC BY-SA 4.0 and stays a separate artifact from the Apache-2.0 code, loaded at runtime and never embedded. Ships NOTICE, data/LICENSE and an attribution file recording every change.
This commit is contained in:
1 parent
582ba27354
commit
4b4afea679
18 files changed
+2662
-1
No files matched your search
@@ -0,0 +1,54 @@
|
||||
// Package vietnamese normalizes Vietnamese word input.
|
||||
//
|
||||
// The same normalization runs in two places: the dictionary builder, which
|
||||
// normalizes the corpus, and the game server, which normalizes what a player
|
||||
// types. They must agree exactly — if they ever diverge, a word stored in the
|
||||
// database stops matching the identical string typed by a player. Sharing this
|
||||
// package is what prevents that.
|
||||
package vietnamese
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// MinSyllables is the shortest word the game accepts. Nối từ is played with
|
||||
// compound words; a single syllable is not a valid move.
|
||||
const MinSyllables = 2
|
||||
|
||||
// ErrEmpty is returned when input contains no usable characters.
|
||||
var ErrEmpty = errors.New("vietnamese: empty input")
|
||||
|
||||
// Normalize canonicalizes raw player or corpus input and splits it into syllables.
|
||||
//
|
||||
// It returns the canonical word (syllables joined by single spaces) and the
|
||||
// syllables themselves. It deliberately does not enforce a syllable count:
|
||||
// length is a game rule, not a text rule, so the engine owns that check.
|
||||
//
|
||||
// Steps, in order:
|
||||
// - NFC composition, so "ữ" written as a base plus a combining mark compares
|
||||
// equal to the single-codepoint form. Without this, words typed on different
|
||||
// keyboards or platforms fail to match identical database entries.
|
||||
// - lowercase, so capitalization never affects a lookup.
|
||||
// - whitespace split and rejoin, which collapses runs of spaces, tabs, and
|
||||
// non-breaking spaces that IMEs and copy-paste routinely introduce.
|
||||
func Normalize(raw string) (string, []string, error) {
|
||||
composed := norm.NFC.String(raw)
|
||||
lowered := strings.ToLower(composed)
|
||||
|
||||
// strings.Fields splits on every unicode.IsSpace rune, which covers tabs and
|
||||
// U+00A0 non-breaking spaces as well as ordinary spaces.
|
||||
syllables := strings.Fields(lowered)
|
||||
if len(syllables) == 0 {
|
||||
return "", nil, ErrEmpty
|
||||
}
|
||||
|
||||
return strings.Join(syllables, " "), syllables, nil
|
||||
}
|
||||
|
||||
// HasEnoughSyllables reports whether a word is long enough to be a legal move.
|
||||
func HasEnoughSyllables(syllables []string) bool {
|
||||
return len(syllables) >= MinSyllables
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package vietnamese
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
syllables int
|
||||
}{
|
||||
{"plain two syllables", "pháp luật", "pháp luật", 2},
|
||||
{"uppercase", "PHÁP LUẬT", "pháp luật", 2},
|
||||
{"mixed case", "Pháp Luật", "pháp luật", 2},
|
||||
{"leading and trailing space", " pháp luật ", "pháp luật", 2},
|
||||
{"repeated spaces", "pháp luật", "pháp luật", 2},
|
||||
{"tab separator", "pháp\tluật", "pháp luật", 2},
|
||||
{"non-breaking space", "pháp luật", "pháp luật", 2},
|
||||
{"newline separator", "pháp\nluật", "pháp luật", 2},
|
||||
{"three syllables", "vô tuyến điện", "vô tuyến điện", 3},
|
||||
{"four syllables", "công nghiệp hóa hiện", "công nghiệp hóa hiện", 4},
|
||||
{"single syllable passes through", "pháp", "pháp", 1},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
word, syllables, err := Normalize(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%q) returned error: %v", tc.raw, err)
|
||||
}
|
||||
if word != tc.want {
|
||||
t.Errorf("word = %q, want %q", word, tc.want)
|
||||
}
|
||||
if len(syllables) != tc.syllables {
|
||||
t.Errorf("got %d syllables, want %d", len(syllables), tc.syllables)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A word typed on one keyboard can arrive decomposed and on another composed.
|
||||
// Both must normalize to the same string, or database lookups fail for reasons
|
||||
// invisible to the player.
|
||||
func TestNormalizeUnicodeEquivalence(t *testing.T) {
|
||||
composed := "ngôn ngữ" // ữ as a single codepoint
|
||||
decomposed := "ngôn ngữ" // o+circumflex, u+horn+tilde
|
||||
|
||||
gotComposed, _, err := Normalize(composed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDecomposed, _, err := Normalize(decomposed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if gotComposed != gotDecomposed {
|
||||
t.Errorf("composed %q and decomposed %q normalized differently: %q vs %q",
|
||||
composed, decomposed, gotComposed, gotDecomposed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmpty(t *testing.T) {
|
||||
for _, raw := range []string{"", " ", "\t\n", " "} {
|
||||
if _, _, err := Normalize(raw); !errors.Is(err, ErrEmpty) {
|
||||
t.Errorf("Normalize(%q) error = %v, want ErrEmpty", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasEnoughSyllables(t *testing.T) {
|
||||
tests := []struct {
|
||||
syllables []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"pháp"}, false},
|
||||
{[]string{"pháp", "luật"}, true},
|
||||
{[]string{"vô", "tuyến", "điện"}, true},
|
||||
{nil, false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := HasEnoughSyllables(tc.syllables); got != tc.want {
|
||||
t.Errorf("HasEnoughSyllables(%v) = %v, want %v", tc.syllables, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user