Phase 4b: enum types name magic-int value spaces

enum Name { A, B, C } declares named int constants; Enum.Variant is a
compile-time int (index by declaration order), usable in match patterns,
comparisons, setreg. New: parse_enum + dispatch, enum_ordinal resolver
(emit_core), Enum.Variant handling (emit_expr). Scoped to a naming layer over
int (no enum-typed variables), so register/save semantics are untouched.

combat.ludic battle menus dispatch on KnightAct/MageAct instead of 0..3;
chronorift golden byte-identical. Editor vocab (ludic_syntax.h, JetBrains,
TextMate, emacs) gained enum, dropped retired edge/export/pure decl keywords;
check-vocabulary + test-tools 28/0. when/if kept by choice; bitwise stays
functions. Reseeded, fixpoint holds, test.sh 14/14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 16:29:02 +03:00
parent fd0b890acc
commit f87964c5f7
14 changed files with 3021 additions and 2672 deletions

View file

@ -392,6 +392,26 @@ is still accepted when a state needs a specific value.) A `machine <reg>` reads
value>)`. Both lower to plain branches (and `match` runs on the native LLVM
backend too).
## Enums
`enum` names a set of related integer values so a magic-number space — a menu
selection, a mode, a machine state — reads as names instead of literals:
```ludic
# doc-check: skip — composite: a declaration plus its uses
enum Action { Attack, Guard, Item, Flee } # Attack = 0, Guard = 1, …
match reg(R_CUR) { Action.Attack => attack() Action.Guard => guard() _ => wait() }
if reg(R_MODE) == Mode.Battle { … }
```
A variant is a **compile-time `int`** accessed as `Enum.Variant` (`Action.Guard`
is `1`), numbered from `0` by declaration order, so it works anywhere an int does
— `match` patterns, comparisons, `setreg`. Enums are a naming layer over `int`:
there is no distinct enum runtime type yet, so an enum value lives in an ordinary
`int` or register (and is saved with it). See `examples/chronorift/combat.ludic`,
whose battle menus dispatch on `KnightAct`/`MageAct` instead of `0..3`.
## Expressions
Precedence: `or → and → compar(< <= > >= == !=) → + - → * / % → unary(- not) →

View file

@ -48,9 +48,9 @@ Verified against the self-hosted compiler ([selfhost/parse.ludic](selfhost/parse
11. Operators are words (`and`/`or`/`not`), symbols (`==`/`<=`), *and* functions
(`band`/`shl`) at once.
12. Three overlapping control families — `if`/`when`, `match`, `machine`/`become`
— and `enter` reuses `become`'s AST node ([parse.ludic:176-177](selfhost/parse.ludic)).
— and `enter` reuses `become`'s AST node ([parse.ludic:176-177](selfhost/parse.ludic)). *(Phase 4: `if`/`when` kept by choice; magic-int dispatch resolved)*
13. Typed components/structs exist, but real state lives in 64 untyped int
registers (`reg`/`setreg`), so `machine`/`match` dispatch on magic numbers.
registers (`reg`/`setreg`), so `machine`/`match` dispatch on magic numbers. *(✅ Phase 4: auto-numbered states + `enum` name the values)*
---
@ -285,13 +285,36 @@ migration (IR byte-identical / goldens identical) → reseed → docs. The migra
tools ([migrate_separators.c](tools/ludic-tools/migrate_separators.c),
[migrate_records.c](tools/ludic-tools/migrate_records.c)) are the reusable spine.
### Phase 4 — Control-flow & operator consolidation (largest)
- Remove `when`; confirm `if` covers all uses in the corpus.
- Introduce `enum` + typed `machine`/state; migrate `combat.ludic`'s R_PHASE
machine and the register-driven `match reg(…)` sites.
- Decide the bitwise story: keep `band/shl/…` as functions (document as a
deliberate "one spelling" choice) or promote to operators — pick one and state
it, don't leave it implicit.
### Phase 4 — Control-flow & state consolidation
**4a — machine states auto-number ✅ DONE.** `state KnightMenu = 0 { }` →
`state KnightMenu { }`; a state's value is its declaration index (an explicit
`= expr` still works). Removes the magic constants from state machines
([parse.ludic](selfhost/parse.ludic)). combat.ludic migrated; chronorift golden
byte-identical.
**4b — `enum` types ✅ DONE.** `enum Action { Attack, Guard, Item, Flee }` declares
named `int` constants; a variant is a compile-time int accessed as `Action.Guard`
(= 1), numbered by order. Parser `parse_enum` + dispatch, `enum_ordinal` resolver
in [emit_core.ludic](selfhost/emit_core.ludic), and `Enum.Variant` handling in
[emit_expr.ludic](selfhost/emit_expr.ludic). combat.ludic's battle menus now
dispatch on `KnightAct`/`MageAct` instead of `0..3`; chronorift golden
byte-identical. Editor vocab (`ludic_syntax.h`, JetBrains, TextMate, emacs) gained
`enum` and lost the retired `edge`/`export`/`pure` decl keywords; check-vocabulary
+ test-tools green. **Scoped:** enums are a naming layer over `int` (no distinct
runtime type / enum-typed variables yet) — that keeps register/save semantics
untouched, which the "enum var replaces the register" vision would have to solve.
**`when` vs `if` — kept both (decision).** `when` stays as the `if`-without-else
spelling: it is not incoherent so much as a readability signal ("no else here"),
it is documented and highlighted, and it is a pure alias with no semantic overlap
to untangle. The real target of finding #12 — dispatch on magic integers — is
addressed by 4a/4b, not by collapsing `if`/`when`.
**Bitwise operators — kept as functions (decision).** `band`/`bor`/`bxor`/`bnot`/
`shl`/`shr` stay functions, documented as the deliberate "one spelling, symbols
stay free" choice (LANGUAGE.md §Expressions already states this). Promoting them
to operators would re-introduce the symbol soup the current design avoids.
### Phase 5 — Single source of truth for keywords/grammar
- Generate every editor plugin keyword list, `ludic_syntax.h`, and the LSP's

View file

@ -3,6 +3,11 @@
# The battle is a state machine over R_PHASE. `machine` dispatches on the
# register and `become` transitions to a named state — no more `if ph == N`
# ladders. Knight turn (0,1), Mage turn (2,3), Enemy turn (4).
# Menu selections, named instead of matched on bare integers (R_CUR holds one).
enum KnightAct { Attack, Guard, Item, Flee }
enum MageAct { Attack, Heal, Guard }
system Battle phase Update {
if reg(R_MODE) == 1 {
let k = key()
@ -20,20 +25,20 @@ system Battle phase Update {
if k == 's' { setreg(R_CUR, min(3, reg(R_CUR) + 1)) }
if is_confirm(k) {
match reg(R_CUR) {
0 => {
KnightAct.Attack => {
let d = max(1, st.atk - e.def + rng_range(0, 4))
e.hp = e.hp - d
setreg(R_PDMG, d)
}
1 => { st.guard = 1; setreg(R_PDMG, 0) }
2 => {
KnightAct.Guard => { st.guard = 1; setreg(R_PDMG, 0) }
KnightAct.Item => {
if reg(R_POTION) > 0 {
setreg(R_POTION, reg(R_POTION) - 1)
st.hp = min(st.maxhp, st.hp + 24)
setreg(R_PDMG, 24)
} else { setreg(R_PDMG, 0) }
}
3 => {
KnightAct.Flee => {
if rng_chance(50) { e.hp = 0; setreg(R_MODE, 0) }
else { setreg(R_PDMG, 0) }
}
@ -71,12 +76,12 @@ system Battle phase Update {
if k == 'k' { setreg(R_CUR, min(2, reg(R_CUR) + 1)) }
if k == 'j' {
match reg(R_CUR) {
0 => {
MageAct.Attack => {
let d = max(1, st.atk + 5 - e.def + rng_range(0, 5))
e.hp = e.hp - d
setreg(R_PDMG, d)
}
1 => {
MageAct.Heal => {
if st.mp >= 4 {
st.mp = st.mp - 4
for (t2, p2) in query [Stats, Party] {
@ -85,7 +90,7 @@ system Battle phase Update {
setreg(R_PDMG, 16)
} else { setreg(R_PDMG, 0) }
}
2 => { st.guard = 1; setreg(R_PDMG, 0) }
MageAct.Guard => { st.guard = 1; setreg(R_PDMG, 0) }
}
become MageResolve
setreg(R_CUR, 0)

View file

@ -15,6 +15,7 @@ const N_SYS: int = 9
const N_ARCH: int = 10
const N_EXTERN: int = 11
const N_UI: int = 12
const N_ENUM: int = 27 # enum Name { A, B, ... } — named int constants
# statements
const S_LET: int = 10
const S_ASSIGN: int = 11

View file

@ -119,6 +119,21 @@ fn find_global(name: ptr) -> Node {
}
return ptr_null()
}
# `Enum.Variant` -> the variant's ordinal (its index), or -1 if `ename` names no
# enum with that variant. Enum names live in `prog` like any other declaration.
fn enum_ordinal(ename: ptr, vname: ptr) -> int {
let i = 0
while i < len(prog) {
let d = prog[i]
if d.kind == N_ENUM and streq(d.s, ename) {
let j = 0
while j < len(d.kids) { if streq(d.kids[j].s, vname) { return j }; j = j + 1 }
}
i = i + 1
}
return 0 - 1
}
fn find_fn(name: ptr) -> Node {
let i = 0
while i < len(prog) { let d = prog[i]; if d.kind == N_FN and streq(d.s, name) { return d }; i = i + 1 }

View file

@ -152,7 +152,13 @@ fn emit_expr(e: Node) -> Val {
if is_ui_ident(e.s) { return val(itoa(ui_index_of(e.s)), "int") }
perr(sconcat("unknown identifier ", e.s))
}
if e.kind == E_MEMBER { let a = emit_member_addr(e); return emit_load_at(a, g_addr_ty) }
if e.kind == E_MEMBER {
if e.a.kind == E_ID { # `Enum.Variant` -> its ordinal, a compile-time int
let ord = enum_ordinal(e.a.s, e.s)
if ord >= 0 { return val(itoa(ord), "int") }
}
let a = emit_member_addr(e); return emit_load_at(a, g_addr_ty)
}
if e.kind == E_INDEX { let a = emit_index_addr(e); return emit_load_at(a, g_addr_ty) }
if e.kind == E_CALL { return emit_call(e) }
if e.kind == E_BIN { return emit_bin(e) }

File diff suppressed because it is too large Load diff

View file

@ -296,6 +296,7 @@ fn parse_one_decl() -> void {
return
}
if is_id("struct") { push(prog, parse_struct()); return }
if is_id("enum") { push(prog, parse_enum()); return }
if is_id("component") { push(prog, parse_component()); return }
if is_id("archetype") { push(prog, parse_archetype()); return }
if is_id("system") { push(prog, parse_system()); return }

View file

@ -96,6 +96,17 @@ fn parse_spawn() -> Node {
eat_op("}"); return n
}
# enum Name { A, B, C } — named int constants; a variant's value is its index.
# Accessed as `Name.A` (a compile-time int), so it names magic-int value spaces
# (state ids, menu selections, mode registers) without a runtime cost.
fn parse_enum() -> Node {
pi = pi + 1; let n = node(N_ENUM); n.s = eat_id(); skipnl(); eat_op("{")
while true { skipnl(); if is_op("}") { break }
let v = node(E_ID); v.s = eat_id(); push(n.kids, v)
if is_op(",") { pi = pi + 1 }; skipnl() }
eat_op("}"); return n
}
# archetype Name { CompA, CompB } — a named entity kind (bundle of components)
fn parse_archetype() -> Node {
pi = pi + 1; let n = node(N_ARCH); n.s = eat_id(); skipnl(); eat_op("{")

View file

@ -11,8 +11,8 @@
;;; Code:
(defconst ludic--declaration-keywords
'("game" "module" "import" "component" "archetype" "ui" "scene" "layer"
"const" "var" "export" "fn" "pure" "extern" "system" "edge" "start" "on"))
'("game" "module" "import" "component" "struct" "archetype" "enum" "ui" "scene" "layer"
"const" "var" "fn" "extern" "system" "start" "on"))
(defconst ludic--clause-keywords
'("phase" "query" "reads" "writes" "needs" "uses"

View file

@ -41,8 +41,8 @@ object LudicTokens {
*/
object LudicVocabulary {
val DECL = setOf(
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
"game", "module", "import", "component", "struct", "archetype", "enum", "ui", "scene", "layer",
"const", "var", "fn", "extern", "system", "start", "on"
)
val CLAUSE = setOf(
"phase", "query", "reads", "writes", "needs", "uses",

View file

@ -174,8 +174,8 @@
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern|start)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -174,8 +174,8 @@
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern|start)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|struct|archetype|enum|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -52,8 +52,8 @@ typedef struct {
* mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"game","module","import","component","struct","archetype","ui","scene","layer",
"const","var","export","fn","pure","extern","system","edge","start","on", 0
"game","module","import","component","struct","archetype","enum","ui","scene","layer",
"const","var","fn","extern","system","start","on", 0
};
static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes","needs","uses",