Networking N2–N6, and a fully C-free toolchain

Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.

Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
  compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
  entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
  extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().

C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
  programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
  parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.

Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-29 15:08:23 +03:00
parent 96d01e45ab
commit bca8f126fc
67 changed files with 24066 additions and 9309 deletions

135
test.sh
View file

@ -71,6 +71,141 @@ if ./selfhost/game-build.sh build/ludicc examples/strings.ludic "/tmp/ludic_str"
&& [ "$(/tmp/ludic_str </dev/null | tr '\n' ' ')" = "1 2 3 4 5 6 7 8 9 " ]; then
ok "strings.ludic (str ops, interpolation, slicing)"
else bad "strings: $(tail -1 /tmp/str.out)"; fi
# Scenes: one scene active at a time; only its layers' handlers run each phase
# (global handlers first, then the active scene's layers in declaration order).
# `become` runs the source scene's on-exit, switches, runs the target's on-enter.
# The active scene is snapshotted per phase, so the switch shows next phase (Play
# renders the frame it is entered). Trace: boot 1000, enter Title 1, Tick 101,
# Tick 102 -> become (exit 2, enter 3), Draw 900, Step 201, Draw 900, Step 202
# -> quit, Draw 900.
if ./selfhost/game-build.sh build/ludicc examples/scenes.ludic "/tmp/ludic_scn" >/tmp/scn.out 2>&1 \
&& [ "$(printf 'aaaaaaaaaa' | /tmp/ludic_scn | tr '\n' ' ')" = "1000 1 101 102 2 3 900 201 900 202 900 " ]; then
ok "scenes.ludic (scene/layer/on enter/on exit/become)"
else bad "scenes: $(tail -1 /tmp/scn.out)"; fi
# Structural attach/detach + @OnAttach/@OnDetach: attach seeds a property on a
# live entity and fires @OnAttach (5+10=15), one Shield matches (1), detach fires
# @OnDetach reading the outgoing value (5+20=25) then clears it (0).
if ./selfhost/game-build.sh build/ludicc examples/detach.ludic "/tmp/ludic_det" >/tmp/det.out 2>&1 \
&& [ "$(/tmp/ludic_det </dev/null | tr '\n' ' ')" = "15 1 25 0 " ]; then
ok "detach.ludic (attach/detach + @OnAttach/@OnDetach)"
else bad "detach: $(tail -1 /tmp/det.out)"; fi
# LC1 reason-carrying teardown: one @OnDespawn(M, reason: r) hook branches on the
# EndReason each site passes. An in-world despawn -> Despawned (drop loot 3+500),
# a survivor at quit -> Quit (skip loot 9+1000; no silent deaths).
if ./selfhost/game-build.sh build/ludicc examples/reason.ludic "/tmp/ludic_rsn" >/tmp/rsn.out 2>&1 \
&& [ "$(/tmp/ludic_rsn </dev/null | tr '\n' ' ')" = "503 1009 " ]; then
ok "reason.ludic (@OnDespawn reason: Despawned vs Quit)"
else bad "reason: $(tail -1 /tmp/rsn.out)"; fi
# EV0 the event bus: `event E { payload }` + `@On(E)` listeners fired by `emit`.
# emit Hurt(3,5) -> Flash 5, Guard 8; Hurt(10,20) -> 20, 30; Cleared -> 999;
# Hurt(amount:42) with entity defaulting to 0 -> 42, 42. Listeners run in
# declaration order; the whole thing desugars to a direct @ev_<E> call.
if ./selfhost/game-build.sh build/ludicc examples/events.ludic "/tmp/ludic_evt" >/tmp/evt.out 2>&1 \
&& [ "$(/tmp/ludic_evt </dev/null | tr '\n' ' ')" = "5 8 20 30 999 42 42 " ]; then
ok "events.ludic (event/emit/@On — the event bus core)"
else bad "events: $(tail -1 /tmp/evt.out)"; fi
# A pure-Ludic example that drives and asserts itself from its own `entry`: compile
# headless to IR, let clang assemble it (no C compiled — clang is only the IR
# assembler, the floor Rust/Swift stand on), run, and compare stdout.
netcase() { # name expected-output(space-joined)
local n="$1" exp="$2"
build/ludicc --headless "examples/$n.ludic" --emit-llvm -o "/tmp/$n.ll" >"/tmp/$n.out" 2>&1 \
&& ${LUDIC_CC:-clang} -O2 "/tmp/$n.ll" -o "/tmp/ludic_$n" >>"/tmp/$n.out" 2>&1 || { bad "$n: build ($(tail -1 /tmp/$n.out))"; return; }
local got; got=$("/tmp/ludic_$n" </dev/null | tr '\n' ' ' | sed 's/ *$//')
[ "$got" = "$exp" ] && ok "$n ($got)" || bad "$n: got [$got] want [$exp]"
}
# EV0b the event bus with two listeners on one event: Native prints each hit,
# Accum sums them. Fire 10 then 32 -> 10, 32, 42. (Pure Ludic; was a C mod.)
netcase mod_events "10 32 42"
# NETWORKING N0–N6 (NETWORKING-DESIGN.md) — every phase is a self-contained Ludic
# program, driven and asserted from its own `entry`. No C host and no C compiled.
# The transport is the compiler's built-in loopback (an `extern fn` overrides it
# with a real socket in production), so a networked game runs with zero foreign code.
# N0 transport seam: net_send/net_poll — the built-in loopback (no extern host)
netcase net_echo "4 10 20 30 42"
# N1 world snapshot to a buffer: world_size/world_save/world_load (rollback substrate)
netcase net_snapshot "50 7 50"
# N2 @Sync: per-model serialize/apply, selective — Player syncs 12 bytes, max kept mutated
netcase net_sync "12 3 4 50 999"
# N3 @Owned: owner/set_owner/is_owner — fresh entity unowned, then assigned/tested
netcase net_owner "-1 7 0 1"
# N4 remote events (RPCs): @ToServer emit → wire → net_pump re-emits (5+3=8)
netcase net_rpc "0 8"
# N5 roles + drivable sim: @Server gated by set_role; tick_fixed() drives the phases
netcase net_roles "1 102"
# N6 blessed runtime end to end: RPC input → authority applies → replicate → reconcile
netcase net_demo "5 999 5"
# EV1 @Public hook promotion: a @Public @OnSpawn/@OnDespawn also emits the public
# event model_<M>_spawn / model_<M>_despawn, which native @On listeners (and
# foreign mods over the ABI) subscribe to. Two spawns -> Spawned 100,101; two
# despawns -> Died 200,201 (entity + reason(0) + 200).
if ./selfhost/game-build.sh build/ludicc examples/promote.ludic "/tmp/ludic_prm" >/tmp/prm.out 2>&1 \
&& [ "$(/tmp/ludic_prm </dev/null | tr '\n' ' ')" = "100 101 200 201 " ]; then
ok "promote.ludic (@Public -> model_<M>_spawn/despawn events)"
else bad "promote: $(tail -1 /tmp/prm.out)"; fi
# EV3 cancellable events: a `cancellable` event fires before an action; a listener
# `cancel`s it and the caller reads the verdict from `emit E(...)` as an
# expression. Armor vetoes hits over 10: emit(5)->0, emit(15)->1; applying only
# un-vetoed damage leaves hp at 92.
if ./selfhost/game-build.sh build/ludicc examples/cancel.ludic "/tmp/ludic_cnl" >/tmp/cnl.out 2>&1 \
&& [ "$(/tmp/ludic_cnl </dev/null | tr '\n' ' ')" = "0 1 92 " ]; then
ok "cancel.ludic (cancellable event + cancel + emit-as-expression)"
else bad "cancel: $(tail -1 /tmp/cnl.out)"; fi
# EV2 the world table: an ECS program that declares events also generates the mod
# reflection ABI, now callable from Ludic via the world_* builtins (world_prop_id/
# field_id/get/set/has/count/kind/model_id/query_next/spawn/register_prop/attach_dyn)
# — the same functions a foreign mod binds by name. Each driver below reads and
# writes the world by NAME, never having compiled against a fixed layout.
netcase world_get "50 1 7" # EV2 read/write a field by name (has 1, hp 50->7)
netcase world_scan "2 110" # EV2b count/kind/model_id — scan + identify by model
netcase world_spawn "1 42 1" # EV2b world_spawn — create an entity by model id
netcase world_mixed "99" # EV2b real struct offsets (qty after a ptr, byte 8)
netcase world_query "2 110" # EV2b world_query_next — iterate bearers of a property
# EV6 re-entrancy bound: Ping's listener emits Pong and Pong's emits Ping — an
# event cycle. The @ev_depth cap makes the nesting trap as an early return, so the
# program terminates deterministically (n=16 at the cap) instead of hanging.
if ./selfhost/game-build.sh build/ludicc examples/recurse.ludic "/tmp/ludic_rec" >/tmp/rec.out 2>&1 \
&& [ "$(/tmp/ludic_rec </dev/null | tr '\n' ' ')" = "16 " ]; then
ok "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)"
else bad "recurse: $(tail -1 /tmp/rec.out)"; fi
# EV5 despawn drops an entity from later event-driven work: an @On(Tick) listener
# counts live Units; ticking twice gives 2, then the Unit is despawned and a third
# tick finds none, so the count stays 2.
netcase scoped "2"
# EV7 schema opening: register a NEW component (Mana) the game never declared, get a
# prop id that works with the same get/set/has ABI, attach it, and keep per-entity
# storage isolated. has 0->1, fields 30/100, name resolves (1), other entity's write
# doesn't corrupt the first (still 30).
netcase world_dyn "0 1 30 100 1 30"
# EV1 for properties: @Public on @OnAttach/@OnDetach promotes to prop_<P>_attach /
# prop_<P>_detach, extending public events past models to properties. Attaching
# then detaching Shield on entity 0 -> Gained 300, Lost 400.
if ./selfhost/game-build.sh build/ludicc examples/prop_events.ludic "/tmp/ludic_pe" >/tmp/pe.out 2>&1 \
&& [ "$(/tmp/ludic_pe </dev/null | tr '\n' ' ')" = "300 400 " ]; then
ok "prop_events.ludic (@Public -> prop_<P>_attach/detach events)"
else bad "prop events: $(tail -1 /tmp/pe.out)"; fi
# EV1 for scenes: a `public` scene promotes on-enter/on-exit to scene_<S>_enter /
# scene_<S>_exit. Boot enters Menu (10, listener 1); `become Game` leaves Menu (20,
# listener 2) and enters Game (30, listener 3).
if ./selfhost/game-build.sh build/ludicc examples/scene_events.ludic "/tmp/ludic_se" >/tmp/se.out 2>&1 \
&& [ "$(/tmp/ludic_se </dev/null | tr '\n' ' ')" = "10 1 20 2 30 3 " ]; then
ok "scene_events.ludic (public scene -> scene_<S>_enter/exit events)"
else bad "scene events: $(tail -1 /tmp/se.out)"; fi
# EV1 for the program scope: @Public @OnStart/@OnQuit -> program_start/program_quit.
# Boot fires @OnStart(1) then program_start(100); shutdown fires @OnQuit(2) then
# program_quit(200) -> the top-level mod entry/exit points.
if ./selfhost/game-build.sh build/ludicc examples/program_events.ludic "/tmp/ludic_pge" >/tmp/pge.out 2>&1 \
&& [ "$(/tmp/ludic_pge </dev/null | tr '\n' ' ')" = "1 100 2 200 " ]; then
ok "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)"
else bad "program events: $(tail -1 /tmp/pge.out)"; fi
# EV1 for layers (+ SCENES E2 layer toggle): a `public` layer + `enable/disable
# layer L` flips the layer (its handlers stop) and fires layer_<L>_show/hide.
# Frame 1 Draw(50) then disable->Hidden(2); frame 2 Draw gated off, enable->Shown(1);
# frame 3 Draw(50) then quit. Needs input frames, so feed keys.
if ./selfhost/game-build.sh build/ludicc examples/layer_events.ludic "/tmp/ludic_le" >/tmp/le.out 2>&1 \
&& [ "$(printf 'aaaa' | /tmp/ludic_le | tr '\n' ' ')" = "50 2 1 50 " ]; then
ok "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)"
else bad "layer events: $(tail -1 /tmp/le.out)"; fi
# --- Toolchain-agent CLI smoke tests append below this line ---
echo "== self-hosted front-end binaries (ludicc / ludic) =="
# The two commands are one multi-call native binary built from the seed with