Merge lang/i18n-data-fill 88823314: L is keys only - a plain string is drawn as it is in every language

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-30 03:11:16 +03:00
commit 744a03bdfa
18 changed files with 91 additions and 516 deletions

View file

@ -0,0 +1,9 @@
bump: minor
type: change
**ludic.i18n draws plain text as it is: the English path is gone (phase 26.9).** `L` makes a key, a
key glued into text, or a line bracketed inside another; anything else - a player's name, a chat
line, a number - is drawn as it is, in every language, so a player named "Settings" stays
"Settings". Removed with it: the lookup of English words (exact lines, patterns with holes, a
paragraph a sentence at a time, padding), `Ln` (use `trn(kn"...")`), `i18n_pattern_count`, and an
English argument's own lookup inside a key's hole. A language `.po` is read for its keys and
plurals only. A game still on English msgids draws them untranslated until they are keys.

View file

@ -26,7 +26,7 @@ section. The rules are in [ludic.base](ludic.base/README.md).
| [ludic.devlink](ludic.devlink/README.md) | an editor's live link into a running dev build: loopback UDP, one request a frame, verbs as ports |
| [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry |
| [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key |
| [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language |
| [ludic.i18n](ludic.i18n/README.md) | a game in any language by keys: gettext `.po` files (en.po the English), holes, plurals, a mod folder, a font per language |
| [ludic.inventory](ludic.inventory/README.md) | a pack: a count per kind, with room the game decides |
| [ludic.minimap](ludic.minimap/README.md) | a map's logic: the explored fog over a grid and its land share, the player's marks, and the view (world to map and back, zoom, pan, the camp, you, focus) |
| [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse |

View file

@ -1,19 +1,39 @@
# ludic.i18n
A game in any language, from gettext files anyone can add. English is the source and stays in the
code: nothing here, and nothing a translator does, rewrites a string in the game. A language is a
`.po` keyed by the English exactly as the game draws it, and text is translated at the moment it is
drawn, so a string that reaches the screen is covered wherever it was built. Uses nothing - not
even `ludic.base`; the font atlas and the mod folder are the game's, through a port.
A game in any language, from gettext files anyone can add. The code names text by KEYS, never by its
English: a key says what a text is FOR (`pause.resume`), `en.po` says what it says in English, and
every other language is a `.po` keyed the same way. Text is made at the moment it is drawn, so a
line that reaches the screen is covered wherever it was built. Uses nothing - not even
`ludic.base`; the font atlas and the mod folder are the game's, through a port.
```ludic
import "ludic.i18n"
i18n_use("tr") # from settings; the atlas follows
ov_text(x, y, 16, L("Settings"), ...)
ov_text(x, y, 16, L(`Day {day}`), ...) # "Day {1}" in the file matches "Day 12"
ov_text(x, y, 16, Ln("{1} trout", "{1} trout", n), ...)
i18n_use("tr") # from settings; the atlas follows
ov_text(x, y, 16, L(tr(k"pause.resume")), ...) # "Resume"; "Devam" in Turkish
ov_text(x, y, 16, L(trf(k"hud.day", `{day}`, "", "", "")), ...)
ov_text(x, y, 16, L(trn(kn"pack.items", n, "", "", "")), ...)
```
## Keys
A key travels as a string with a marker byte in front (the compiler's `k"pause.resume"` is exactly
that; `kn"..."` a plural key), so code that makes text never takes the language's state: the text is
made where it is drawn, by `L`.
- A key's text is the language in use's, else en.po's (read from the languages' directory the first
time a key is asked for), else the key itself - `[[key]]` in a developer's build (`i18n_loud`),
said once.
- Holes `{1}` .. `{4}` take `trf`'s arguments in the language's own order, and a translation may
reorder or drop them; an argument that is a key is made into its text first. A plural's count is
`{1}`, and its form is the language's `Plural-Forms` rule's.
- A key's line inside another's hole survives: an argument's own separators are escaped outside
brackets, and a line bracketed with bytes 29 .. 30 (a game's nested line) rides raw and is made
first, outermost in.
- **Anything without a marker is drawn as it is**, in every language: a player's name, a chat line,
a number, a path. There is no lookup by English words, so a player named "Settings" stays
"Settings". A key glued into such text (`"Photo: " + tr(k"...")`) is still made where it sits,
an interim help for code on its way to a key of its own.
## Where languages come from
Later ones replace earlier ones with the same code:
@ -22,40 +42,8 @@ Later ones replace earlier ones with the same code:
- every `*.po` in the mod folder the port names - a player drops a file in and picks it. No build.
`X-Language-Name` in the header is what a menu shows; `X-Font` names a font atlas directory beside
the `.po`, for a script the default atlas does not carry; `Plural-Forms` picks the plural rule.
## What a msgid can be
- `"Hold to break off deadwood"` - an exact string.
- `"Day {1}"` -> `"{1}. Gün"` - a pattern: the English around the holes must match, the holes are
whatever the game put there, and a translation may reorder or drop them. A hole's own text is
translated too when the file knows it - an item name, or "a deer" with its English article taken
off. Of several patterns that match, the one pinning down the most English wins.
- A paragraph the game assembled from sentences is tried whole, then a sentence at a time; the
padding a layout put round a word (" owned") is taken off and put back.
- `msgid_plural` / `msgstr[k]` - `Ln(one, many, n)`, by the file's `Plural-Forms`.
Whatever has no translation draws in English, so a partial file is always playable.
## Keys (phase 26: the form that replaces English in the code)
A key names what a text is FOR (`pause.resume`), and `assets/lang/en.po` says what it says in English,
like any other language. A key travels as a string with a marker byte in front (the compiler's
`k"pause.resume"` is exactly that; `kn"..."` a plural key), so code that makes text never takes the
language's state: the text is made where it is drawn, by `L`.
```ludic
L(tr(k"pause.resume")) # "Resume"; "Devam" in Turkish
L(trf(k"catch.saw", tr(k"species.elk.a_name"), "5:30", "", "")) # "You saw an elk at 5:30."
L(trn(kn"pack.items", n, "", "", "")) # "{1} items" by the language's plural rule
```
- A key's text is the language in use's, else en.po's (read from the languages' directory the first
time a key is asked for), else the key itself - `[[key]]` in a developer's build (`i18n_loud`),
said once.
- Holes `{1}` .. `{4}` take `trf`'s arguments in the language's own order; an argument that is a key
is made into its text first. A plural's count is `{1}`.
- A string with no marker takes the English path below, so a game moves over a file at a time.
the `.po`, for a script the default atlas does not carry; `Plural-Forms` picks the plural rule. A
language that lacks a key draws its English, so a partial file is always playable.
## Config and port
@ -73,8 +61,7 @@ port I18nWorld {
| | |
| --- | --- |
| `L(s) -> string` | what the game draws, in the language in use (cached) |
| `Ln(one, many, n) -> string` | a count's form, with `{1}` the count; English is one for 1, many otherwise (the old form) |
| `L(s) -> string` | what the game draws, in the language in use (made once per distinct line, kept) |
| `tr(k: Key)`, `trf(k: Key, a, b, c, d)`, `trn(k: Key, n, a, b, c) -> string` | a key (`k"..."`), a key with its holes' arguments, a plural key (`kn"..."`) by `n`: made into text by `L` |
| `i18n_key(name)`, `i18n_key_plural(name)`, `key_text(k)`, `i18n_is_key(s)` | a key named at run time, its name without the marker, whether a string is one |
| `i18n_base_text(text)`, `i18n_loud(on)` | the base (en.po) from text, for a test; a developer's build's loud missing key |
@ -82,21 +69,16 @@ port I18nWorld {
| `i18n_use(code)`, `i18n_off()`, `i18n_on()`, `i18n_current()` | choosing one; whether anything is translating |
| `i18n_font_base(dir)`, `i18n_font_now()` | the atlas the overlay was opened with, and the one it has now |
| `i18n_load_text(text)`, `i18n_parse(text) -> []PoEntry`, `i18n_header(text, field)` | a `.po` from text, outright |
| `i18n_exact_count()`, `i18n_pattern_count()`, `i18n_plural_rule()`, `i18n_plural_form(rule, n)`, `PL_*` | what the file gave |
| `i18n_exact_count()`, `i18n_plural_rule()`, `i18n_plural_form(rule, n)`, `PL_*` | what the file gave |
| `i18n_find`, `i18n_starts`, `i18n_ends` | the byte helpers, for a game that wants them |
## Keeping an extractor working
A game's extractor finds the English by reading its own source for literals (Maroon Lake's
`tools/i18n/extract.py`); nothing here changes what it reads. Build sentences a translator can
find: one template literal with holes, not fragments glued with `+`.
## Tests
```bash
ludic test packages/ludic.i18n
```
A shipped list and a mod folder in the temp directory: exact lines, patterns with translated holes
and articles, paragraphs, padding, plurals (a Russian-shaped rule), the font and its fallback, a mod
replacing a shipped code, a forced code, and English untouched.
`keys_test`: keys, holes, plurals, a key glued into text, plain text as it is. `nest_test`: lines
inside lines, bare, bracketed and escaped. `i18n_test`: a shipped list and a mod folder in the temp
directory, a language's keys, plurals (a Russian-shaped rule), the font and its fallback, a mod
replacing a shipped code, a forced code.

View file

@ -8,8 +8,6 @@ import "bytes.ludic"
import "po.ludic"
import "langs.ludic"
import "use.ludic"
import "patterns.ludic"
import "match.ludic"
import "translate.ludic"
import "keys.ludic"
import "keyed.ludic"

View file

@ -21,13 +21,6 @@ function kr_unescape(s: string) -> string {
return text_of(buf, n)
}
# an argument with letters in it (not a number, a time, a sign): while the game still hands English
# into a key's holes (until its code and data are keys, phase 26.9), it gets the old English lookup
function kr_words(s: string) -> bool {
for i in 0 .. len(s) { if (s[i] >= 65 and s[i] <= 90) or (s[i] >= 97 and s[i] <= 122) { return true } }
return false
}
# a count written as decimal digits (a leading - allowed); anything else is 0
function kr_int(s: string) -> int {
var v = 0

View file

@ -60,7 +60,7 @@ function kr_fill(i18n_st: mut I18nState, t: string, args: []string) -> string {
if h >= 1 {
var v = ""
if h < len(args) { v = args[h] }
if i18n_is_key(v) or kx_mixed(v) or kx_nested(v) { v = L(i18n_st, v) } else if i18n_st.on and kr_words(v) { v = L(i18n_st, v) }
if i18n_is_key(v) or kx_mixed(v) or kx_nested(v) { v = L(i18n_st, v) }
r = r + t[seg..i] + v
i = hole_end(t, i)
seg = i

View file

@ -1,96 +0,0 @@
# match.ludic - which pattern produces a string, the best one being the one that pins down the most
# English, and the text that filled each hole translated in turn
# does pattern p produce s? The holes land in caps, in hole order
function try_pattern(i18n_st: mut I18nState, p: int, s: string) -> bool {
let n = len(s)
let l0 = i18n_st.pat_l0[p]
let nl = i18n_st.pat_nl[p]
let first = i18n_st.lits[l0]
let f_n = len(first)
let last = i18n_st.lits[l0 + nl - 1]
let l_n = len(last)
if nl - 1 > CAPS { return false }
if n < f_n + l_n + nl - 1 { return false } # every hole takes at least a byte
for k in 0 .. f_n { if s[k] != first[k] { return false } }
for k in 0 .. l_n { if s[n - l_n + k] != last[k] { return false } }
let lim = n - l_n
var pos = f_n
for g in 0 .. nl - 1 {
var stop = lim
var adv = 0
if g < nl - 2 {
let mid = i18n_st.lits[l0 + g + 1]
stop = i18n_find(s, lim, pos + 1, mid, len(mid))
if stop < 0 { return false }
adv = len(mid)
}
if stop <= pos { return false }
i18n_st.caps[g] = s[pos..stop]
pos = stop + adv
}
return true
}
# the best of a chain that produces s, or `best` as it was
function best_of(i18n_st: mut I18nState, head: int, next: words, s: string, best: int) -> int {
var b = best
var c = head
while c > 0 {
let p = c - 1
var sc = -1
if b >= 0 { sc = i18n_st.pat_sc[b] }
if i18n_st.pat_sc[p] > sc and try_pattern(i18n_st, p, s) { b = p }
c = next[p]
}
return b
}
@alloc_ok("a translation cache miss (L): once per distinct text")
function pattern(i18n_st: mut I18nState, s: string, depth: int) -> string {
i18n_st.hit = false
if i18n_st.pat_n == 0 { return s }
let n = len(s)
var best = -1
if n >= 4 {
best = best_of(i18n_st, Dict.get_or(i18n_st.pfx, s[0..4], 0), i18n_st.nx_p, s, best)
best = best_of(i18n_st, Dict.get_or(i18n_st.sfx, s[n - 4..n], 0), i18n_st.nx_s, s, best)
}
if n < 80 { best = best_of(i18n_st, i18n_st.gen_head, i18n_st.nx_g, s, best) }
if best < 0 { return s }
try_pattern(i18n_st, best, s)
let l0 = i18n_st.pat_l0[best]
let nl = i18n_st.pat_nl[best]
# copied out first: translating a hole may match another pattern and reuse caps
let raw = new []string
for g in 0 .. nl - 1 { push(raw, i18n_st.caps[g]) }
let cs = new []string
for g in 0 .. nl - 1 { push(cs, piece(i18n_st, raw[g], depth + 1)) }
let r = fill_holes(i18n_st, i18n_st.pat_out[best], l0, nl, cs)
# a pattern that gives the English straight back has translated nothing, and must not stop
# the sentence-by-sentence pass
if r == s { return s }
i18n_st.hit = true
return r
}
# the text that filled a hole: known as it stands, known without the English article the game
# glued on ("a deer", "the Dark King"), or itself a pattern
function piece(i18n_st: mut I18nState, c: string, depth: int) -> string {
let e = Dict.get_or(i18n_st.exact, c, -1)
if e >= 0 { return i18n_st.out[e] }
let n = len(c)
var bare = -1
if n > 4 and (i18n_starts(c, "the ") or i18n_starts(c, "The ")) { bare = 4 }
else if n > 3 and (i18n_starts(c, "an ") or i18n_starts(c, "An ")) { bare = 3 }
else if n > 2 and (i18n_starts(c, "a ") or i18n_starts(c, "A ")) { bare = 2 }
if bare > 0 {
let e2 = Dict.get_or(i18n_st.exact, c[bare..n], -1)
if e2 >= 0 { return i18n_st.out[e2] }
}
if depth < 3 {
let r = pattern(i18n_st, c, depth)
if i18n_st.hit { return r }
}
return c
}

View file

@ -1,5 +1,5 @@
# mixed.ludic - a key's text glued into an English line ("A {1}, {2} cm." given a name that is a key, or a
# name + "\n" + its blurb): each marked name made in the language in use, then the line as English. Interim,
# name + "\n" + its blurb): each marked name made in the language in use; the words round it drawn as they are. Interim,
# until every such line is a key of its own (26.9); a mark with arguments cannot be told from the line after it
# a key's name: a-z, 0-9, _ and . (every key the compiler derives or a game writes), a full stop at its
@ -30,8 +30,7 @@ function kx_line(i18n_st: mut I18nState, s: string) -> string {
if i18n_st.kc == null { kc_clear(i18n_st) }
let ci = Dict.get_or(i18n_st.kc, s, -1)
if ci >= 0 { return i18n_st.kc_out[ci] }
var r = kx_unmix(i18n_st, s)
if i18n_st.on { r = whole(i18n_st, r) }
let r = kx_unmix(i18n_st, s)
if Dict.size(i18n_st.kc) >= CACHE_MAX { kc_clear(i18n_st) }
@alloc_ok("a line with a key glued in, made into text: kept, up to CACHE_MAX")
Dict.set(i18n_st.kc, s, len(i18n_st.kc_out))

View file

@ -1,83 +0,0 @@
# patterns.ludic - a msgid with holes ("Day {1}"): its literal pieces, how much English they pin
# down, and a chain by its first (else last) four bytes so a lookup tries only its own kind
function holes(s: string) -> int {
let n = len(s)
var h = 0
var i = 0
while i < n {
if hole_at(s, i) >= 0 {
h += 1
i = hole_end(s, i)
continue
}
i += 1
}
return h
}
function add_pattern(i18n_st: mut I18nState, id: string, tr: string) -> void {
let p = i18n_st.pat_n
i18n_st.pat_n += 1
let l0 = len(i18n_st.lits)
let n = len(id)
var seg = 0
var i = 0
var nl = 0
var score = 0
while i < n {
let num = hole_at(id, i)
if num >= 0 {
push(i18n_st.lits, id[seg..i])
score += i - seg
i18n_st.pat_ph[l0 + nl] = num
nl += 1
i = hole_end(id, i)
seg = i
continue
}
i += 1
}
push(i18n_st.lits, id[seg..n])
score += n - seg
nl += 1
i18n_st.pat_l0[p] = l0
i18n_st.pat_nl[p] = nl
i18n_st.pat_sc[p] = score
push(i18n_st.pat_out, tr)
chain(i18n_st, p, i18n_st.lits[l0], i18n_st.lits[l0 + nl - 1])
}
function chain(i18n_st: mut I18nState, p: int, first: string, last: string) -> void {
if len(first) >= 4 {
let key: string = first[0..4]
i18n_st.nx_p[p] = Dict.get_or(i18n_st.pfx, key, 0)
Dict.set(i18n_st.pfx, key, p + 1)
} else if len(last) >= 4 {
let key: string = last[len(last) - 4..len(last)]
i18n_st.nx_s[p] = Dict.get_or(i18n_st.sfx, key, 0)
Dict.set(i18n_st.sfx, key, p + 1)
} else {
i18n_st.nx_g[p] = i18n_st.gen_head
i18n_st.gen_head = p + 1
}
}
# a translation with its holes filled from caps, by number: a translation may reorder or drop them
function fill_holes(i18n_st: I18nState, t: string, l0: int, nl: int, cs: []string) -> string {
let n = len(t)
var r = ""
var seg = 0
var i = 0
while i < n {
let num = hole_at(t, i)
if num >= 0 {
r = r + t[seg..i]
for g in 0 .. nl - 1 { if i18n_st.pat_ph[l0 + g] == num { r = r + cs[g] } }
i = hole_end(t, i)
seg = i
continue
}
i += 1
}
return r + t[seg..n]
}

View file

@ -41,36 +41,3 @@ function plural_add(i18n_st: mut I18nState, e: PoEntry) -> void {
if k < len(e.forms) { push(i18n_st.forms, e.forms[k]) } else { push(i18n_st.forms, e.forms[len(e.forms) - 1]) }
}
}
# `one` or `many` by the count, in the language in use, with every {1} the count: Ln("{1} trout",
# "{1} trout", 3). English (or a line the file lacks) is one for 1 and many otherwise
export function Ln(i18n_st: I18nState, one: string, many: string, n: int) -> string {
var t = many
if n == 1 { t = one }
if i18n_st.on {
let k = Dict.get_or(i18n_st.plural, one, -1)
if k >= 0 {
let f = i18n_st.forms[k * 3 + i18n_plural_form(i18n_st.plural_rule, n)]
if not blank(f) { t = f }
}
}
return count_in(t, n)
}
@alloc_ok("a counted line (Ln) made when the game asks for one, outside a frame; its caller keeps it")
function count_in(t: string, n: int) -> string {
let m = len(t)
var r = ""
var seg = 0
var i = 0
while i < m {
if hole_at(t, i) == 1 {
r = r + t[seg..i] + `{n}`
i = hole_end(t, i)
seg = i
continue
}
i += 1
}
return r + t[seg..m]
}

View file

@ -24,23 +24,6 @@ export state I18nState {
font_now: string = ""
exact: Dict = null # msgid -> index into out
out: []string = null
lits: []string = null # every pattern's literal pieces, one run per pattern
pat_out: []string = null
pat_l0: words = null # where a pattern's pieces start in lits
pat_nl: words = null # how many pieces (holes + 1)
pat_sc: words = null # bytes of English it pins down: more is a better match
pat_ph: words = null # by piece: the hole number that follows it
pat_n: int = 0
pfx: Dict = null # a pattern's first four bytes -> its chain head + 1
sfx: Dict = null # else its last four
gen_head: int = 0 # else the short list, tried on short strings only
nx_p: words = null
nx_s: words = null
nx_g: words = null
cache: Dict = null # English as drawn -> index into cache_out
cache_out: []string = null
caps: []string = null
hit: bool = false
plural: Dict = null # a plural msgid (the singular) -> index into forms / 3
forms: []string = null # three forms per entry, "" where the file has fewer
plural_rule: int = 0 # PL_*

View file

@ -3,7 +3,6 @@
const CACHE_MAX: int = 4000
const CAPS: int = 16
export function i18n_on(i18n_st: I18nState) -> bool { return i18n_st.on }
export function i18n_current(i18n_st: I18nState) -> string { return i18n_st.cur }
@ -12,7 +11,6 @@ export function i18n_exact_count(i18n_st: I18nState) -> int {
if i18n_st.out == null { return 0 }
return len(i18n_st.out)
}
export function i18n_pattern_count(i18n_st: I18nState) -> int { return i18n_st.pat_n }
# the overlay's own font, as the overlay was opened with it
export function i18n_font_base(i18n_st: mut I18nState, path: string) -> void { i18n_st.font_now = path }

View file

@ -3,22 +3,6 @@
property I18nTables {
exact: Dict = null
out: []string = null
lits: []string = null
pat_out: []string = null
pat_l0: words = null
pat_nl: words = null
pat_sc: words = null
pat_ph: words = null
pat_n: int = 0
pfx: Dict = null
sfx: Dict = null
gen_head: int = 0
nx_p: words = null
nx_s: words = null
nx_g: words = null
cache: Dict = null
cache_out: []string = null
caps: []string = null
plural: Dict = null
forms: []string = null
plural_rule: int = 0
@ -29,22 +13,6 @@ function tables_keep(i18n_st: I18nState) -> I18nTables {
let t = new I18nTables
t.exact = i18n_st.exact
t.out = i18n_st.out
t.lits = i18n_st.lits
t.pat_out = i18n_st.pat_out
t.pat_l0 = i18n_st.pat_l0
t.pat_nl = i18n_st.pat_nl
t.pat_sc = i18n_st.pat_sc
t.pat_ph = i18n_st.pat_ph
t.pat_n = i18n_st.pat_n
t.pfx = i18n_st.pfx
t.sfx = i18n_st.sfx
t.gen_head = i18n_st.gen_head
t.nx_p = i18n_st.nx_p
t.nx_s = i18n_st.nx_s
t.nx_g = i18n_st.nx_g
t.cache = i18n_st.cache
t.cache_out = i18n_st.cache_out
t.caps = i18n_st.caps
t.plural = i18n_st.plural
t.forms = i18n_st.forms
t.plural_rule = i18n_st.plural_rule
@ -54,22 +22,6 @@ function tables_keep(i18n_st: I18nState) -> I18nTables {
function tables_put(i18n_st: mut I18nState, t: I18nTables) -> void {
i18n_st.exact = t.exact
i18n_st.out = t.out
i18n_st.lits = t.lits
i18n_st.pat_out = t.pat_out
i18n_st.pat_l0 = t.pat_l0
i18n_st.pat_nl = t.pat_nl
i18n_st.pat_sc = t.pat_sc
i18n_st.pat_ph = t.pat_ph
i18n_st.pat_n = t.pat_n
i18n_st.pfx = t.pfx
i18n_st.sfx = t.sfx
i18n_st.gen_head = t.gen_head
i18n_st.nx_p = t.nx_p
i18n_st.nx_s = t.nx_s
i18n_st.nx_g = t.nx_g
i18n_st.cache = t.cache
i18n_st.cache_out = t.cache_out
i18n_st.caps = t.caps
i18n_st.plural = t.plural
i18n_st.forms = t.forms
i18n_st.plural_rule = t.plural_rule

View file

@ -1,6 +1,6 @@
# i18n_test.ludic - languages from a shipped list and a mod folder, exact lines, patterns with their
# holes translated, a paragraph a sentence at a time, padding, plurals, the font a language names,
# and English untouched throughout; every file written to the temp directory
# i18n_test.ludic - languages from a shipped list and a mod folder, a language's keys and their holes,
# plurals, the font a language names, and plain text drawn as it is; every file written to the
# temp directory
import "ludic.i18n"
program I18nTest {
numbers float
@ -30,11 +30,10 @@ program I18nTest {
# a shipped "tt" with a font of its own, and a Russian-shaped plural
function tt() -> string {
var s = header("Test Tongue", "\"X-Font: font_tt\\n\"\n\"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : 2);\\n\"")
s = s + po_line("Settings", "Zettings") + po_line("Plant fiber", "Lif") + po_line("a deer", "") + po_line("deer", "geyik")
s = s + po_line("Day {1}", "{1}. Gun") + po_line("+{1} {2}", "{2} +{1}") + po_line("You saw {1}.", "{1} gordun.")
s = s + po_line("You need to eat.", "Yemek lazim.") + po_line("owned", "senin")
s = s + "msgid \"{1} trout\"\nmsgid_plural \"{1} trout\"\nmsgstr[0] \"{1} alabalik\"\nmsgstr[1] \"{1} alabaliga\"\nmsgstr[2] \"{1} alabaliklar\"\n\n"
s = s + "msgid \"Multi\"\n\"line\"\nmsgstr \"Cok\"\n\"satir\\n\"\n"
s = s + po_line("menu.settings", "Zettings") + po_line("items.fiber.name", "Lif") + po_line("hud.day", "{1}. Gun")
s = s + po_line("pack.gain", "{2} +{1}") + po_line("empty.one", "")
s = s + "msgid \"catch.count\"\nmsgid_plural \"catch.count\"\nmsgstr[0] \"{1} alabalik\"\nmsgstr[1] \"{1} alabaliga\"\nmsgstr[2] \"{1} alabaliklar\"\n\n"
s = s + "msgid \"multi.line\"\nmsgstr \"Cok\"\n\"satir\\n\"\n"
return s
}
@ -62,39 +61,25 @@ program I18nTest {
expect(L(i18n_st, "Settings") == "Settings")
}
test "exact lines, patterns with translated holes, and English where unknown" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
test "a language's keys, their holes, and plain text drawn as it is" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
fresh(i18n_st, i18n_test_st)
i18n_use(i18n_st, "tt")
expect(i18n_on(i18n_st))
expect(i18n_current(i18n_st) == "tt")
expect(L(i18n_st, "Settings") == "Zettings")
expect(L(i18n_st, "Day 12") == "12. Gun")
expect(L(i18n_st, "+2 Plant fiber") == "Lif +2")
expect(L(i18n_st, "You saw a deer.") == "geyik gordun.")
expect(L(i18n_st, "zzqx words nobody wrote") == "zzqx words nobody wrote")
expect(L(i18n_st, "Multiline") == "Coksatir\n")
expect_eq(i18n_exact_count(i18n_st), 6)
expect_eq(i18n_pattern_count(i18n_st), 3)
expect(L(i18n_st, tr(k"menu.settings")) == "Zettings")
expect(L(i18n_st, trf(k"hud.day", "12", "", "", "")) == "12. Gun")
expect(L(i18n_st, trf(k"pack.gain", "2", tr(k"items.fiber.name"), "", "")) == "Lif +2")
expect(L(i18n_st, tr(k"multi.line")) == "Coksatir\n")
expect(L(i18n_st, "Settings") == "Settings")
expect_eq(i18n_exact_count(i18n_st), 5)
}
test "a paragraph a sentence at a time, and a layout's padding kept" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
test "plurals by the file's rule" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
fresh(i18n_st, i18n_test_st)
i18n_use(i18n_st, "tt")
expect(L(i18n_st, "You need to eat. zzqx words nobody wrote.") == "Yemek lazim. zzqx words nobody wrote.")
expect(L(i18n_st, "zzqx. zzqy.") == "zzqx. zzqy.")
expect(L(i18n_st, " owned") == " senin")
expect(L(i18n_st, " zzqx") == " zzqx")
}
test "plurals by the file's rule, and English by one and many" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
fresh(i18n_st, i18n_test_st)
expect(Ln(i18n_st, "{1} trout", "{1} trouts", 1) == "1 trout")
expect(Ln(i18n_st, "{1} trout", "{1} trouts", 3) == "3 trouts")
i18n_use(i18n_st, "tt")
expect_eq(i18n_plural_rule(i18n_st), PL_SLAVIC)
expect(Ln(i18n_st, "{1} trout", "{1} trouts", 21) == "21 alabalik")
expect(Ln(i18n_st, "{1} trout", "{1} trouts", 5) == "5 alabaliklar")
expect(Ln(i18n_st, "{1} fish", "{1} fishes", 2) == "2 fishes")
expect(L(i18n_st, trn(kn"catch.count", 21, "", "", "")) == "21 alabalik")
expect(L(i18n_st, trn(kn"catch.count", 5, "", "", "")) == "5 alabaliklar")
expect_eq(i18n_plural_form(PL_NOT_ONE, 0), 1)
expect_eq(i18n_plural_form(PL_ABOVE_ONE, 0), 0)
expect_eq(i18n_plural_form(PL_SLAVIC, 3), 1)
@ -117,28 +102,28 @@ program I18nTest {
test "a language switched back to is its kept tables, not its file read again; a new probe reads it" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
fresh(i18n_st, i18n_test_st)
i18n_use(i18n_st, "tt")
let first = L(i18n_st, "Settings")
Fs.write_text(dir() + "/tt.po", header("Tongue", "") + po_line("Settings", "Changed"))
let first = L(i18n_st, tr(k"menu.settings"))
Fs.write_text(dir() + "/tt.po", header("Tongue", "") + po_line("menu.settings", "Changed"))
i18n_use(i18n_st, "en")
i18n_use(i18n_st, "tt")
expect(L(i18n_st, "Settings") == first)
expect(L(i18n_st, tr(k"menu.settings")) == first)
i18n_init(i18n_st)
i18n_use(i18n_st, "tt")
expect(L(i18n_st, "Settings") == "Changed")
expect(L(i18n_st, tr(k"menu.settings")) == "Changed")
}
test "a mod in the player's folder is found, replaces a shipped code, and goes when removed" (i18n_st: mut I18nState, i18n_test_st: mut I18nTestState) {
fresh(i18n_st, i18n_test_st)
Fs.write_text(mods() + "/zz.po", header("Mod Tongue", "") + po_line("Settings", "Mettings"))
Fs.write_text(mods() + "/tt.po", header("Tongue Two", "") + po_line("Settings", "Z2"))
Fs.write_text(mods() + "/zz.po", header("Mod Tongue", "") + po_line("menu.settings", "Mettings"))
Fs.write_text(mods() + "/tt.po", header("Tongue Two", "") + po_line("menu.settings", "Z2"))
i18n_init(i18n_st)
expect_eq(i18n_count(i18n_st), 3)
expect(i18n_name_at(i18n_st, i18n_index(i18n_st, "zz")) == "Mod Tongue")
i18n_use(i18n_st, "zz")
expect(L(i18n_st, "Settings") == "Mettings")
expect(L(i18n_st, tr(k"menu.settings")) == "Mettings")
expect(L(i18n_st, "Day 3") == "Day 3")
i18n_use(i18n_st, "tt")
expect(L(i18n_st, "Settings") == "Z2")
expect(L(i18n_st, tr(k"menu.settings")) == "Z2")
Fs.remove(mods() + "/zz.po")
Fs.remove(mods() + "/tt.po")
i18n_init(i18n_st)
@ -158,9 +143,9 @@ program I18nTest {
test "a header field, and a .po loaded from text" (i18n_st: mut I18nState) {
expect(i18n_header(tt(), "X-Language-Name") == "Test Tongue")
expect(i18n_header(tt(), "X-Nothing") == "")
i18n_load_text(i18n_st, po_line("Quit", "Cik"))
expect(L(i18n_st, "Quit") == "Cik")
i18n_load_text(i18n_st, po_line("pause.quit", "Cik"))
expect(L(i18n_st, tr(k"pause.quit")) == "Cik")
i18n_off(i18n_st)
expect(L(i18n_st, "Quit") == "Quit")
expect(not (L(i18n_st, tr(k"pause.quit")) == "Cik")) # back to English, whatever en.po is found
}
}

View file

@ -1,6 +1,6 @@
# keys_test.ludic - keys where English was: en.po as the base under every language, a key's text from the
# language in use else English else the key ([[key]] in a developer's build), holes filled by arguments
# that may be keys themselves, plural forms by each language's rule, and the old English form beside them
# that may be keys themselves, plural forms by each language's rule, and plain text drawn as it is
import "ludic.i18n"
program I18nKeysTest {
numbers float
@ -68,24 +68,14 @@ program I18nKeysTest {
expect(L(i18n_st, trn(kn"catch.count", 2, "", "", "")) == "2 trout") # the language lacks it
}
test "the old English form still works beside keys, so a game can move over a file at a time" (i18n_st: mut I18nState) {
test "a plain string is drawn as it is in every language: a player named Settings stays Settings" (i18n_st: mut I18nState) {
start (i18n_st)
expect(L(i18n_st, "Settings") == "Settings")
i18n_load_text(i18n_st, tt())
expect(L(i18n_st, "Settings") == "Ayarlar")
expect(L(i18n_st, tr(k"pause.resume")) == "Devam")
expect(L(i18n_st, "Settings") == "Settings" and L(i18n_st, tr(k"pause.resume")) == "Devam")
expect(L(i18n_st, trf(k"catch.saw", "Settings", "5:30", "", "")) == "5:30 saatinde Settings gordun.") # a name in a hole too
expect(not i18n_is_key("Settings") and i18n_is_key(i18n_key("x")) and i18n_is_key(i18n_key_plural("x")))
}
test "an English value handed into a key's hole still gets its old English line, until it is a key itself" (i18n_st: mut I18nState) {
start (i18n_st)
expect(L(i18n_st, trf(k"catch.saw", "Settings", "5:30", "", "")) == "You saw Settings at 5:30.")
i18n_load_text(i18n_st, tt())
expect(L(i18n_st, trf(k"catch.saw", "Settings", "5:30", "", "")) == "5:30 saatinde Ayarlar gordun.") # exact
expect(L(i18n_st, trf(k"catch.saw", "Day 3", "5:30", "", "")) == "5:30 saatinde 3. gun gordun.") # a pattern
expect(L(i18n_st, trf(k"catch.saw", "Nowhere known", "7", "", "")) == "7 saatinde Nowhere known gordun.") # no line: as it is
}
test "a key's text glued into an English line is made where it sits, until the line is a key of its own" (i18n_st: mut I18nState) {
start (i18n_st)
let elk = tr(k"species.elk.a_name")
@ -94,7 +84,7 @@ program I18nKeysTest {
expect(L(i18n_st, "Not owned. " + tr(k"pause.quit")) == "Not owned. Quit" and L(i18n_st, elk + "Quit") == "an elkQuit")
expect(L(i18n_st, trf(k"catch.saw", "Seen: " + elk, "5:30", "", "")) == "You saw Seen: an elk at 5:30.")
i18n_load_text(i18n_st, tt())
expect(L(i18n_st, "Day " + tr(k"pause.resume")) == "Devam. gun") # the name made, then the line's own pattern
expect(L(i18n_st, "Day " + tr(k"pause.resume")) == "Day Devam") # the name made; the words round it as they are
expect(L(i18n_st, trf(k"catch.saw", elk, "5:30", "", "")) == "5:30 saatinde bir geyik gordun.") # a key whole is untouched
}
}

View file

@ -28,8 +28,8 @@ program I18nNestTest {
expect(L(i18n_st, trf(k"shop.ada", day, "", "", "")) == "Ada: Day 3")
expect(L(i18n_st, b(trf(k"shop.ada", day, "", "", ""))) == "Ada: Day 3")
i18n_load_text(i18n_st, tt())
expect(L(i18n_st, "Settings " + day) == "Settings 3. Gun") # the rest is English, looked up as it was
expect(L(i18n_st, b("Settings")) == "Ayarlar")
expect(L(i18n_st, "Settings " + day) == "Settings 3. Gun") # the words round it drawn as they are
expect(L(i18n_st, b("Settings")) == "Settings") # a name in a bracket too
}
test "a bracket with no partner draws nothing of itself" (i18n_st: mut I18nState) {

View file

@ -1,83 +1,10 @@
# translate.ludic - what the game draws, in the language in use: a key (keyed_render.ludic); else, the old
# English form, exact, else a pattern, else padding taken off, else a paragraph a sentence at a time
# translate.ludic - what the game draws, in the language in use: a key (keyed_render.ludic), a line with
# a key glued into it (mixed.ludic) or bracketed inside it (nest.ludic); anything else is drawn as it is -
# a player's name, a chat line, a number. English lives in en.po under keys, never looked up by its words
export function L(i18n_st: mut I18nState, s: string) -> string {
let mixed = kx_mixed(s)
if i18n_is_key(s) and not mixed { return keyed(i18n_st, s) } # a key whole: its arguments are made in it
if kx_nested(s) { return kx_nest_line(i18n_st, s) }
if mixed { return kx_line(i18n_st, s) }
if not i18n_st.on { return s }
let ci = Dict.get_or(i18n_st.cache, s, -1)
if ci >= 0 { return i18n_st.cache_out[ci] }
let r = whole(i18n_st, s)
if Dict.size(i18n_st.cache) >= CACHE_MAX {
Dict.clear(i18n_st.cache)
List.clear(i18n_st.cache_out)
}
@alloc_ok("a translation cache miss: the entry is kept, up to CACHE_MAX")
Dict.set(i18n_st.cache, s, len(i18n_st.cache_out))
@alloc_ok("a translation cache miss: as above")
push(i18n_st.cache_out, r)
return r
}
@alloc_ok("a translation cache miss (L): once per distinct text, bounded by CACHE_MAX")
function whole(i18n_st: mut I18nState, s: string) -> string {
let n = len(s)
if n == 0 { return s }
let e = Dict.get_or(i18n_st.exact, s, -1)
if e >= 0 { return i18n_st.out[e] }
let r = pattern(i18n_st, s, 0)
if i18n_st.hit { return r }
# the padding a layout put round a word: " owned"
var a = 0
var b = n
while a < n and s[a] == 32 { a += 1 }
while b > a and s[b - 1] == 32 { b -= 1 }
if a > 0 or b < n {
if b <= a { return s }
let core: string = s[a..b]
let t = whole(i18n_st, core)
if t == core { return s }
return s[0..a] + t + s[b..n]
}
return sentences(i18n_st, s)
}
# a paragraph built from sentences, one at a time; English stays where a sentence is unknown
@alloc_ok("a translation cache miss (L): once per distinct text")
function sentences(i18n_st: mut I18nState, s: string) -> string {
let n = len(s)
var r = ""
var start = 0
var any = false
var pieces = 0
var i = 0
while i < n - 1 {
let c = s[i]
if (c == 46 or c == 33 or c == 63) and s[i + 1] == 32 {
let t = sentence(i18n_st, s[start..i + 1])
if i18n_st.hit { any = true }
r = r + t + " "
start = i + 2
pieces += 1
i += 2
continue
}
i += 1
}
if pieces == 0 { return s }
let t = sentence(i18n_st, s[start..n])
if i18n_st.hit { any = true }
if not any { return s }
return r + t
}
function sentence(i18n_st: mut I18nState, p: string) -> string {
i18n_st.hit = false
let e = Dict.get_or(i18n_st.exact, p, -1)
if e >= 0 {
i18n_st.hit = true
return i18n_st.out[e]
}
return pattern(i18n_st, p, 0)
return s
}

View file

@ -40,43 +40,14 @@ export function i18n_load_text(i18n_st: mut I18nState, text: string) -> void {
kc_clear(i18n_st)
i18n_st.exact = Dict.new()
i18n_st.out = new []string
i18n_st.cache = Dict.new()
i18n_st.cache_out = new []string
i18n_st.caps = new []string
for c in 0 .. CAPS { push(i18n_st.caps, "") }
i18n_st.plural = Dict.new()
i18n_st.forms = new []string
i18n_st.plural_rule = plural_rule_of(i18n_header(text, "Plural-Forms"))
var np = 0
var nlit = 0
for i in 0 .. len(es) {
if blank(es[i].id) or blank(es[i].str) { continue }
let h = holes(es[i].id)
if h > 0 {
np += 1
nlit += h + 1
}
}
i18n_st.pat_out = new []string
i18n_st.lits = new []string
i18n_st.pat_l0 = words(np + 1)
i18n_st.pat_nl = words(np + 1)
i18n_st.pat_sc = words(np + 1)
i18n_st.pat_ph = words(nlit + 1)
i18n_st.nx_p = words(np + 1)
i18n_st.nx_s = words(np + 1)
i18n_st.nx_g = words(np + 1)
i18n_st.pfx = Dict.new()
i18n_st.sfx = Dict.new()
i18n_st.gen_head = 0
i18n_st.pat_n = 0
for i in 0 .. len(es) {
let e = es[i]
if len(e.plural) > 0 { plural_add(i18n_st, e) } else if not (blank(e.id) or blank(e.str)) {
if holes(e.id) > 0 { add_pattern(i18n_st, e.id, e.str) } else {
Dict.set(i18n_st.exact, e.id, len(i18n_st.out))
push(i18n_st.out, e.str)
}
Dict.set(i18n_st.exact, e.id, len(i18n_st.out))
push(i18n_st.out, e.str)
}
}
i18n_st.on = true