ludic.devlink: the interface's verbs (ui_screen, ui_model, ui_tree, ui_override) through a DevlinkUi port
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f07fc8a62
commit
5bc2342174
8 changed files with 203 additions and 1 deletions
|
|
@ -11,7 +11,12 @@ An editor's live link into a running dev build: the wire protocol v1 frozen in
|
|||
`cam_get` / `cam_set` / `cam_release`, `goto`, `map_load`, `time`, `weather`, `shot`, `pause` / `resume`,
|
||||
`step`. `map_load`, `shot` and `step` answer later by id (5 s at most, one at a time). While
|
||||
`coop()` says a session is connected, everything but `ping` and `hello` is refused.
|
||||
- **The interface's verbs** are `DevlinkUi` members, the same default: `ui_screen` answers the screen's root
|
||||
class and every component up; `ui_model "<Class>" "<out>"` and `ui_tree "<out>"` have the game write the
|
||||
first instance's model, or the whole tree, to that file (no `..`) and answer `ok "<out>"`, so a datagram
|
||||
stays small; `ui_override "<path>" "<file>"` has the interface read a template or stylesheet from that file
|
||||
and reload it keeping state (`""` for the file clears one, `"" ""` clears all). All answer at once.
|
||||
- **Call `devlink_tick`** once a frame from something that runs while the game is paused (not from a
|
||||
system the pause stops), or `resume` could never arrive.
|
||||
|
||||
Tests: `ludic test packages/ludic.devlink` (a fake transport and world; each answer byte for byte).
|
||||
Tests: `ludic test packages/ludic.devlink` (a fake transport, world and interface; each answer byte for byte).
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ module ludic_devlink uses ludic_base
|
|||
numbers float
|
||||
import "ludic.base"
|
||||
import "port.ludic"
|
||||
import "ui.ludic"
|
||||
import "state.ludic"
|
||||
import "wire_read.ludic"
|
||||
import "wire_write.ludic"
|
||||
import "verbs.ludic"
|
||||
import "verbs_world.ludic"
|
||||
import "verbs_ui.ludic"
|
||||
import "late.ludic"
|
||||
import "system.ludic"
|
||||
|
|
|
|||
|
|
@ -26,4 +26,5 @@ export state DevlinkState {
|
|||
dl_pend_port: int = 0
|
||||
dl_pend_t: float = 0.0
|
||||
dl_cam: DevlinkCam = new DevlinkCam
|
||||
dl_ui: DevlinkUiScreen = new DevlinkUiScreen
|
||||
}
|
||||
|
|
|
|||
90
packages/ludic.devlink/tests/devlink_ui_test.ludic
Normal file
90
packages/ludic.devlink/tests/devlink_ui_test.ludic
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# devlink_ui_test.ludic - the ui verbs against a fake transport and a fake interface: each answer byte for
|
||||
# byte, a path with .. refused, an instance that is not there, the overrides set and cleared, not offered
|
||||
import "ludic.devlink"
|
||||
import "ludic.base"
|
||||
program DevlinkUiTest {
|
||||
numbers float
|
||||
state FakeUiState {
|
||||
inbox: string = ""
|
||||
sent: string = ""
|
||||
offered: bool = true
|
||||
last: string = "" # what the interface was last asked, "a|b"
|
||||
}
|
||||
function fk_open(port: int) -> int { return 3 }
|
||||
function fk_recv(fake_ui_st: mut FakeUiState, h: int, b: []byte, cap: int) -> int {
|
||||
let s = fake_ui_st.inbox
|
||||
fake_ui_st.inbox = ""
|
||||
for i in 0 .. len(s) { b[i] = s[i] }
|
||||
return len(s)
|
||||
}
|
||||
function fk_ip(h: int) -> int { return 2130706433 }
|
||||
function fk_port(h: int) -> int { return 5555 }
|
||||
@alloc_ok("a test: the answer kept to compare")
|
||||
function fk_send(fake_ui_st: mut FakeUiState, h: int, ip: int, port: int, b: []byte, n: int) -> int {
|
||||
fake_ui_st.sent = text_of(b, n)
|
||||
return n
|
||||
}
|
||||
function fk_loop(ip: int) -> bool { return true }
|
||||
function fk_on() -> bool { return true }
|
||||
bind DevlinkNet { open: fn fk_open, recv: fn fk_recv, from_ip: fn fk_ip, from_port: fn fk_port, send: fn fk_send, loopback: fn fk_loop }
|
||||
bind DevlinkWorld { enabled: fn fk_on }
|
||||
|
||||
function fu_screen(fake_ui_st: FakeUiState, s: DevlinkUiScreen) -> int {
|
||||
if not fake_ui_st.offered { return DL_UNBOUND }
|
||||
s.root = "PauseMenu"
|
||||
s.components = "Screen,Heading,Footer"
|
||||
return 0
|
||||
}
|
||||
@alloc_ok("a test: what the interface was asked")
|
||||
function fu_model(fake_ui_st: mut FakeUiState, cls: string, path: string) -> int {
|
||||
fake_ui_st.last = cls + "|" + path
|
||||
if cls == "Ghost" { return -1 }
|
||||
return 0
|
||||
}
|
||||
function fu_tree(fake_ui_st: mut FakeUiState, path: string) -> int {
|
||||
fake_ui_st.last = path
|
||||
return 0
|
||||
}
|
||||
@alloc_ok("a test: what the interface was asked")
|
||||
function fu_override(fake_ui_st: mut FakeUiState, path: string, file: string) -> int {
|
||||
fake_ui_st.last = path + "|" + file
|
||||
return 0
|
||||
}
|
||||
bind DevlinkUi { screen: fn fu_screen, model: fn fu_model, tree: fn fu_tree, override: fn fu_override }
|
||||
|
||||
function ask(devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState, line: string) -> string {
|
||||
fake_ui_st.inbox = line
|
||||
fake_ui_st.sent = ""
|
||||
devlink_tick(devlink_st, tick_new(0.016, 0, 0.0))
|
||||
return fake_ui_st.sent
|
||||
}
|
||||
|
||||
test "ui_screen names the root and every component; unbound says not offered" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
|
||||
expect(ask(devlink_st, fake_ui_st, "1 ui_screen") == "1 ok \"PauseMenu\" \"Screen,Heading,Footer\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "2 ui_screen extra") == "2 err bad_args \"bad arguments\"")
|
||||
fake_ui_st.offered = false
|
||||
expect(ask(devlink_st, fake_ui_st, "3 ui_screen") == "3 err unbound \"not offered\"")
|
||||
}
|
||||
|
||||
test "ui_model and ui_tree write where they are told, never past .., and say a missing instance" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
|
||||
expect(ask(devlink_st, fake_ui_st, "4 ui_model \"PauseMenu\" \"/tmp/m.json\"") == "4 ok \"/tmp/m.json\"")
|
||||
expect(fake_ui_st.last == "PauseMenu|/tmp/m.json")
|
||||
expect(ask(devlink_st, fake_ui_st, "5 ui_model \"Ghost\" \"/tmp/g.json\"") == "5 err failed \"no such instance\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "6 ui_model \"PauseMenu\" \"/tmp/../etc/x\"") == "6 err bad_args \"a path without ..\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "7 ui_model \"PauseMenu\"") == "7 err bad_args \"bad arguments\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "8 ui_model \"\" \"/tmp/m.json\"") == "8 err bad_args \"a class and a path\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "9 ui_tree \"/tmp/tree.json\"") == "9 ok \"/tmp/tree.json\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "10 ui_tree \"\"") == "10 err bad_args \"a path without ..\"")
|
||||
}
|
||||
|
||||
test "ui_override sets one, clears one with \"\", clears all with \"\" \"\"; a file past .. is refused" (devlink_st: mut DevlinkState, fake_ui_st: mut FakeUiState) {
|
||||
expect(ask(devlink_st, fake_ui_st, "11 ui_override \"src/ui/menus/PauseMenu.xml\" \"/tmp/p.xml\"") == "11 ok")
|
||||
expect(fake_ui_st.last == "src/ui/menus/PauseMenu.xml|/tmp/p.xml")
|
||||
expect(ask(devlink_st, fake_ui_st, "12 ui_override \"src/ui/menus/PauseMenu.xml\" \"\"") == "12 ok")
|
||||
expect(fake_ui_st.last == "src/ui/menus/PauseMenu.xml|")
|
||||
expect(ask(devlink_st, fake_ui_st, "13 ui_override \"\" \"\"") == "13 ok")
|
||||
expect(fake_ui_st.last == "|")
|
||||
expect(ask(devlink_st, fake_ui_st, "14 ui_override \"\" \"/tmp/p.xml\"") == "14 err bad_args \"a file without .., or \\\"\\\" \\\"\\\" to clear all\"")
|
||||
expect(ask(devlink_st, fake_ui_st, "15 ui_override \"a.xml\" \"../b.xml\"") == "15 err bad_args \"a file without .., or \\\"\\\" \\\"\\\" to clear all\"")
|
||||
}
|
||||
}
|
||||
17
packages/ludic.devlink/ui.ludic
Normal file
17
packages/ludic.devlink/ui.ludic
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# ui.ludic - what the link asks the game's interface (ludic.ui): which screen is up, a component's model
|
||||
# or the whole tree written to a file, and a template or stylesheet read from another file for a while.
|
||||
# Every member defaults to "not offered"; the big answers go through a file, as a shot does.
|
||||
export property DevlinkUiScreen {
|
||||
root: string = "" # the screen's root class
|
||||
components: string = "" # every component up, comma-separated
|
||||
}
|
||||
|
||||
export port DevlinkUi {
|
||||
screen: fn(DevlinkUiScreen) -> int = fn dlu_screen
|
||||
model: fn(string, string) -> int = fn dlu_s2 # class, out path -> 0 written, -1 no such instance
|
||||
tree: fn(string) -> int = fn dlu_s1 # out path -> 0 written, -1 failed
|
||||
override: fn(string, string) -> int = fn dlu_s2 # path, file ("" clears it; "" "" clears all) -> 0, -1
|
||||
}
|
||||
function dlu_screen(s: DevlinkUiScreen) -> int { return DL_UNBOUND }
|
||||
function dlu_s1(a: string) -> int { return DL_UNBOUND }
|
||||
function dlu_s2(a: string, b: string) -> int { return DL_UNBOUND }
|
||||
|
|
@ -39,6 +39,10 @@ function dl_verb(st: mut DevlinkState, id: int) -> void {
|
|||
else if dl_word(st, "pause") { dl_answer(st, id, DevlinkWorld.pause(true)) }
|
||||
else if dl_word(st, "resume") { dl_answer(st, id, DevlinkWorld.pause(false)) }
|
||||
else if dl_word(st, "step") { dl_step(st, id) }
|
||||
else if dl_word(st, "ui_screen") { dl_ui_screen(st, id) }
|
||||
else if dl_word(st, "ui_model") { dl_ui_model(st, id) }
|
||||
else if dl_word(st, "ui_tree") { dl_ui_tree(st, id) }
|
||||
else if dl_word(st, "ui_override") { dl_ui_override(st, id) }
|
||||
else { dl_err(st, id, "unknown_verb", "no such verb") }
|
||||
}
|
||||
|
||||
|
|
|
|||
76
packages/ludic.devlink/verbs_ui.ludic
Normal file
76
packages/ludic.devlink/verbs_ui.ludic
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# verbs_ui.ludic - ui_screen, ui_model, ui_tree and ui_override: answered at once, the big ones in a file
|
||||
function dl_ui_screen(st: mut DevlinkState, id: int) -> void {
|
||||
if dl_bad_args(st, id) { return }
|
||||
let s = st.dl_ui
|
||||
if DevlinkUi.screen(s) == DL_UNBOUND {
|
||||
dl_err(st, id, "unbound", "not offered")
|
||||
return
|
||||
}
|
||||
dl_ok(st, id)
|
||||
dl_b(st, 32)
|
||||
dl_q(st, s.root)
|
||||
dl_b(st, 32)
|
||||
dl_q(st, s.components)
|
||||
}
|
||||
|
||||
# a file the game writes: a path with no "..", as a shot's
|
||||
function dl_out_path(st: DevlinkState, n: int) -> bool {
|
||||
if n < 1 or n > len(st.dl_path) { return false }
|
||||
for i in 0 .. n - 1 { if st.dl_str[i] == 46 and st.dl_str[i + 1] == 46 { return false } }
|
||||
return true
|
||||
}
|
||||
# the answer to a member that wrote a file: ok and the path, not offered, or what went wrong
|
||||
function dl_wrote(st: mut DevlinkState, id: int, r: int, n: int, why: string) -> void {
|
||||
if r == DL_UNBOUND { dl_err(st, id, "unbound", "not offered") }
|
||||
else if r < 0 { dl_err(st, id, "failed", why) }
|
||||
else {
|
||||
dl_ok(st, id)
|
||||
dl_b(st, 32)
|
||||
dl_q_bytes(st, st.dl_str, n)
|
||||
}
|
||||
}
|
||||
|
||||
function dl_ui_model(st: mut DevlinkState, id: int) -> void {
|
||||
let nc = dl_quoted(st)
|
||||
if st.dl_bad or nc < 1 {
|
||||
dl_err(st, id, "bad_args", "a class and a path")
|
||||
return
|
||||
}
|
||||
let cls = dl_text(st, nc)
|
||||
let n = dl_quoted(st)
|
||||
if dl_bad_args(st, id) { return }
|
||||
if not dl_out_path(st, n) {
|
||||
dl_err(st, id, "bad_args", "a path without ..")
|
||||
return
|
||||
}
|
||||
dl_wrote(st, id, DevlinkUi.model(cls, dl_text(st, n)), n, "no such instance")
|
||||
}
|
||||
|
||||
function dl_ui_tree(st: mut DevlinkState, id: int) -> void {
|
||||
let n = dl_quoted(st)
|
||||
if dl_bad_args(st, id) { return }
|
||||
if not dl_out_path(st, n) {
|
||||
dl_err(st, id, "bad_args", "a path without ..")
|
||||
return
|
||||
}
|
||||
dl_wrote(st, id, DevlinkUi.tree(dl_text(st, n)), n, "not written")
|
||||
}
|
||||
|
||||
# "" for the file clears that path's override; "" "" clears every one
|
||||
function dl_ui_override(st: mut DevlinkState, id: int) -> void {
|
||||
let np = dl_quoted(st)
|
||||
if st.dl_bad {
|
||||
dl_err(st, id, "bad_args", "a path and a file")
|
||||
return
|
||||
}
|
||||
let path = dl_text(st, np)
|
||||
let nf = dl_quoted(st)
|
||||
if dl_bad_args(st, id) { return }
|
||||
let file_ok = nf == 0 or dl_out_path(st, nf)
|
||||
if not file_ok or (np == 0 and nf > 0) {
|
||||
dl_err(st, id, "bad_args", "a file without .., or \"\" \"\" to clear all")
|
||||
return
|
||||
}
|
||||
let r = DevlinkUi.override(path, dl_text(st, nf))
|
||||
if r < 0 and r != DL_UNBOUND { dl_err(st, id, "failed", "not overridden") } else { dl_answer(st, id, r) }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue