v0.3 — AI parser lane + dialogue talker portraits
AI lane (ported from MRPGI): unknown typed sentences go to a local LLM (MRPCI_AI: ollama default / openrouter / off) that maps them onto already-legal commands; one in flight, polled per tick, hard-off under --script so replays stay byte-identical. Portraits: NPCs/props gain a portrait sprite drawn beside the dialogue window in the GUI (6x) and annotated frames (2x); sergeant + vend-bot faces in the demo. Also: --script + --render-room renders the frame a replay ends on. Verified: 5 test suites green, deterministic 25/25 playthrough, GUI boots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
64a2db6605
commit
0191b4680d
477
Cargo.lock
generated
477
Cargo.lock
generated
@ -58,6 +58,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@ -88,6 +94,16 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@ -156,6 +172,17 @@ version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@ -171,6 +198,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@ -197,6 +230,15 @@ dependencies = [
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
@ -273,6 +315,109 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
"utf8_iter",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec 1.15.2",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_locale_core",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec 1.15.2",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.24.9"
|
||||
@ -320,6 +465,12 @@ version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
@ -407,6 +558,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tiny_http",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -460,6 +612,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@ -485,6 +643,15 @@ version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
|
||||
dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
@ -566,6 +733,55 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@ -615,6 +831,12 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
@ -659,12 +881,24 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@ -687,6 +921,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.18"
|
||||
@ -714,6 +959,16 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.21.1"
|
||||
@ -726,6 +981,48 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "2.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"flate2",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@ -802,6 +1099,24 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@ -824,12 +1139,114 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.55"
|
||||
@ -850,6 +1267,66 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.11.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
|
||||
15
README.md
15
README.md
@ -111,13 +111,22 @@ The GUI renders via a **GPU palette shader**: the frame ships to the card
|
||||
as indices + a 256x1 palette strip, and the fragment shader does the lookup
|
||||
plus the whole CRT (curvature, scanlines, vignette, room fades).
|
||||
|
||||
## The AI parser lane
|
||||
|
||||
When the deterministic parser shrugs at a typed sentence, a small local LLM
|
||||
(Ollama by default; OpenRouter optional) translates it into ONE command from
|
||||
the *already-legal* verb/noun tables — then that command runs through the
|
||||
real game logic. The AI can suggest; only the sim decides. Configure with
|
||||
`MRPCI_AI` (`ollama`/`openrouter`/`off`), `MRPCI_AI_MODEL`, `MRPCI_AI_URL`.
|
||||
`--script` replays force the lane off, so golden tests stay byte-identical.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] v0.1 — the engine above + Neon Precinct
|
||||
- [x] v0.2 — procedural fills (fBm/Voronoi/scatter), weather particles,
|
||||
in-core font + annotated frames, GPU palette/CRT shader
|
||||
- [x] v0.3 — world bundles carry art (`--export-bundle`/`--bundle`, the
|
||||
wasm path), AI parser lane, dialogue talker portraits
|
||||
- [ ] Priority-mask art workflow polish (paint depth in any image editor)
|
||||
- [ ] In-engine room editor (MRPGI's, upgraded to four screens)
|
||||
- [ ] AI parser lane (LLM maps free text onto already-legal commands, like MRPGI)
|
||||
- [ ] Portraits/talkers in dialogue windows
|
||||
- [ ] wasm build (`mrpci-core` is already windowless — the web is an adapter away)
|
||||
- [ ] wasm build → see the [MRP3GI](../mrp3gi) sibling (three.js + rigged GLBs over this core)
|
||||
|
||||
@ -25,6 +25,11 @@ mrpci-headless --help
|
||||
text stamped with the built-in font; add `--raw` for the bare scene),
|
||||
`--screens dir/`.
|
||||
|
||||
NPCs and props with `dialogue` may set `portrait` (a sprite name) — the
|
||||
talker face drawn beside the dialogue window in the GUI and in annotated
|
||||
frames. `--script FILE --render-room OUT.png` renders the frame a replay
|
||||
ends on (screenshot-after-scenario).
|
||||
|
||||
RoomDoc extras beyond the basics: procedural paint ops `fbm_fill`
|
||||
(ramp/scale/octaves/seed), `voronoi_fill` (cell/colors/edge_color/edge/seed),
|
||||
`scatter` (colors/density/seed) — all deterministic by seed — and a
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
"max_score": 25,
|
||||
"ego_sprite": "",
|
||||
"defaults": {
|
||||
"smell": "Ozone, rain, and yesterday's synth-noodles.",
|
||||
"listen": "The city hums in B-flat.",
|
||||
"look": "Rain-slick chrome and old neon. Nothing more.",
|
||||
"do": "Your servos find no purchase on that."
|
||||
"do": "Your servos find no purchase on that.",
|
||||
"listen": "The city hums in B-flat.",
|
||||
"smell": "Ozone, rain, and yesterday's synth-noodles."
|
||||
}
|
||||
}
|
||||
@ -1070,8 +1070,8 @@
|
||||
],
|
||||
"poly": [],
|
||||
"msgs": {
|
||||
"look": "Neon drowns in the puddle. Your chassis manual is very clear about puddles.",
|
||||
"do": "You are 900 pounds of municipal robot. The puddle wins."
|
||||
"do": "You are 900 pounds of municipal robot. The puddle wins.",
|
||||
"look": "Neon drowns in the puddle. Your chassis manual is very clear about puddles."
|
||||
},
|
||||
"exit_to": null,
|
||||
"arrive": null,
|
||||
@ -1105,9 +1105,9 @@
|
||||
"x": 288,
|
||||
"y": 148,
|
||||
"msgs": {
|
||||
"smell": "Your olfactory sensor files a grievance.",
|
||||
"look": "A city dumpster. Something inside is composting on a geological timescale.",
|
||||
"do": "You rummage. Old circuit boards, a single roller skate, regret.",
|
||||
"smell": "Your olfactory sensor files a grievance."
|
||||
"do": "You rummage. Old circuit boards, a single roller skate, regret."
|
||||
},
|
||||
"takeable": false,
|
||||
"synonyms": "",
|
||||
@ -1117,6 +1117,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 0,
|
||||
"consumes": false,
|
||||
"kills": false,
|
||||
@ -1172,6 +1173,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"portrait": "vendbot-face",
|
||||
"wander": 34,
|
||||
"visible_flag": "",
|
||||
"hidden_by_flag": "",
|
||||
|
||||
@ -680,8 +680,8 @@
|
||||
],
|
||||
"poly": [],
|
||||
"msgs": {
|
||||
"look": "The duty desk. Coffee rings dating back three administrations.",
|
||||
"do": "You straighten a stack of incident forms. Order is restored, briefly."
|
||||
"do": "You straighten a stack of incident forms. Order is restored, briefly.",
|
||||
"look": "The duty desk. Coffee rings dating back three administrations."
|
||||
},
|
||||
"exit_to": null,
|
||||
"arrive": null,
|
||||
@ -728,6 +728,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "locker_open",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 10,
|
||||
"consumes": false,
|
||||
"kills": false,
|
||||
@ -754,6 +755,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 10,
|
||||
"consumes": true,
|
||||
"kills": false,
|
||||
@ -830,6 +832,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"portrait": "sergeant-face",
|
||||
"wander": 0,
|
||||
"visible_flag": "",
|
||||
"hidden_by_flag": "",
|
||||
|
||||
@ -617,6 +617,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 0,
|
||||
"consumes": false,
|
||||
"kills": false,
|
||||
@ -642,6 +643,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 0,
|
||||
"consumes": false,
|
||||
"kills": false,
|
||||
@ -667,6 +669,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 5,
|
||||
"consumes": false,
|
||||
"kills": false,
|
||||
@ -692,6 +695,7 @@
|
||||
"requires_flag": "",
|
||||
"sets_flag": "",
|
||||
"dialogue": [],
|
||||
"portrait": "",
|
||||
"points": 0,
|
||||
"consumes": false,
|
||||
"kills": true,
|
||||
|
||||
BIN
games/neon-precinct/sprites/sergeant-face.png
Normal file
BIN
games/neon-precinct/sprites/sergeant-face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 B |
BIN
games/neon-precinct/sprites/vendbot-face.png
Normal file
BIN
games/neon-precinct/sprites/vendbot-face.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 286 B |
@ -13,9 +13,11 @@ image = { version = "0.24", default-features = false, features = ["png"] } # PN
|
||||
# replayed script is bit-identical — the whole CPU-speed-bug class, deleted.
|
||||
rhai = { version = "1", features = ["sync", "no_time"] }
|
||||
|
||||
# Native-only: the HTTP surface doesn't exist in browser builds.
|
||||
# Native-only: the HTTP surface and the AI parser lane don't exist in
|
||||
# browser builds (wasm gets the deterministic parser and bundles).
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tiny_http = "0.12" # the --serve control surface
|
||||
ureq = { version = "2", features = ["json", "tls"] } # AI parser lane: Ollama / OpenRouter HTTP
|
||||
|
||||
[[bin]]
|
||||
name = "mrpci-headless"
|
||||
|
||||
167
mrpci-core/src/ai.rs
Normal file
167
mrpci-core/src/ai.rs
Normal file
@ -0,0 +1,167 @@
|
||||
//! The AI parser lane — MRPGI's proven trick, carried into the SCI era.
|
||||
//!
|
||||
//! When the deterministic parser can't map a sentence to a command, a small
|
||||
//! LLM translates the player's free text into ONE *allowed* command — which
|
||||
//! is then re-run through the real game logic, so logic stays authoritative
|
||||
//! and the AI can never invent state. The call runs on a worker thread and
|
||||
//! reports back over a channel, so no caller ever blocks; replays and
|
||||
//! `--script` runs force the lane off, so determinism is untouched.
|
||||
//!
|
||||
//! Provider is chosen by env vars (default = local Ollama, free + offline):
|
||||
//! MRPCI_AI ollama (default) | openrouter | off
|
||||
//! MRPCI_AI_MODEL e.g. "gemma3:4b" (ollama) or "google/gemma-2-9b-it:free" (openrouter)
|
||||
//! MRPCI_AI_URL override the endpoint (e.g. a tailnet Ollama box)
|
||||
//! OPENROUTER_API_KEY (or MRPCI_AI_KEY) for openrouter
|
||||
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::thread;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // browser builds only ever construct Off
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Provider {
|
||||
Off,
|
||||
Ollama,
|
||||
OpenRouter,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] // url/key unused when the lane is compiled out
|
||||
#[derive(Clone)]
|
||||
pub struct AiConfig {
|
||||
provider: Provider,
|
||||
model: String,
|
||||
url: String,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
impl AiConfig {
|
||||
pub fn from_env() -> Self {
|
||||
// No HTTP client, no worker threads in the browser build.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Self::off();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Self::from_env_native()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn from_env_native() -> Self {
|
||||
let which = std::env::var("MRPCI_AI").unwrap_or_else(|_| "ollama".into()).to_lowercase();
|
||||
let provider = match which.as_str() {
|
||||
"off" | "none" | "0" => Provider::Off,
|
||||
"openrouter" | "or" => Provider::OpenRouter,
|
||||
_ => Provider::Ollama,
|
||||
};
|
||||
let model = std::env::var("MRPCI_AI_MODEL").unwrap_or_else(|_| match provider {
|
||||
Provider::OpenRouter => "google/gemma-2-9b-it:free".into(),
|
||||
_ => "gemma3:4b".into(),
|
||||
});
|
||||
let url = std::env::var("MRPCI_AI_URL").unwrap_or_else(|_| match provider {
|
||||
Provider::OpenRouter => "https://openrouter.ai/api/v1/chat/completions".into(),
|
||||
_ => "http://localhost:11434/api/generate".into(),
|
||||
});
|
||||
let key = std::env::var("OPENROUTER_API_KEY").ok().or_else(|| std::env::var("MRPCI_AI_KEY").ok());
|
||||
AiConfig { provider, model, url, key }
|
||||
}
|
||||
|
||||
pub fn off() -> Self {
|
||||
AiConfig { provider: Provider::Off, model: String::new(), url: String::new(), key: None }
|
||||
}
|
||||
|
||||
pub fn enabled(&self) -> bool {
|
||||
self.provider != Provider::Off
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self.provider {
|
||||
Provider::Off => "off".into(),
|
||||
Provider::Ollama => format!("ollama/{}", self.model),
|
||||
Provider::OpenRouter => format!("openrouter/{}", self.model),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The instruction we send the model — tight, so even a 4B local model
|
||||
/// behaves. `verbs` and `things` come from the live tables, so the
|
||||
/// whitelist the AI sees can never drift from what the parser accepts.
|
||||
pub fn build_prompt(text: &str, things: &str, inventory: &str, verbs: &str) -> String {
|
||||
format!(
|
||||
"You translate a player's sentence into ONE point-and-click adventure command.
|
||||
Valid verbs: {verbs}.
|
||||
Things you can see: {things}.
|
||||
Carried items: {inventory}.
|
||||
Reply with ONLY one short command like `look sign`, `take keycard`, `talk sergeant`, or `use keycard on locker`, using a verb above and (if it helps) one of the things listed. If nothing fits, reply `look`. No quotes, no explanation.
|
||||
Player: {text}
|
||||
Command:"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fire the translation on a worker thread; poll the returned receiver with
|
||||
/// `try_recv()`. The result is a sanitized command (or "" = no idea).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn translate_async(cfg: AiConfig, prompt: String) -> Receiver<String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let raw = call(&cfg, &prompt).unwrap_or_default();
|
||||
let _ = tx.send(sanitize(&raw));
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// Browser stub — never reached (the wasm AiConfig is always off), but keeps
|
||||
/// the call sites compiling without a cfg forest.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn translate_async(_cfg: AiConfig, _prompt: String) -> Receiver<String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _ = tx.send(String::new());
|
||||
rx
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| l.trim())
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("")
|
||||
.trim_matches(|c: char| c == '`' || c == '"' || c == '\'' || c == '.' || c == '>' || c == '*')
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn call(cfg: &AiConfig, prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(Duration::from_secs(5))
|
||||
.timeout_read(Duration::from_secs(25))
|
||||
.build();
|
||||
match cfg.provider {
|
||||
Provider::Off => Ok(String::new()),
|
||||
Provider::Ollama => {
|
||||
let resp = agent
|
||||
.post(&cfg.url)
|
||||
.send_json(serde_json::json!({
|
||||
"model": cfg.model,
|
||||
"prompt": prompt,
|
||||
"stream": false,
|
||||
"options": { "temperature": 0.1, "num_predict": 24 }
|
||||
}))?
|
||||
.into_json::<serde_json::Value>()?;
|
||||
Ok(resp["response"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
Provider::OpenRouter => {
|
||||
let key = cfg.key.clone().ok_or_else(|| "no OPENROUTER_API_KEY set".to_string())?;
|
||||
let resp = agent
|
||||
.post(&cfg.url)
|
||||
.set("Authorization", &format!("Bearer {}", key))
|
||||
.send_json(serde_json::json!({
|
||||
"model": cfg.model,
|
||||
"max_tokens": 24,
|
||||
"temperature": 0.1,
|
||||
"messages": [{ "role": "user", "content": prompt }]
|
||||
}))?
|
||||
.into_json::<serde_json::Value>()?;
|
||||
Ok(resp["choices"][0]["message"]["content"].as_str().unwrap_or("").to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,11 +93,14 @@ fn main() {
|
||||
gs.apply(Command::Tick { n });
|
||||
}
|
||||
|
||||
if let Some(path) = get("--render-room") {
|
||||
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
|
||||
std::fs::write(&path, png).expect("write png");
|
||||
println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current);
|
||||
return;
|
||||
// (With --script, rendering happens after the replay instead.)
|
||||
if get("--script").is_none() {
|
||||
if let Some(path) = get("--render-room") {
|
||||
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
|
||||
std::fs::write(&path, png).expect("write png");
|
||||
println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(dir) = get("--screens") {
|
||||
@ -123,6 +126,9 @@ fn main() {
|
||||
}
|
||||
|
||||
if let Some(path) = get("--script") {
|
||||
// Replays must be byte-identical: the AI lane is hard-off here no
|
||||
// matter what the environment says.
|
||||
gs.ai = mrpci_core::ai::AiConfig::off();
|
||||
let f = std::fs::File::open(&path).unwrap_or_else(|e| {
|
||||
eprintln!("can't open {}: {}", path, e);
|
||||
std::process::exit(1);
|
||||
@ -130,6 +136,13 @@ fn main() {
|
||||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||||
run_line(&mut gs, &line);
|
||||
}
|
||||
// `--script X --render-room Y` renders the frame the replay ended
|
||||
// on — screenshot-after-scenario, the golden-test's visual twin.
|
||||
if let Some(out) = get("--render-room") {
|
||||
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
|
||||
std::fs::write(&out, png).expect("write png");
|
||||
eprintln!("rendered {}", out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -252,6 +252,7 @@ fn room0() -> RoomDoc {
|
||||
npcs: vec![NpcDef {
|
||||
name: "vend-bot".into(),
|
||||
sprite: "vendbot".into(),
|
||||
portrait: "vendbot-face".into(),
|
||||
x: 150,
|
||||
y: 132,
|
||||
wander: 34,
|
||||
@ -381,6 +382,7 @@ fn room1() -> RoomDoc {
|
||||
npcs: vec![NpcDef {
|
||||
name: "sergeant".into(),
|
||||
sprite: "sergeant".into(),
|
||||
portrait: "sergeant-face".into(),
|
||||
x: 162,
|
||||
y: 132,
|
||||
wander: 0,
|
||||
@ -697,6 +699,27 @@ fn write_sprites(dir: &Path) -> std::io::Result<()> {
|
||||
fill(&mut vendbot, 0, 26, 18, 4, [30, 30, 34]);
|
||||
save("vendbot", &vendbot)?;
|
||||
|
||||
// Talker portraits (drawn 2x in the core window, 6x in the GUI).
|
||||
let mut serg_face = RgbaImage::new(24, 24);
|
||||
fill(&mut serg_face, 0, 0, 24, 24, [26, 30, 44]); // backdrop
|
||||
fill(&mut serg_face, 5, 6, 14, 14, [140, 150, 170]); // head
|
||||
fill(&mut serg_face, 4, 2, 16, 5, [40, 60, 140]); // cap
|
||||
fill(&mut serg_face, 4, 6, 16, 1, [230, 200, 60]); // cap band
|
||||
fill(&mut serg_face, 8, 11, 3, 3, [80, 200, 255]); // eyes
|
||||
fill(&mut serg_face, 14, 11, 3, 3, [80, 200, 255]);
|
||||
fill(&mut serg_face, 9, 17, 7, 2, [70, 78, 104]); // mouth grille
|
||||
fill(&mut serg_face, 10, 20, 5, 1, [70, 78, 104]);
|
||||
save("sergeant-face", &serg_face)?;
|
||||
|
||||
let mut vend_face = RgbaImage::new(24, 24);
|
||||
fill(&mut vend_face, 0, 0, 24, 24, [60, 18, 18]);
|
||||
fill(&mut vend_face, 3, 4, 18, 16, [180, 60, 60]); // faceplate
|
||||
fill(&mut vend_face, 6, 8, 4, 4, [255, 220, 140]); // lamp eyes
|
||||
fill(&mut vend_face, 14, 8, 4, 4, [255, 220, 140]);
|
||||
fill(&mut vend_face, 7, 15, 10, 2, [30, 30, 34]); // speaker mouth
|
||||
fill(&mut vend_face, 9, 2, 6, 2, [230, 200, 60]); // marquee light
|
||||
save("vendbot-face", &vend_face)?;
|
||||
|
||||
let mut fusebox = RgbaImage::new(14, 18);
|
||||
fill(&mut fusebox, 0, 0, 14, 18, [110, 114, 124]);
|
||||
fill(&mut fusebox, 2, 2, 10, 14, [80, 84, 96]);
|
||||
|
||||
@ -40,6 +40,9 @@ pub struct Dlg {
|
||||
pub node: usize,
|
||||
/// Who we're talking to (prefixes the says lines).
|
||||
pub with: String,
|
||||
/// Sprite name for the talker portrait ("" = no portrait) — the SCI1.1
|
||||
/// "talker" flourish; renderers draw it beside the window if present.
|
||||
pub portrait: String,
|
||||
}
|
||||
|
||||
impl Dlg {
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
//! [`Command`]s in and reads [`Event`]s out.
|
||||
|
||||
pub mod actor;
|
||||
pub mod ai;
|
||||
pub mod assets;
|
||||
pub mod audio;
|
||||
pub mod dialogue;
|
||||
|
||||
@ -77,6 +77,9 @@ pub struct PropDef {
|
||||
pub sets_flag: String,
|
||||
#[serde(default)]
|
||||
pub dialogue: Vec<DlgNode>, // node 0 = entry; empty = no conversation
|
||||
/// Sprite drawn beside this thing's dialogue window ("" = none).
|
||||
#[serde(default)]
|
||||
pub portrait: String,
|
||||
/// Points for taking it (takeable) or first successful use (otherwise).
|
||||
#[serde(default)]
|
||||
pub points: u32,
|
||||
@ -125,6 +128,9 @@ pub struct NpcDef {
|
||||
pub msgs: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub dialogue: Vec<DlgNode>,
|
||||
/// Sprite drawn beside this NPC's dialogue window ("" = none).
|
||||
#[serde(default)]
|
||||
pub portrait: String,
|
||||
/// Wander radius in pixels (0 = stands still).
|
||||
#[serde(default)]
|
||||
pub wander: i32,
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
//! Error 47 hard-crash class is a log line here.
|
||||
|
||||
use crate::actor::Actor;
|
||||
use crate::ai;
|
||||
use crate::audio::AudioCue;
|
||||
use crate::dialogue::Dlg;
|
||||
use crate::font;
|
||||
@ -210,6 +211,10 @@ pub struct GameState {
|
||||
checkpoint: Option<SaveData>,
|
||||
/// Presentation-only particle layer (rain/snow/embers).
|
||||
pub weather: Weather,
|
||||
/// The AI parser lane (off by env, off under --script).
|
||||
pub ai: ai::AiConfig,
|
||||
/// A translation in flight — at most one, polled each cycle.
|
||||
pending_ai: Option<std::sync::mpsc::Receiver<String>>,
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
@ -240,6 +245,8 @@ impl GameState {
|
||||
pending_verb: None,
|
||||
checkpoint: None,
|
||||
weather: Weather::new(0, 1),
|
||||
ai: ai::AiConfig::from_env(),
|
||||
pending_ai: None,
|
||||
};
|
||||
let _ = gs.enter_room(None);
|
||||
gs
|
||||
@ -290,7 +297,7 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
Command::Parse { text } => {
|
||||
ev.extend(self.run_parse(&text));
|
||||
ev.extend(self.run_parse(&text, false));
|
||||
}
|
||||
Command::Choose { n } => {
|
||||
if let Some(mut d) = self.dlg.take() {
|
||||
@ -455,6 +462,7 @@ impl GameState {
|
||||
self.score = 0;
|
||||
self.scored.clear();
|
||||
self.dlg = None;
|
||||
self.pending_ai = None;
|
||||
self.transcript.clear();
|
||||
self.flags = self.world.manifest.flags.clone();
|
||||
self.vars.clear();
|
||||
@ -647,6 +655,7 @@ impl GameState {
|
||||
let mut ev = Vec::new();
|
||||
self.ticks += 1;
|
||||
self.weather.step();
|
||||
ev.extend(self.poll_ai());
|
||||
|
||||
// Deterministic timers: count down in ticks, fire script functions.
|
||||
if !self.timers.is_empty() {
|
||||
@ -969,12 +978,12 @@ impl GameState {
|
||||
let m = self.cascade(&p.msgs, effective).unwrap();
|
||||
ev.push(self.line(&m));
|
||||
if verb == "talk" && !p.dialogue.is_empty() {
|
||||
ev.extend(self.open_dialogue(&p.name, p.dialogue.clone()));
|
||||
ev.extend(self.open_dialogue(&p.name, &p.portrait, p.dialogue.clone()));
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
"talk" if !p.dialogue.is_empty() => {
|
||||
ev.extend(self.open_dialogue(&p.name, p.dialogue.clone()));
|
||||
ev.extend(self.open_dialogue(&p.name, &p.portrait, p.dialogue.clone()));
|
||||
return ev;
|
||||
}
|
||||
_ => {}
|
||||
@ -1065,7 +1074,7 @@ impl GameState {
|
||||
if let Some(m) = self.cascade(&def.msgs, effective) {
|
||||
ev.push(self.line(&m));
|
||||
}
|
||||
ev.extend(self.open_dialogue(&def.name, def.dialogue.clone()));
|
||||
ev.extend(self.open_dialogue(&def.name, &def.portrait, def.dialogue.clone()));
|
||||
return ev;
|
||||
}
|
||||
let m = self
|
||||
@ -1095,9 +1104,9 @@ impl GameState {
|
||||
ev
|
||||
}
|
||||
|
||||
fn open_dialogue(&mut self, with: &str, nodes: Vec<crate::dialogue::DlgNode>) -> Vec<Event> {
|
||||
fn open_dialogue(&mut self, with: &str, portrait: &str, nodes: Vec<crate::dialogue::DlgNode>) -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
let d = Dlg { nodes, node: 0, with: with.to_string() };
|
||||
let d = Dlg { nodes, node: 0, with: with.to_string(), portrait: portrait.to_string() };
|
||||
let lines = d.lines(&self.flags);
|
||||
for l in &lines {
|
||||
ev.push(self.line(l));
|
||||
@ -1126,7 +1135,7 @@ impl GameState {
|
||||
|
||||
// --- the parser lane -----------------------------------------------------
|
||||
|
||||
fn run_parse(&mut self, text: &str) -> Vec<Event> {
|
||||
fn run_parse(&mut self, text: &str, from_ai: bool) -> Vec<Event> {
|
||||
let mut ev = Vec::new();
|
||||
let line = text.trim().to_string();
|
||||
if line.is_empty() {
|
||||
@ -1139,7 +1148,8 @@ impl GameState {
|
||||
ev.push(self.line("(type a number to answer, or end the conversation.)"));
|
||||
return ev;
|
||||
}
|
||||
ev.push(self.line(&format!("> {}", line)));
|
||||
let echo = if from_ai { format!("(\u{2248} {})", line) } else { format!("> {}", line) };
|
||||
ev.push(self.line(&echo));
|
||||
|
||||
match parser::parse(&line, &self.verb_idx) {
|
||||
Parsed::Empty => {}
|
||||
@ -1197,13 +1207,45 @@ impl GameState {
|
||||
}
|
||||
}
|
||||
Parsed::Unknown(orig) => {
|
||||
ev.push(self.line(&format!("The parser squints at \u{201C}{}\u{201D} and shrugs.", orig)));
|
||||
ev.push(Event::Audio { cue: AudioCue::Error });
|
||||
// The AI lane: hand the sentence to a small LLM that may map
|
||||
// it onto an already-legal command. One in flight at a time;
|
||||
// the AI's own output never re-enters the lane.
|
||||
if !from_ai && self.ai.enabled() && self.pending_ai.is_none() {
|
||||
let mut things = self.visible_things();
|
||||
things.extend(self.world.doc.hotspots.iter().map(|h| h.name.clone()).filter(|n| !n.is_empty()));
|
||||
let prompt = ai::build_prompt(
|
||||
&orig,
|
||||
&things.join(", "),
|
||||
&self.inventory.join(", "),
|
||||
&parser::verb_whitelist(&self.world.manifest.verbs).join(", "),
|
||||
);
|
||||
self.pending_ai = Some(ai::translate_async(self.ai.clone(), prompt));
|
||||
ev.push(self.line("\u{2026}(thinking)\u{2026}"));
|
||||
ev.push(Event::Audio { cue: AudioCue::Blip });
|
||||
} else {
|
||||
ev.push(self.line(&format!("The parser squints at \u{201C}{}\u{201D} and shrugs.", orig)));
|
||||
ev.push(Event::Audio { cue: AudioCue::Error });
|
||||
}
|
||||
}
|
||||
}
|
||||
ev
|
||||
}
|
||||
|
||||
/// Poll the AI worker; if a translation arrived, run it through the real
|
||||
/// parser (the AI can only ever *suggest* an already-legal command).
|
||||
fn poll_ai(&mut self) -> Vec<Event> {
|
||||
let got = self.pending_ai.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||
let Some(cmd) = got else { return Vec::new() };
|
||||
self.pending_ai = None;
|
||||
if cmd.is_empty() {
|
||||
let mut ev = vec![self.line("You can't do that.")];
|
||||
ev.push(Event::Audio { cue: AudioCue::Error });
|
||||
ev
|
||||
} else {
|
||||
self.run_parse(&cmd, true)
|
||||
}
|
||||
}
|
||||
|
||||
fn match_inventory(&self, words: &str) -> Option<String> {
|
||||
let w = words.to_lowercase();
|
||||
self.inventory
|
||||
@ -1513,16 +1555,39 @@ impl GameState {
|
||||
}
|
||||
|
||||
if let Some(d) = &self.dlg {
|
||||
// The classic Sierra window: white, red border, black text.
|
||||
// The classic Sierra window: white, red border, black text —
|
||||
// with the SCI1.1 talker portrait beside the prose when the
|
||||
// speaker has one.
|
||||
let portrait = if d.portrait.is_empty() { None } else { self.world.cel(&d.portrait).cloned() };
|
||||
let (pw, ph) = portrait.as_ref().map(|c| (c.w as i32 * 2, c.h as i32 * 2)).unwrap_or((0, 0));
|
||||
let text_x_off = if pw > 0 { pw + 10 } else { 0 };
|
||||
let wrap_w = (264 - text_x_off).max(80) as usize;
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for l in d.lines(&self.flags) {
|
||||
lines.extend(font::wrap(&l, 264));
|
||||
lines.extend(font::wrap(&l, wrap_w));
|
||||
}
|
||||
let h = lines.len() as i32 * 9 + 10;
|
||||
let h = (lines.len() as i32 * 9 + 10).max(ph + 12);
|
||||
let (x, y) = (24, 24);
|
||||
font::draw_box(&mut vis, x, y, 272, h, 15, 4);
|
||||
if let Some(cel) = portrait {
|
||||
// 2x nearest stamp, black frame around it.
|
||||
font::draw_box(&mut vis, x + 4, y + 4, pw + 4, ph + 4, 15, 0);
|
||||
for cy in 0..cel.h {
|
||||
for cx in 0..cel.w {
|
||||
if let Some(c) = cel.at(cx, cy) {
|
||||
for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] {
|
||||
let px = x + 6 + cx as i32 * 2 + dx;
|
||||
let py = y + 6 + cy as i32 * 2 + dy;
|
||||
if px >= 0 && py >= 0 && (px as usize) < PIC_W && (py as usize) < PIC_H {
|
||||
vis[py as usize * PIC_W + px as usize] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
font::draw_text(&mut vis, x + 6, y + 5 + i as i32 * 9, l, 0);
|
||||
font::draw_text(&mut vis, x + 6 + text_x_off, y + 5 + i as i32 * 9, l, 0);
|
||||
}
|
||||
} else {
|
||||
// Last transcript lines, bottom strip.
|
||||
|
||||
@ -453,12 +453,34 @@ fn draw_input(ui: &Ui) {
|
||||
}
|
||||
|
||||
fn draw_dialogue(lines: &[String], gs: &mut GameState, ui: &mut Ui, audio: &mut sound::LazyAudio) {
|
||||
// Talker portrait (SCI1.1 style): the speaker's face, chunky-pixeled,
|
||||
// beside the prose.
|
||||
let portrait = gs
|
||||
.dlg
|
||||
.as_ref()
|
||||
.filter(|d| !d.portrait.is_empty())
|
||||
.and_then(|d| gs.world.cel(&d.portrait).cloned());
|
||||
const PSCALE: f32 = 6.0;
|
||||
let pw = portrait.as_ref().map(|c| c.w as f32 * PSCALE + 28.0).unwrap_or(0.0);
|
||||
|
||||
let w = WIN_W as f32 - 240.0;
|
||||
let h = 60.0 + lines.len() as f32 * 26.0;
|
||||
let h = (60.0 + lines.len() as f32 * 26.0).max(portrait.as_ref().map(|c| c.h as f32 * PSCALE + 44.0).unwrap_or(0.0));
|
||||
let x = 120.0;
|
||||
let y = BAR_H + 80.0;
|
||||
draw_rectangle(x, y, w, h, Color::from_rgba(18, 20, 30, 242));
|
||||
draw_rectangle_lines(x, y, w, h, 3.0, Color::from_rgba(200, 170, 90, 255));
|
||||
if let Some(cel) = &portrait {
|
||||
let (px0, py0) = (x + 16.0, y + 22.0);
|
||||
draw_rectangle_lines(px0 - 3.0, py0 - 3.0, cel.w as f32 * PSCALE + 6.0, cel.h as f32 * PSCALE + 6.0, 2.0, Color::from_rgba(200, 170, 90, 255));
|
||||
for cy in 0..cel.h {
|
||||
for cx in 0..cel.w {
|
||||
if let Some(idx) = cel.at(cx, cy) {
|
||||
let [r, g, b, _] = gs.world.palette.rgba(idx);
|
||||
draw_rectangle(px0 + cx as f32 * PSCALE, py0 + cy as f32 * PSCALE, PSCALE, PSCALE, Color::from_rgba(r, g, b, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut clicked: Option<usize> = None;
|
||||
let (mx, my) = mouse_position();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
@ -466,7 +488,7 @@ fn draw_dialogue(lines: &[String], gs: &mut GameState, ui: &mut Ui, audio: &mut
|
||||
let is_choice = l.trim_start().chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false);
|
||||
let hover = is_choice && my > ly - 18.0 && my < ly + 6.0 && mx > x && mx < x + w;
|
||||
let col = if hover { Color::from_rgba(255, 230, 150, 255) } else { WHITE };
|
||||
draw_text(l, x + 20.0, ly, 22.0, col);
|
||||
draw_text(l, x + 20.0 + pw, ly, 22.0, col);
|
||||
if hover && is_mouse_button_pressed(MouseButton::Left) {
|
||||
clicked = l.trim_start().chars().next().and_then(|c| c.to_digit(10)).map(|d| d as usize);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user