attributes: @Material(field) resolved as @Node is; @OneOf on a string field takes words, and every registry row's value is checked against them - words on another field, or constants on a string, is an error; reseeded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 19:13:32 +03:00
parent d82dc3162f
commit 81cb992fad
10 changed files with 75030 additions and 73286 deletions

View file

@ -642,6 +642,8 @@ property Tool {
@Color tint: int = 0 @Color tint: int = 0
@Node(model) grip: string = "" # a node inside the glTF that `model` names @Node(model) grip: string = "" # a node inside the glTF that `model` names
@Clip(model) swing: string = "" # a clip inside it @Clip(model) swing: string = "" # a clip inside it
@Material(model) finish: string = "" # a material inside it
@OneOf("box", "hull") shape: string = "" # a string field: one of these words
@Color @Tint(TSLOT_SHELL) shell: int = 0 # a colour for that tint slot @Color @Tint(TSLOT_SHELL) shell: int = 0 # a colour for that tint slot
@Derived reach: float = 0.0 # worked out at boot: anything written is overwritten @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 @Text @Multiline blurb: string = "" # read by the player (so translated), and prose
@ -651,12 +653,15 @@ property Tool {
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. Four are checked, and every field that fails is reported, not They change nothing the program does. A target that no part of the program declares - the registry
only the first: `@Ref` must name a registry the program declares; `@OneOf` with one argument ending an `@Ref` names, the constant of an `@Tint` or a listed `@OneOf` - is a warning, and the schema marks
in `_` is a prefix, and otherwise each argument is a constant that must exist; `@Tint(C)` must name a the attribute `"unresolved": true`: a package can name the game's registry without importing it.
constant that exists (a row of the program's tint-slot registry); and `@Node(f)` or `@Clip(f)` must Everything else is an error, every one reported: a target that exists but is another kind
name a field `f` of the same record that is either `@Asset("gltf")` or an `@Ref` to a registry whose record has (`@Ref(Vendor)` on a record); `@OneOf` of the wrong kind for its field - on a string field the
exactly one `@Asset("gltf")` field (the model is then the referenced row's). arguments are words (`@OneOf("box", "hull")`) and every registry row's value must be one of them, on
any other field a prefix ending in `_` or constants; and `@Node(f)`, `@Clip(f)` or `@Material(f)`
naming anything but a field `f` of the same record that is `@Asset("gltf")`, or an `@Ref` to a
registry whose record has exactly one `@Asset("gltf")` field (the model is then the 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,7 @@ 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`, `@Node(field)`, `@Clip(field)`, `@Tint(SLOT)`, `@Derived`, `@Text`, `@Multiline`, `@Key`, and `@AppendOnly` / `@ByKey` on `@Color`, `@Node(field)`, `@Clip(field)`, `@Material(field)`, `@Tint(SLOT)`, `@Derived`, `@Text`, `@Multiline`, `@Key`, and `@AppendOnly` / `@ByKey` on
a registry - which change nothing but go into the schema; `@Ref` naming no registry is an error, and 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 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, and `@Tint` naming no constant. A field may now carry several attributes. `ludicc --check a listing `@OneOf` (`@OneOf(A, B)`, not a prefix `@OneOf(P_)`) naming a constant that does not exist, and `@Tint` naming no constant. A field may now carry several attributes. `ludicc --check
@ -17,3 +17,8 @@ of `{file, line, col, severity, message}` on stdout; tokens and nodes now know t
`Build.schema_hash()` answers FNV-1a 64 of the program's own schema (the bytes `ludic schema` prints), `Build.schema_hash()` answers FNV-1a 64 of the program's own schema (the bytes `ludic schema` prints),
computed only when a program names it, and 0 under `ludicc --release`, which `ludic bundle` now passes. computed only when a program names it, and 0 under `ludicc --release`, which `ludic bundle` now passes.
A target no part of the program declares (an `@Ref` registry, an `@Tint` or listed `@OneOf` constant) is
a warning and `"unresolved": true` in the schema, so a package can name the game's registry; a name of
another kind is an error. `@OneOf` on a string field takes words, and every registry row's value is
checked against them.

View file

@ -32,6 +32,8 @@ program Attributes {
@Key bind: int = 0 # the key that takes it in hand @Key bind: int = 0 # the key that takes it in hand
@Clip(model) swing: string = "swing" # the clip it plays in use @Clip(model) swing: string = "swing" # the clip it plays in use
@OneOf(VD_STORE, VD_MARINA) sold_at: int = VD_MARINA @OneOf(VD_STORE, VD_MARINA) sold_at: int = VD_MARINA
@Material(model) finish: string = "steel" # a material in that model
@OneOf("box", "hull") shape: string = "box" # what it collides as: one of these words, row by row
} }
@AppendOnly @AppendOnly
registry Tools of Tool as TL from "data/attr_tools.lres" registry Tools of Tool as TL from "data/attr_tools.lres"

View file

@ -5,4 +5,5 @@ lantern {
seller: VD_MARINA seller: VD_MARINA
weight: 0.75 weight: 0.75
stocked_at: VD_MARINA stocked_at: VD_MARINA
shape: "hull"
} }

View file

@ -1,6 +1,8 @@
# attributes: @Node / @Clip name a field of their record that names a glTF - `handle` names no field, # attributes: @Node / @Clip name a field of their record that names a glTF - `handle` names no field,
# `tag` is a plain string, and `kit` refers to a registry whose record has two models: four errors. # `tag` is a plain string, and `kit` refers to a registry whose record has two models.
# @OneOf's KITS_NONE and @Tint's TSLOT_NONE are in no part of the program: two warnings. # @Material(tag) is the same fault; words on an int, constants on a string, and a row whose shape is
# no word of its @OneOf are the rest: eight errors. @OneOf's KITS_NONE and @Tint's TSLOT_NONE are in
# no part of the program: two warnings.
program R { program R {
property Kit { property Kit {
key: string = "" key: string = ""
@ -17,6 +19,12 @@ program R {
@Clip(tag) swing: string = "" @Clip(tag) swing: string = ""
@OneOf(KITS_COUNT, KITS_NONE) which: int = 0 @OneOf(KITS_COUNT, KITS_NONE) which: int = 0
@Tint(TSLOT_NONE) shade: int = 0 @Tint(TSLOT_NONE) shade: int = 0
@Material(tag) paint: string = ""
@OneOf("a", "b") size: int = 0
@OneOf(KITS_COUNT) form: string = ""
@OneOf("box", "hull") shape: string = "box"
} }
registry Tools of Tool
def Tools crate { shape: "cube" }
entry { print(KITS_COUNT) } entry { print(KITS_COUNT) }
} }

View file

@ -11,6 +11,8 @@
# @Color tint: int = 0 # @Color tint: int = 0
# @Node(model) grip: string = "" # a node inside the glTF the field `model` names # @Node(model) grip: string = "" # a node inside the glTF the field `model` names
# @Clip(model) idle: string = "" # a clip inside that glTF # @Clip(model) idle: string = "" # a clip inside that glTF
# @Material(model) skin: string = "" # a material inside it
# @OneOf("box", "hull") shape: string = "" # a string field: one of these words
# @Color @Tint(TSLOT_HAIR) hair: int = 0 # a colour for that tint slot (a constant that exists) # @Color @Tint(TSLOT_HAIR) hair: int = 0 # a colour for that tint slot (a constant that exists)
# @Derived reach: float = 0.0 # worked out at boot: anything written is overwritten # @Derived reach: float = 0.0 # worked out at boot: anything written is overwritten
# @Text @Multiline blurb: string = "" # read by the player, so translated; prose # @Text @Multiline blurb: string = "" # read by the player, so translated; prose
@ -19,9 +21,10 @@
# @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. A target the program does not have at all is a warning and # They change nothing the program does. A target the program does not have at all is a warning and
# "unresolved" in the schema (a package names the game's registry it cannot import). Four are checked: @Ref must name a registry that exists, # "unresolved" in the schema (a package names the game's registry it cannot import). Checked: @Ref
# @Node and @Clip a field of its record that names a glTF (attrs_node.ludic), and a listing @OneOf # names a registry; @Node, @Clip and @Material a field of their record naming a glTF
# constants that exist, and @Tint a constant that exists (a slot's row in the game's registry). A # (attrs_node.ludic); a listing @OneOf constants (or, on a string field, words - and every row's value
# one of them); and @Tint a constant (a slot's row in the game's registry). 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 ...
@ -47,6 +50,8 @@ function at_push(g: int, name: pointer, args: pointer, first: pointer, nargs: in
push(g_at_first, first) push(g_at_first, first)
push(g_at_nargs, nargs) push(g_at_nargs, nargs)
push(g_at_unres, false) push(g_at_unres, false)
push(g_at_strs, g_at_strs_next)
g_at_strs_next = false
g_at_count[g] = g_at_count[g] + 1 g_at_count[g] = g_at_count[g] + 1
} }
# `(a, "b", -1.5)` after an attribute's name, if it is there, as a JSON array; the first argument # `(a, "b", -1.5)` after an attribute's name, if it is there, as a JSON array; the first argument
@ -55,11 +60,15 @@ 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 var g_at_allid: bool = true # every argument a name
var g_at_allstr: bool = true # every argument a string
var g_at_strs: []bool = new []bool # per attribute: its arguments were all strings
var g_at_strs_next: bool = false # what at_push files for the attribute it pushes
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 g_at_allid = true
g_at_allstr = true
if not is_op("(") { return "[]" } if not is_op("(") { return "[]" }
pi += 1 pi += 1
var out: pointer = "[" var out: pointer = "["
@ -69,6 +78,7 @@ function at_args() -> pointer {
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 } if t.kind != TK_ID { g_at_allid = false }
if t.kind != TK_STR { g_at_allstr = 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 {
@ -108,7 +118,7 @@ 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 {
if (a == "Ref") or (a == "OneOf") or (a == "Range") or (a == "Unit") or (a == "Asset") or (a == "Color") { return true } 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 (a == "Tint") or at_flag(a) return (a == "Node") or (a == "Clip") or (a == "Material") or (a == "Tint") or at_flag(a)
} }
# the attributes that take nothing: they say what a field is # the attributes that take nothing: they say what a field is
function at_flag(a: pointer) -> bool { function at_flag(a: pointer) -> bool {
@ -118,10 +128,11 @@ function at_flag(a: pointer) -> bool {
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 == "Node") or (a == "Clip") or (a == "Tint") { if (a == "Ref") or (a == "Node") or (a == "Clip") or (a == "Material") or (a == "Tint") {
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 == "OneOf") and (n < 1 or not (g_at_allid or g_at_allstr)) { perr("@OneOf takes a prefix, constants or words: @OneOf(GR_), @OneOf(GR_GOLD, GR_SILVER) or @OneOf(\"box\", \"hull\")") }
g_at_strs_next = (a == "OneOf") and g_at_allstr
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")`) }
@ -180,7 +191,7 @@ function at_check_field(d: Node, f: Node) -> void {
var j = g_at_start[g] var j = g_at_start[g]
while j < g_at_start[g] + g_at_count[g] { while j < g_at_start[g] + g_at_count[g] {
if (g_at_name[j] == "Ref") and reg_find(g_at_first[j]) < 0 { at_target(d, f, j, "a registry") } if (g_at_name[j] == "Ref") and reg_find(g_at_first[j]) < 0 { at_target(d, f, j, "a registry") }
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] == "Node") or (g_at_name[j] == "Clip") or (g_at_name[j] == "Material") { 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) } if g_at_name[j] == "OneOf" { at_check_oneof(d, f, j) }
if (g_at_name[j] == "Tint") and not at_const_exists(g_at_first[j]) { at_const_target(d, f, j, g_at_first[j]) } if (g_at_name[j] == "Tint") and not at_const_exists(g_at_first[j]) { at_const_target(d, f, j, g_at_first[j]) }
j += 1 j += 1

View file

@ -1,4 +1,5 @@
# attrs_node.ludic — @Node(model) / @Clip(model): a string naming a node or a clip inside the glTF # attrs_node.ludic — @Node(model) / @Clip(model) / @Material(model): a string naming a node, a clip or a
# material 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 # 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. # @Asset("gltf") field (the model is found through the row). Anything else is an error naming it.
@ -68,17 +69,71 @@ function at_check_node(d: Node, f: Node, a: pointer, target: pointer) -> void {
} }
} }
# @OneOf: one argument ending in _ is a prefix; otherwise every argument is a constant that must exist # @OneOf: on a string field, words, and every row's value one of them; otherwise one argument ending
# in _ is a prefix, or every argument a constant that must exist
function at_check_oneof(d: Node, f: Node, j: int) -> void { function at_check_oneof(d: Node, f: Node, j: int) -> void {
let where = `field {d.s}.{f.s}: @OneOf`
let words = g_at_strs[j]
let is_text = f.ty != null and f.ty == "string"
if words and not is_text {
perr_at(f.file, f.line, f.col, `{where} lists words, and {f.s} is not a string - name constants instead`)
return
}
if is_text and not words {
perr_at(f.file, f.line, f.col, `{where} names constants, and {f.s} is a string - list the words it may hold: @OneOf("a", "b")`)
return
}
let names = at_arg_names(g_at_args[j])
if words {
at_check_rows(d, f, names)
return
}
let first = g_at_first[j] let first = g_at_first[j]
if g_at_nargs[j] == 1 and first[len(first) - 1] == '_' { return } if g_at_nargs[j] == 1 and first[len(first) - 1] == '_' { return }
let names = at_arg_names(g_at_args[j])
var i = 0 var i = 0
while i < len(names) { while i < len(names) {
if not at_const_exists(names[i]) { at_const_target(d, f, j, names[i]) } if not at_const_exists(names[i]) { at_const_target(d, f, j, names[i]) }
i += 1 i += 1
} }
} }
# every registry row of record d that writes field f as a string: one of the words
function at_check_rows(d: Node, f: Node, words: []pointer) -> void {
let plain = vis_plain(d.s)
var i = 0
while i < len(g_df_rec) {
let r = reg_find(g_df_reg[i])
if r >= 0 and vis_plain(g_rg_type[r]) == plain { at_check_row(d, f, g_df_rec[i], g_df_key[i], words) }
i += 1
}
}
function at_check_row(d: Node, f: Node, rec: Node, key: pointer, words: []pointer) -> void {
var k = 0
while k < len(rec.kids) {
let fi = rec.kids[k]
if fi.kind == E_FINIT and fi.s == f.s and fi.a != null and fi.a.kind == E_STR and not at_word_in(fi.a.s, words) {
perr_at(fi.file, fi.line, fi.col, `{key}.{f.s} is "{fi.a.s}", and field {d.s}.{f.s} is @OneOf({at_words_text(words)})`)
}
k += 1
}
}
function at_word_in(w: pointer, words: []pointer) -> bool {
var i = 0
while i < len(words) {
if words[i] == w { return true }
i += 1
}
return false
}
function at_words_text(words: []pointer) -> pointer {
var out: pointer = ""
var i = 0
while i < len(words) {
if i > 0 { out = out + ", " }
out = out + "\"" + words[i] + "\""
i += 1
}
return out
}
# the names in an attribute's JSON arguments (["A", "B"]): each a quoted identifier # the names in an attribute's JSON arguments (["A", "B"]): each a quoted identifier
function at_arg_names(args: pointer) -> []pointer { function at_arg_names(args: pointer) -> []pointer {
let out = new []pointer let out = new []pointer

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1200,7 +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): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error") diag_json_case("rejected/ref_unknown", 2, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error")
diag_json_case("rejected/node_bad", 6, "this program has no constant KITS_NONE - left unresolved", "--diagnostics=json: every @Node / @Clip that names no model field, and every @OneOf constant that does not exist") diag_json_case("rejected/node_bad", 10, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings")
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")