Merge commit '34c2ac2' into lang/foundations

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 18:34:52 +03:00
commit 48bf705ca4
10 changed files with 75749 additions and 72417 deletions

View file

@ -636,17 +636,26 @@ the lines above it:
property Tool { property Tool {
@Ref(Vendors) seller: int = 0 # an index into that registry: its entries are offered @Ref(Vendors) seller: int = 0 # an index into that registry: its entries are offered
@OneOf(GR_) grade: int = 0 # one of the constants whose names start GR_ @OneOf(GR_) grade: int = 0 # one of the constants whose names start GR_
@OneOf(GR_GOLD, GR_SILVER) medal: int = 0 # or one of these constants
@Range(0, 20.5) @Unit("kg") weight: float = 1.0 @Range(0, 20.5) @Unit("kg") weight: float = 1.0
@Asset("gltf") model: string = "" # a file of that kind (any string) @Asset("gltf") model: string = "" # a file of that kind (any string)
@Color tint: int = 0 @Color tint: int = 0
@Node(model) grip: string = "" # a node inside the glTF that `model` names
@Clip(model) swing: string = "" # a clip inside it
@Derived reach: float = 0.0 # worked out at boot: anything written is overwritten
@Text @Multiline blurb: string = "" # read by the player (so translated), and prose
@Key bind: int = 0 # a key code
} }
@AppendOnly @ByKey @AppendOnly @ByKey
registry Tools of Tool as TL from "data/tools.lres" registry Tools of Tool as TL from "data/tools.lres"
``` ```
They change nothing the program does. One is checked: `@Ref` must name a registry the program They change nothing the program does. Three are checked, and every field that fails is reported, not
declares, and a field whose `@Ref` names none is an error naming the field and the registry (every only the first: `@Ref` must name a registry the program declares; `@OneOf` with one argument ending
such field is reported, not only the first). in `_` is a prefix, and otherwise each argument is a constant that must exist; and `@Node(f)` or
`@Clip(f)` must name a field
`f` of the same record that is either `@Asset("gltf")` or an `@Ref` to a registry whose record has
exactly one `@Asset("gltf")` field (the model is then the referenced row's).
`ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the `ludicc app.ludic --emit-schema out.json` (or `ludic schema [file] [-o out.json]`) writes what the
compiler resolved, once the types are checked and every open registry has its entries, as one JSON compiler resolved, once the types are checked and every open registry has its entries, as one JSON

View file

@ -8,7 +8,9 @@ prefix, resource file, openness and its entries in their final order after the o
which entries in); every const; and every function a `fn` value can name, with the `fn_type` a field sees (its states stripped). which entries in); every const; and every function a `fn` value can name, with the `fn_type` a field sees (its states stripped).
Deterministic, `"schema_version": 1`. Fields and registries carry editor attributes on the existing Deterministic, `"schema_version": 1`. Fields and registries carry editor attributes on the existing
`@` syntax - `@Ref(Registry)`, `@OneOf(PREFIX_)`, `@Range(lo, hi)`, `@Unit("m/s")`, `@Asset("gltf")`, `@` syntax - `@Ref(Registry)`, `@OneOf(PREFIX_)`, `@Range(lo, hi)`, `@Unit("m/s")`, `@Asset("gltf")`,
`@Color`, and `@AppendOnly` / `@ByKey` on a registry - which change nothing but go into the schema; `@Color`, `@Node(field)`, `@Clip(field)`, `@Derived`, `@Text`, `@Multiline`, `@Key`, and `@AppendOnly` / `@ByKey` on
`@Ref` naming no registry is an error. A field may now carry several attributes. `ludicc --check a registry - which change nothing but go into the schema; `@Ref` naming no registry is an error, and
so is `@Node` / `@Clip` naming a field that is not a glTF (`@Asset("gltf")`, or an `@Ref` to one), and
a listing `@OneOf` (`@OneOf(A, B)`, not a prefix `@OneOf(P_)`) naming a constant that does not exist. A field may now carry several attributes. `ludicc --check
--diagnostics=json` (`ludic build --check --diagnostics=json`) prints every error as one JSON array --diagnostics=json` (`ludic build --check --diagnostics=json`) prints every error as one JSON array
of `{file, line, col, severity, message}` on stdout; tokens and nodes now know their column. of `{file, line, col, severity, message}` on stdout; tokens and nodes now know their column.

View file

@ -1,8 +1,8 @@
# attributes.ludic — editor metadata on the @-syntax: which registry a field indexes, which constants # attributes.ludic — editor metadata on the @-syntax: which registry a field indexes, which constants
# it takes, its range, its unit, the kind of file it names, that it is a colour, and a registry's own # it takes, its range, its unit, the kind of file it names, that it is a colour, and a registry's own
# rules (append only, saved by key). None of them changes what the program does: `ludicc # rules (append only, saved by key). None of them changes what the program does: `ludicc
# --emit-schema` carries them to an editor with every record, registry entry and const. One is # --emit-schema` carries them to an editor with every record, registry entry and const. Two are
# checked - @Ref must name a registry that exists. # checked - @Ref must name a registry that exists, and @Node a field of its record naming a glTF.
# #
# Running it prints: 2 1 Lantern 2 # Running it prints: 2 1 Lantern 2
program Attributes { program Attributes {
@ -26,9 +26,20 @@ program Attributes {
model: string = "" # its model, under assets/kit model: string = "" # its model, under assets/kit
@Color tint: int = 16777215 @Color tint: int = 16777215
@OneOf(VD_) stocked_at: int = VD_STORE @OneOf(VD_) stocked_at: int = VD_STORE
@Node(model) grip: string = "grip" # where a hand holds it, a node in that model
@Derived reach: float = 0.0 # worked out at boot from the model
@Text @Multiline blurb: string = "" # what the shop says about it
@Key bind: int = 0 # the key that takes it in hand
@Clip(model) swing: string = "swing" # the clip it plays in use
@OneOf(VD_STORE, VD_MARINA) sold_at: int = VD_MARINA
} }
@AppendOnly @AppendOnly
registry Tools of Tool as TL from "data/attr_tools.lres" registry Tools of Tool as TL from "data/attr_tools.lres"
# a tool hung on the camp's rack: which, and from which node of ITS model
property RackSlot {
@Ref(Tools) tool: int = 0
@Node(tool) hook: string = "grip"
}
const CAMP_TOOLS: int = 2 * VD_COUNT # how many a camp can carry const CAMP_TOOLS: int = 2 * VD_COUNT # how many a camp can carry
# the tool a new camp starts with # the tool a new camp starts with
function first_tool() -> string { return Tools[0].name } function first_tool() -> string { return Tools[0].name }

View file

@ -0,0 +1,21 @@
# attributes: @Node / @Clip name a field of its record that names a glTF - `handle` names no field,
# `tag` is a plain string, `kit` refers to a registry whose record has two models - and a listing
# @OneOf names constants that exist, which KITS_NONE is not (all five are reported)
program R {
property Kit {
key: string = ""
@Asset("gltf") body: string = ""
@Asset("gltf") lod: string = ""
}
registry Kits of Kit
property Tool {
tag: string = ""
@Ref(Kits) kit: int = 0
@Node(handle) grip: string = ""
@Node(tag) tip: string = ""
@Node(kit) hook: string = ""
@Clip(tag) swing: string = ""
@OneOf(KITS_COUNT, KITS_NONE) which: int = 0
}
entry { print(KITS_COUNT) }
}

View file

@ -3,15 +3,23 @@
# #
# property Item { # property Item {
# @Ref(Vendors) seller: int = 0 # an index into that registry (its entries are offered) # @Ref(Vendors) seller: int = 0 # an index into that registry (its entries are offered)
# @OneOf(GR_) grade: int = 0 # one of the constants that start GR_ # @OneOf(GR_) grade: int = 0 # one of the constants that start GR_ ...
# @OneOf(WILD_MEADOW, WILD_SHORE) hab: int = 0 # ... or one of these (each must exist)
# @Range(0, 1.5) weight: float = 0.4 # @Range(0, 1.5) weight: float = 0.4
# @Unit("m/s") speed: float = 0.0 # @Unit("m/s") speed: float = 0.0
# @Asset("gltf") model: string = "" # a file of that kind # @Asset("gltf") model: string = "" # a file of that kind
# @Color tint: int = 0 # @Color tint: int = 0
# @Node(model) grip: string = "" # a node inside the glTF the field `model` names
# @Clip(model) idle: string = "" # a clip inside that glTF
# @Derived reach: float = 0.0 # worked out at boot: anything written is overwritten
# @Text @Multiline blurb: string = "" # read by the player, so translated; prose
# @Key bind: int = 0 # a key code
# } # }
# @AppendOnly @ByKey registry Items of Item as IT from "items.lres" # @AppendOnly @ByKey registry Items of Item as IT from "items.lres"
# #
# They change nothing the program does. One is checked: @Ref must name a registry that exists. A # They change nothing the program does. Three are checked: @Ref must name a registry that exists,
# @Node and @Clip a field of its record that names a glTF (attrs_node.ludic), and a listing @OneOf
# constants that exist. A
# field's or a registry's attributes are a group (Node.at, g_rg_at): a run of names, each with its # field's or a registry's attributes are a group (Node.at, g_rg_at): a run of names, each with its
# arguments as JSON values - an identifier or a string as a string, a number as written. # arguments as JSON values - an identifier or a string as a string, a number as written.
var g_at_start: []int = new []int # a group's first attribute ... var g_at_start: []int = new []int # a group's first attribute ...
@ -42,10 +50,12 @@ function at_push(g: int, name: pointer, args: pointer, first: pointer, nargs: in
var g_at_arg0: pointer = null var g_at_arg0: pointer = null
var g_at_argn: int = 0 var g_at_argn: int = 0
var g_at_allnum: bool = true # every argument a number var g_at_allnum: bool = true # every argument a number
var g_at_allid: bool = true # every argument a name
function at_args() -> pointer { function at_args() -> pointer {
g_at_arg0 = null g_at_arg0 = null
g_at_argn = 0 g_at_argn = 0
g_at_allnum = true g_at_allnum = true
g_at_allid = true
if not is_op("(") { return "[]" } if not is_op("(") { return "[]" }
pi += 1 pi += 1
var out: pointer = "[" var out: pointer = "["
@ -54,6 +64,7 @@ function at_args() -> pointer {
var neg = false var neg = false
if is_op("-") { neg = true; pi += 1 } if is_op("-") { neg = true; pi += 1 }
let t = toks[pi] let t = toks[pi]
if t.kind != TK_ID { g_at_allid = false }
var v: pointer = null var v: pointer = null
var raw: pointer = null var raw: pointer = null
if t.kind == TK_INT { if t.kind == TK_INT {
@ -92,19 +103,25 @@ function at_lead0(raw: pointer) -> pointer {
} }
# the editor attributes a field may carry, and what each takes # the editor attributes a field may carry, and what each takes
function at_field_known(a: pointer) -> bool { function at_field_known(a: pointer) -> bool {
return (a == "Ref") or (a == "OneOf") or (a == "Range") or (a == "Unit") or (a == "Asset") or (a == "Color") if (a == "Ref") or (a == "OneOf") or (a == "Range") or (a == "Unit") or (a == "Asset") or (a == "Color") { return true }
return (a == "Node") or (a == "Clip") or at_flag(a)
}
# the attributes that take nothing: they say what a field is
function at_flag(a: pointer) -> bool {
return (a == "Color") or (a == "Derived") or (a == "Text") or (a == "Multiline") or (a == "Key")
} }
# one field attribute, its '@' and name already read: its arguments, checked for shape, into group g # one field attribute, its '@' and name already read: its arguments, checked for shape, into group g
function at_field(g: int, a: pointer) -> void { function at_field(g: int, a: pointer) -> void {
let args = at_args() let args = at_args()
let n = g_at_argn let n = g_at_argn
if (a == "Ref") or (a == "OneOf") { if (a == "Ref") or (a == "Node") or (a == "Clip") {
if n != 1 or toks[pi - 2].kind != TK_ID { perr(`@{a} takes one name: @{a}(Items)`) } if n != 1 or toks[pi - 2].kind != TK_ID { perr(`@{a} takes one name: @{a}(Items)`) }
} }
if (a == "OneOf") and (n < 1 or not g_at_allid) { perr("@OneOf takes a prefix or constants: @OneOf(GR_) or @OneOf(GR_GOLD, GR_SILVER)") }
if (a == "Range") and n != 2 { perr("@Range takes the least and the most: @Range(0, 1.5)") } if (a == "Range") and n != 2 { perr("@Range takes the least and the most: @Range(0, 1.5)") }
if (a == "Range") and not g_at_allnum { perr("@Range takes two numbers: @Range(0, 1.5)") } if (a == "Range") and not g_at_allnum { perr("@Range takes two numbers: @Range(0, 1.5)") }
if ((a == "Unit") or (a == "Asset")) and (n != 1 or toks[pi - 2].kind != TK_STR) { perr(`@{a} takes one string: @{a}("m/s")`) } if ((a == "Unit") or (a == "Asset")) and (n != 1 or toks[pi - 2].kind != TK_STR) { perr(`@{a} takes one string: @{a}("m/s")`) }
if (a == "Color") and n != 0 { perr("@Color takes nothing") } if at_flag(a) and n != 0 { perr(`@{a} takes nothing`) }
at_push(g, a, args, g_at_arg0, n) at_push(g, a, args, g_at_arg0, n)
} }
# a field's group, made the first time it needs one # a field's group, made the first time it needs one
@ -135,7 +152,9 @@ function at_registry_take() -> int {
g_at_decl = new []pointer g_at_decl = new []pointer
return g return g
} }
# once every registry is declared: each @Ref names one, or the build stops with every one that does not # once every registry is declared: each @Ref names one, each @Node / @Clip a glTF field and a
# listing @OneOf constants that exist, or the build
# stops with every one that does not
function at_check_refs() -> void { function at_check_refs() -> void {
var i = 0 var i = 0
while i < len(prog) { while i < len(prog) {
@ -159,6 +178,8 @@ function at_check_field(d: Node, f: Node) -> void {
if (g_at_name[j] == "Ref") and reg_find(g_at_first[j]) < 0 { if (g_at_name[j] == "Ref") and reg_find(g_at_first[j]) < 0 {
perr_at(f.file, f.line, f.col, `field {d.s}.{f.s}: @Ref({g_at_first[j]}) names no registry - there is no registry {g_at_first[j]}`) perr_at(f.file, f.line, f.col, `field {d.s}.{f.s}: @Ref({g_at_first[j]}) names no registry - there is no registry {g_at_first[j]}`)
} }
if (g_at_name[j] == "Node") or (g_at_name[j] == "Clip") { at_check_node(d, f, g_at_name[j], g_at_first[j]) }
if g_at_name[j] == "OneOf" { at_check_oneof(d, f, j) }
j += 1 j += 1
} }
} }

View file

@ -0,0 +1,107 @@
# attrs_node.ludic — @Node(model) / @Clip(model): a string naming a node or a clip inside the glTF
# another field of the same record names. That field is @Asset("gltf") itself, or an @Ref(R) whose record has exactly one
# @Asset("gltf") field (the model is found through the row). Anything else is an error naming it.
# the field of record d called name, or null
function at_field_of(d: Node, name: pointer) -> Node {
var k = 0
while k < len(d.kids) {
if d.kids[k].s != null and vis_plain(d.kids[k].s) == name { return d.kids[k] }
k += 1
}
return null
}
# field f's argument to attribute a as written, or null when f does not carry it
function at_arg_of(f: Node, a: pointer) -> pointer {
if f.at < 0 { return null }
var j = g_at_start[f.at]
while j < g_at_start[f.at] + g_at_count[f.at] {
if g_at_name[j] == a { return g_at_first[j] }
j += 1
}
return null
}
function at_is_gltf(f: Node) -> bool {
let a = at_arg_of(f, "Asset")
return a != null and a == "gltf"
}
# the record called name (a property or record), or null
function at_record(name: pointer) -> Node {
let plain = vis_plain(name)
var i = 0
while i < len(prog) {
if prog[i].kind == N_COMP and prog[i].s != null and vis_plain(prog[i].s) == plain { return prog[i] }
i += 1
}
return null
}
# how many @Asset("gltf") fields record r carries
function at_gltf_count(r: Node) -> int {
var n = 0
var k = 0
while k < len(r.kids) {
if at_is_gltf(r.kids[k]) { n += 1 }
k += 1
}
return n
}
function at_check_node(d: Node, f: Node, a: pointer, target: pointer) -> void {
let where = `field {d.s}.{f.s}: @{a}({target})`
let t = at_field_of(d, target)
if t == null {
perr_at(f.file, f.line, f.col, `{where} names no field - {d.s} has no field {target}`)
return
}
if at_is_gltf(t) { return }
let reg = at_arg_of(t, "Ref")
if reg == null {
perr_at(f.file, f.line, f.col, `{where}: {target} is neither @Asset("gltf") nor an @Ref to a registry of models`)
return
}
let r = reg_find(reg)
if r < 0 { return } # @Ref's own check says so
let rec = at_record(g_rg_type[r])
var n = 0
if rec != null { n = at_gltf_count(rec) }
if n != 1 {
perr_at(f.file, f.line, f.col, `{where}: {target} is an @Ref({reg}), and its record {g_rg_type[r]} has {n} @Asset("gltf") fields, not one`)
}
}
# @OneOf: one argument ending in _ is a prefix; otherwise every argument is a constant that must exist
function at_check_oneof(d: Node, f: Node, j: int) -> void {
let first = g_at_first[j]
if g_at_nargs[j] == 1 and first[len(first) - 1] == '_' { return }
let names = at_arg_names(g_at_args[j])
var i = 0
while i < len(names) {
if not at_const_exists(names[i]) {
perr_at(f.file, f.line, f.col, `field {d.s}.{f.s}: @OneOf names {names[i]}, and there is no constant {names[i]}`)
}
i += 1
}
}
# the names in an attribute's JSON arguments (["A", "B"]): each a quoted identifier
function at_arg_names(args: pointer) -> []pointer {
let out = new []pointer
var i = 0
var at = -1
while i < len(args) {
if args[i] == '"' {
if at < 0 { at = i + 1 } else {
push(out, args[at .. i])
at = -1
}
}
i += 1
}
return out
}
function at_const_exists(name: pointer) -> bool {
var i = 0
while i < len(prog) {
if prog[i].kind == N_CONST and prog[i].s != null and vis_plain(prog[i].s) == name { return true }
i += 1
}
return false
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@ function selfhost_frags() -> []pointer {
push(f, "selfhost/frontend/registry_finish.ludic") push(f, "selfhost/frontend/registry_finish.ludic")
push(f, "selfhost/frontend/registry_open.ludic") push(f, "selfhost/frontend/registry_open.ludic")
push(f, "selfhost/frontend/attrs.ludic") push(f, "selfhost/frontend/attrs.ludic")
push(f, "selfhost/frontend/attrs_node.ludic")
push(f, "selfhost/frontend/resource.ludic") push(f, "selfhost/frontend/resource.ludic")
push(f, "selfhost/frontend/defaults.ludic") push(f, "selfhost/frontend/defaults.ludic")
push(f, "selfhost/frontend/view.ludic") push(f, "selfhost/frontend/view.ludic")

View file

@ -1200,6 +1200,7 @@ function cmd_dev_test() -> int {
reject_count("rejected/every_error", 3, "every type error in a program is reported at once, not only the first") reject_count("rejected/every_error", 3, "every type error in a program is reported at once, not only the first")
diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}") diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}")
diag_json_case("rejected/ref_unknown", 2, "@Ref(Shops) names no registry", "--diagnostics=json: every @Ref that names no registry") diag_json_case("rejected/ref_unknown", 2, "@Ref(Shops) names no registry", "--diagnostics=json: every @Ref that names no registry")
diag_json_case("rejected/node_bad", 5, "@OneOf names KITS_NONE, and there is no constant KITS_NONE", "--diagnostics=json: every @Node / @Clip that names no model field, and every @OneOf constant that does not exist")
feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)") feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)")
reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused") reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused")
reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types") reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types")
@ -1232,9 +1233,11 @@ function cmd_dev_test() -> int {
reject_case("rejected/registry_unknown", "there is no registry Tables", "a def names a registry that exists") reject_case("rejected/registry_unknown", "there is no registry Tables", "a def names a registry that exists")
feat_case("lang/attributes", "", "2 1 Lantern 2", "attributes.ludic (editor attributes: @Ref, @OneOf, @Range, @Unit, @Asset, @Color, @AppendOnly, @ByKey - no effect on the program)") feat_case("lang/attributes", "", "2 1 Lantern 2", "attributes.ludic (editor attributes: @Ref, @OneOf, @Range, @Unit, @Asset, @Color, @AppendOnly, @ByKey - no effect on the program)")
reject_case("rejected/ref_unknown", "field Tool.seller: @Ref(Vendorz) names no registry", "@Ref names a registry that exists") reject_case("rejected/ref_unknown", "field Tool.seller: @Ref(Vendorz) names no registry", "@Ref names a registry that exists")
reject_case("rejected/node_bad", "field Tool.grip: @Node(handle) names no field", "@Node names a field of its record")
schema_case("lang/attributes", "{\"name\": \"weight\", \"type\": \"float\", \"default\": \"1.0\", \"file\": \"examples/lang/attributes.ludic\", \"line\": 24, \"col\": 33, \"attributes\": [{\"name\": \"Range\", \"args\": [0, 20.5]}, {\"name\": \"Unit\", \"args\": [\"kg\"]}]", "--emit-schema: a field's type, default, place and attributes") schema_case("lang/attributes", "{\"name\": \"weight\", \"type\": \"float\", \"default\": \"1.0\", \"file\": \"examples/lang/attributes.ludic\", \"line\": 24, \"col\": 33, \"attributes\": [{\"name\": \"Range\", \"args\": [0, 20.5]}, {\"name\": \"Unit\", \"args\": [\"kg\"]}]", "--emit-schema: a field's type, default, place and attributes")
schema_case("lang/attributes", "{\"key\": \"lantern\", \"constant\": \"TL_LANTERN\", \"index\": 1, \"file\": \"examples/lang/data/attr_tools.lres\", \"line\": 3, \"col\": 1", "--emit-schema: a resource entry's constant, index and place") schema_case("lang/attributes", "{\"key\": \"lantern\", \"constant\": \"TL_LANTERN\", \"index\": 1, \"file\": \"examples/lang/data/attr_tools.lres\", \"line\": 3, \"col\": 1", "--emit-schema: a resource entry's constant, index and place")
schema_case("lang/attributes", "\"fn_type\": \"fn(Tool,int)->float\", \"params\": [\"Tool\", \"int\"]", "--emit-schema: a function with arguments, its fn_type spelled as a field's") schema_case("lang/attributes", "\"fn_type\": \"fn(Tool,int)->float\", \"params\": [\"Tool\", \"int\"]", "--emit-schema: a function with arguments, its fn_type spelled as a field's")
schema_case("lang/attributes", "{\"name\": \"Node\", \"args\": [\"tool\"]}", "--emit-schema: @Node and the field it reads its model from")
reject_case("rejected/registry_twice", "def Furnishings crate is declared twice", "a registry's key is declared once") reject_case("rejected/registry_twice", "def Furnishings crate is declared twice", "a registry's key is declared once")
reject_case("rejected/registry_field", "Furnishing has no field colour", "a def's fields are the record's") reject_case("rejected/registry_field", "Furnishing has no field colour", "a def's fields are the record's")
feat_case("modules/open_registry", "", "clock animals weather / 2 2 3", "open_registry.ludic (L8+L3: an open registry takes other modules' defs; its own entries first, then by module name)") feat_case("modules/open_registry", "", "clock animals weather / 2 2 3", "open_registry.ludic (L8+L3: an open registry takes other modules' defs; its own entries first, then by module name)")