Aabahran

Diff: GMCP

Comparing revision 600 (Erelei, ) to revision 687 (Erelei, )
1 1 # Forsaken Lands GMCP Specification
2 2
3 3 Client-side reference for what the server emits on the wire. Field names, types, emit timing, and escaping rules.
4 4
5 5 ## Contents
6 6
7 7 1. [Protocol basics](#protocol-basics)
8 8 2. [Handshake and client identification](#handshake-and-client-identification)
9 9 3. [Package reference](#package-reference)
10 10 - [Char.Vitals](#charvitals)
11 11 - [Char.Status](#charstatus)
12 12 - [Char.Worth](#charworth)
13 13 - [Char.Affects](#charaffects)
14 14 - [Char.Combat](#charcombat)
15 15 - [Room.Info](#roominfo)
16 16 - [Room.Chars](#roomchars)
17 17 - [Room.Items](#roomitems)
18 18 - [Map.Tiles](#maptiles)
19 19 - [Group.Info](#groupinfo)
20 20 - [World.Time](#worldtime)
21 21 - [World.Moons](#worldmoons)
22 22 - [Comm.Channel](#commchannel)
23 23 4. [Client to server messages](#client-to-server-messages)
24 24 5. [Scripting recipes](#scripting-recipes)
25 25 6. [Caveats and gotchas](#caveats-and-gotchas)
26 26
27 27 ---
28 28
29 29 ## Protocol basics
30 30
31 31 GMCP rides on TELNET option 201 (0xC9). Each message looks like:
32 32
33 33 ```
34 34 IAC SB 201 <package-name> <space> <json-payload> IAC SE
35 35 ```
36 36
37 37 Example:
38 38 ```
39 39 IAC SB 201 Char.Vitals {"hp":850,"maxhp":900,...} IAC SE
40 40 ```
41 41
42 42 Package names are dotted namespaces. Payloads are JSON (object, array, or primitive). The server emits actively. There is no subscription step beyond enabling the option.
43 43
44 44 ### Enabling GMCP
45 45
46 46 The server sends `IAC WILL 201` at connect. Your client replies `IAC DO 201`. Mudlet, Mudlet-Web, tintin++, Blightmud, and MUSHclient handle this automatically. The built-in web client always negotiates it.
47 47
48 48 There is no `Core.Supports.Set` step. Once GMCP is on, every package fires at its appropriate trigger. Drop the ones you don't care about on the client side.
49 49
50 50 ### Packet ordering
51 51
52 52 GMCP messages interleave with normal telnet output. A single `look` produces a burst:
53 53
54 54 ```
55 55 Room.Info {...}
56 56 Room.Chars [...]
57 57 Room.Items [...]
58 58 Map.Tiles {...}
59 59 <then the plain-text room description>
60 60 ```
61 61
62 62 Buffer packets by name and keep the latest value for each one. Older values are stale.
63 63
64 64 ### String escaping
65 65
66 66 Payloads are valid JSON (`"`, `\\`, `\n`, `\t`, etc.). Backtick color codes are stripped server-side before any string goes into a JSON field, so you get clean text. The reset `` `` ``, single-char codes like `` `6 ``, and 256-color forms `` `(255) `` and `` `[123] `` are all removed. Room names, character names, and channel text arrive pre-stripped.
67 67
68 68 ---
69 69
70 70 ## Handshake and client identification
71 71
72 72 ### Server to client
73 73
74 74 Sent right after telnet negotiation:
75 75 ```
76 76 IAC WILL 201 (server)
77 77 IAC DO 201 (client, your job)
78 78 ```
79 79
80 80 ### Client to server
81 81
82 82 Three optional packages the server watches for.
83 83
84 84 #### `Core.Hello`
85 85 Standard GMCP fingerprint. Send once, right after you accept the option.
86 86 ```json
87 87 Core.Hello {"client":"Mudlet","version":"4.17.2"}
88 88 ```
89 89 Fields: `client` (string), `version` (string). Stored server-side for stats. No functional consequence.
90 90
91 91 #### `Client.Fingerprint.Report`
92 92 Free-form JSON. Forsaken Lands accepts it and stores it per-connection. Skip it if you don't need it.
93 93
94 94 #### `Proxy.Ident`
95 95 Used only by internal web proxies to forward the real client IP. Accepted only from RFC1918/loopback source IPs. Public clients don't need to send it.
96 96
97 97 ---
98 98
99 99 ## Package reference
100 100
101 101 ### `Char.Vitals`
102 102
103 103 Drives HP, mana, and movement bars.
104 104
105 105 **Shape:**
106 106 ```json
107 107 Char.Vitals {"hp":850,"maxhp":900,"mana":760,"maxmana":820,"move":250,"maxmove":250}
108 108 ```
109 109
110 110 **Fields:**
111 111 | Field | Type | Notes |
112 112 |---|---|---|
113 113 | `hp` | int | current hit points |
114 114 | `maxhp` | int | max hit points with buffs |
115 115 | `mana` | int | current mana |
116 116 | `maxmana` | int | max mana with buffs |
117 117 | `move` | int | current movement |
118 118 | `maxmove` | int | max movement |
119 119
120 120 **Emitted on:** every prompt fire.
121 121
122 122 ---
123 123
124 124 ### `Char.Status`
125 125
126 126 Slow-changing identity fields.
127 127
128 128 **Shape:**
129 129 ```json
130 130 Char.Status {"name":"Gibble","level":50,"race":"gnome","class":"Psi"}
131 131 ```
132 132
133 133 **Fields:**
134 134 | Field | Type | Notes |
135 135 |---|---|---|
136 136 | `name` | string | character name |
137 137 | `level` | int | character level |
138 138 | `race` | string | race table name, lower-case short key |
139 139 | `class` | string | class table name (display casing preserved) |
140 140
141 141 **Emitted on:** login, level gain.
142 142
143 143 ---
144 144
145 145 ### `Char.Worth`
146 146
147 147 Currency and soft progression counters.
148 148
149 149 **Shape:**
150 150 ```json
151 151 Char.Worth {"gold":12345,"bank":99000,"exp":812400,"tnl":45085,"trains":2,"practices":0,"cps":1713,"rps":9,"cabal":"Knight"}
152 152 ```
153 153
154 154 **Fields:**
155 155 | Field | Type | Notes |
156 156 |---|---|---|
157 157 | `gold` | int | carried gold |
158 158 | `bank` | int | banked gold |
159 159 | `exp` | int | lifetime earned experience |
160 160 | `tnl` | int | experience remaining to next level (0 if capped) |
161 161 | `trains` | int | unspent training sessions |
162 162 | `practices` | int | unspent practice sessions |
163 163 | `cps` | int | cabal points |
164 164 | `rps` | int | roleplay points |
165 165 | `cabal` | string | current cabal name, or `"none"` if uncabaled |
166 166
167 167 **Emitted on:** every prompt and login.
168 168
169 169 ---
170 170
171 171 ### `Char.Affects`
172 172
173 173 Every active spell, skill, and song. Same list the in-game `affects` command shows.
174 174
175 175 **Shape:**
176 176 ```json
177 177 Char.Affects {"affects":[
178 178 {"kind":"spell","name":"bless","duration":6,"level":50,"location":"hitroll","modifier":4},
179 179 {"kind":"spell","name":"armor","duration":44,"level":50,"location":"ac","modifier":-20},
180 180 {"kind":"song","name":"bagatelle of bravado","duration":8,"level":50,"location":"damroll","modifier":3}
181 181 ]}
182 182 ```
183 183
184 184 **Fields per affect:**
185 185 | Field | Type | Notes |
186 186 |---|---|---|
187 187 | `kind` | string | `"spell"` or `"song"`. Group on this. Don't trust name alone (see below). |
188 188 | `name` | string | stripped of color codes |
189 189 | `duration` | int | ticks remaining (a tick is roughly 30 seconds to a minute of real time). `-1` means permanent. |
190 190 | `level` | int | level the affect was cast at |
191 191 | `location` | string | stat it modifies (`"hitroll"`, `"damroll"`, `"ac"`, `"str"`, `"mana"`, `"none"`, etc.) |
192 192 | `modifier` | int | magnitude (negative is better for AC, etc.) |
193 193
194 194 The payload is the full state, not a diff. Replace your local list each time.
195 195
196 196 **Emitted on:** login, every prompt, and any add/remove/expire.
197 197
198 198 **Why `kind` matters:** spells and songs use disjoint internal indexes. Without `kind`, a spell at sn 18 (control weather) and a song at index 18 collide on numeric ID. Always split on `kind` before rendering.
199 199
200 200 ---
201 201
202 202 ### `Char.Combat`
203 203
204 204 Set when fighting. Empty object when not.
205 205
206 206 **Shape (fighting):**
207 207 ```json
208 208 Char.Combat {"target":"Durim","condition":"quite a few wounds","hp_pct":54}
209 209 ```
210 210
211 211 **Shape (not fighting):**
212 212 ```json
213 213 Char.Combat {}
214 214 ```
215 215
216 216 **Fields:**
217 217 | Field | Type | Notes |
218 218 |---|---|---|
219 219 | `target` | string | name of the char you're fighting (stripped) |
220 220 | `condition` | string | flavor text: `"excellent"`, `"a few scratches"`, `"small wounds"`, `"quite a few wounds"`, `"big nasty wounds"`, `"pretty hurt"`, `"awful"`, `"bleeding to death"` |
221 221 | `hp_pct` | int | 0 to 100; clamps to 0 on death |
222 222
223 223 **Emitted on:** prompt. The empty object fires once when combat ends, so use it as the explicit "tear down combat HUD" signal.
224 224
225 225 ---
226 226
227 227 ### `Room.Info`
228 228
229 229 The room you're standing in.
230 230
231 231 **Shape:**
232 232 ```json
233 233 Room.Info {
234 234 "num": 16601,
235 235 "name": "The Academy Courtyard",
236 236 "area": "Val Miran",
237 237 "terrain": "city",
238 238 "exits": {"north":16600,"east":16602,"west":16603}
239 239 }
240 240 ```
241 241
242 242 **Fields:**
243 243 | Field | Type | Notes |
244 244 |---|---|---|
245 245 | `num` | int | room vnum |
246 246 | `name` | string | stripped of color codes |
247 247 | `area` | string | area name (zone) |
248 248 | `terrain` | string | one of: `inside`, `city`, `field`, `forest`, `hills`, `mountain`, `water_swim`, `water_noswim`, `swamp`, `air`, `desert`, `lava`, `snow` |
249 249 | `exits` | object | direction-name to destination room vnum. Includes `up`/`down` when present. |
250 250
251 251 **Emitted on:** every look at your current room (movement, force-look, etc).
252 252
253 253 ---
254 254
255 255 ### `Room.Chars`
256 256
257 257 Everyone else visible in your room.
258 258
259 259 **Shape:**
260 260 ```json
261 261 Room.Chars [
262 262 {"name":"Durim","npc":false},
263 263 {"name":"a city guard","npc":true}
264 264 ]
265 265 ```
266 266
267 267 **Fields per entry:**
268 268 | Field | Type | Notes |
269 269 |---|---|---|
270 270 | `name` | string | as you see them (handles invis/disguise/PERS()) |
271 271 | `npc` | bool | `true` for mobs, `false` for PCs |
272 272
273 273 You are omitted. People you can't see are omitted.
274 274
275 275 **Emitted on:** every look at the current room.
276 276
277 277 ---
278 278
279 279 ### `Room.Items`
280 280
281 281 Objects on the ground.
282 282
283 283 **Shape:**
284 284 ```json
285 285 Room.Items [
286 286 {"name":"a worn leather boot","type":"armor"},
287 287 {"name":"a pile of gold coins","type":"money"}
288 288 ]
289 289 ```
290 290
291 291 **Fields per entry:**
292 292 | Field | Type | Notes |
293 293 |---|---|---|
294 294 | `name` | string | short description, color-stripped |
295 295 | `type` | string | item type: `weapon`, `armor`, `container`, `food`, `potion`, `wand`, `scroll`, `staff`, `money`, `light`, `corpse_npc`, `corpse_pc`, etc. |
296 296
297 297 Items you can't see are omitted (e.g. invisible without detect-invis).
298 298
299 299 **Emitted on:** every look at the current room.
300 300
301 301 ---
302 302
303 303 ### `Map.Tiles`
304 304
305 305 BFS-generated minimap around the player. Lets clients render a tactical floor view plus arbitrary vertical depth.
306 306
307 307 **Top-level shape:**
308 308 ```json
309 309 Map.Tiles {
310 310 "r": 7,
311 311 "g": [ [ cell|null, cell|null, ... ], ... ],
312 312 "a": [ zlayer_room, ... ],
313 313 "b": [ zlayer_room, ... ],
314 314 "zr": [ zroom, ... ],
315 315 "areas": { "<area_vnum>": {"name":"…","color":"#rrggbb"}, … },
316 316 "t": " ... @e ...| ..."
317 317 }
318 318 ```
319 319
320 320 **Top-level fields:**
321 321 | Field | Type | Notes |
322 322 |---|---|---|
323 323 | `r` | int | radius of the BFS (4 small, 7 default, 10 large). Grid is `(2r+1) x (2r+1)`. |
324 324 | `g` | 2D array | rows by columns, current floor only. Positions outside the BFS reach are `null`. Player is always at `[r][r]`. |
325 325 | `a` | array | legacy: rooms reached via *up* exits, `\|z\|=1` only with a 1-cardinal sample. Stable for older clients. New clients can ignore. |
326 326 | `b` | array | legacy mirror of `a` for *down* exits. |
327 327 | `zr` | array | full multi-Z BFS results, off-floor cells only (`z != 0`). Each entry is the same per-cell payload as `g` plus explicit `x`/`y`/`z`. The cardinal-first BFS prefers same-floor paths on tie. A `zr` entry may share its `(x,y)` with a populated `g[y][x]` when a room sits directly above or below it (a stacked floor). The client decides whether to render off-floor cells as a separate layer, a toggle, or in-place. Arbitrary depth, capped only by the radius hop count. |
328 328 | `areas` | object | keyed by area vnum. Includes every zone present in `g` or `zr`. Value is `{name, color}`. `color` is a stable `#rrggbb` hashed from the vnum. |
329 329 | `t` | string | pre-rendered ASCII fallback grid (`|`-separated rows). `0-9` are sector digits, `a-c` are desert/lava/snow, `@` is you, space is empty. Current floor only. |
330 330
331 331 #### Per-cell (`g[y][x]`) shape
332 332
333 333 ```json
334 334 {
335 335 "s": 1,
336 336 "e": "neswud",
337 337 "l": 2,
338 338 "h": 1,
339 339 "ar": 30,
340 340 "f": "s$b",
341 341 "d": {"n":"locked","s":"closed"},
342 342 "ex": {"n":16600,"e":16602,"w":16603,"u":16800,"d":16900}
343 343 }
344 344 ```
345 345
346 346 | Field | Type | Notes |
347 347 |---|---|---|
348 348 | `s` | int | sector index (0-12, same order as `Room.Info` terrain enum). |
349 349 | `e` | string | exit letters from `n e s w u d`. Uppercase means the exit leads somewhere the map couldn't include (off-grid or teleport-style). |
350 350 | `l` | int | light level 0-4. `0` dark+night, `1` dark, `2` indoors, `3` outdoors+night, `4` outdoors+day. |
351 351 | `h` | int | present and `1` only on the cell you're standing in. Omitted everywhere else. |
352 352 | `ar` | int | area vnum. Cross-reference with top-level `areas` for name and color. |
353 353 | `f` | string | flag chars from `s` (safe/no-PK), `$` (shop), `b` (bank), `t` (trainer), `h` (healer). Omitted when empty. |
354 354 | `d` | object | door state dict. Present only when the room has at least one visible door. Keys are direction letters, values are `"open"`, `"closed"`, `"locked"`, or `"hidden"` (imms only). |
355 355 | `ex` | object | exit destinations as direction-letter to vnum. Omitted if no exits. |
356 356
357 357 Player-identity (`p`) per cell is intentionally not emitted. A passive PK radar over GMCP would erode the scan/where gameplay loop, so it was dropped. Use `scan`, `scan pk`, or `where` for nearby players. `Room.Chars` covers your current room.
358 358
359 359 #### Per-cell in `a` / `b` (legacy z-layers)
360 360
361 361 ```json
362 362 {"x":7,"y":7,"s":0,"e":"u","vnum":2051}
363 363 ```
364 364
365 365 Rooms reached by going up or down from a grid cell at `(x,y)`. Single-step (`|z|=1`) only and don't carry the extended cell fields (no `d`, `f`, `l`, etc.). Older clients still rely on this shape. Prefer `zr` for new code.
366 366
367 367 #### Per-room in `zr`
368 368
369 369 ```json
370 370 {
371 371 "x": 5, "y": 7, "z": -2,
372 372 "s": 0,
373 373 "e": "neud",
374 374 "l": 1,
375 375 "ar": 30,
376 376 "f": "s",
377 377 "d": {"u":"closed"},
378 378 "ex": {"n":3041,"e":3042,"u":3010,"d":3120}
379 379 }
380 380 ```
381 381
382 382 | Field | Type | Notes |
383 383 |---|---|---|
384 384 | `x`, `y` | int | grid coordinate, same space as `g[y][x]`. May coincide with a populated `g` cell when a room is stacked directly above or below. |
385 385 | `z` | int | floor delta from the player. Positive is above (`1` = one floor up), negative is below. Arbitrary depth, capped only by the radius. |
386 386 | `s`, `e`, `l`, `ar`, `f`, `d`, `ex` | as above | identical semantics to `g[y][x]`. `h` never appears in `zr` (player is always in `g`). |
387 387
388 388 #### Secret exits
389 389
390 390 Non-imms never see secret passages or closed-and-secret doors. Those exits are absent from `e`, `ex`, and `d`. Imms see them with `d` value `"hidden"`.
391 391
392 392 **Emitted on:** every look at the current room.
393 393
394 394 **Backwards compatibility:** the original minimal fields (`r`, `g[y][x].s`, `g[y][x].e`, `g[y][x].h`, `t`) are unchanged. Old clients that only read those keep working.
395 395
396 396 ---
397 397
398 398 ### `Group.Info`
399 399
400 400 Your group roster. Empty (`{}`) when solo.
401 401
402 402 **Shape (grouped):**
403 403 ```json
404 404 Group.Info {
405 405 "leader": "Durim",
406 406 "members": [
407 - {"name":"Durim","level":50,"class":"Dkn","hp_pct":78,"mana_pct":55,"move_pct":93,"tnl":1250},
408 - {"name":"Gibble","level":50,"class":"Psi","hp_pct":91,"mana_pct":40,"move_pct":88,"tnl":0}
407 + {"id":1769388810,"name":"Durim","level":50,"class":"Dkn","hp_pct":78,"mana_pct":55,"move_pct":93,"tnl":1250},
408 + {"id":1769401002,"name":"Gibble","level":50,"class":"Psi","hp_pct":91,"mana_pct":40,"move_pct":88,"tnl":0}
409 409 ]
410 410 }
411 411 ```
412 412
413 413 **Shape (solo):**
414 414 ```json
415 415 Group.Info {}
416 416 ```
417 417
418 418 **Top-level fields:**
419 - - `leader` (string): leader's visible name.
420 - - `members` (array): every member including you. Members with `NOWHO` are omitted.
419 + - `leader` (string): leader's name.
420 + - `members` (array): every member including you and any charmed followers. Members with `NOWHO` are omitted.
421 421
422 422 **Per-member fields:**
423 423 | Field | Type | Notes |
424 424 |---|---|---|
425 - | `name` | string | as you see them |
425 + | `id` | int | stable per-character identity. Key your roster on this, not `name`. Two members can share a `name` (e.g. identical summons); `id` never collides. |
426 + | `name` | string | member's real name (PCs) or short description (charmies). Not filtered by visibility, so blindness does not turn group members into `someone`. |
426 427 | `level` | int | 1 to 60 |
427 428 | `class` | string | short class key (e.g. `Dkn`, `Psi`, `War`) for PCs, `"mob"` for charmies |
428 429 | `hp_pct`, `mana_pct`, `move_pct` | int | 0 to 100 |
429 430 | `tnl` | int | exp to next level. `0` for NPCs and capped chars. |
430 431
431 432 **Emitted on:** prompt, login, group join/leave.
432 433
433 434 ---
434 435
435 436 ### `World.Time`
436 437
437 438 In-game clock and weather.
438 439
439 440 **Shape:**
440 441 ```json
441 442 World.Time {"hour":14,"day":12,"month":3,"year":2026,"sunlight":"light","sky":"cloudy"}
442 443 ```
443 444
444 445 **Fields:**
445 446 | Field | Type | Notes |
446 447 |---|---|---|
447 448 | `hour` | int | 0-23 game-time |
448 449 | `day` | int | day of month |
449 450 | `month` | int | 0-17 (see game calendar) |
450 451 | `year` | int | game year |
451 452 | `sunlight` | string | `"dark"`, `"rise"`, `"light"`, `"set"` |
452 453 | `sky` | string | `"cloudless"`, `"cloudy"`, `"raining"`, `"lightning"` |
453 454
454 455 **Emitted on:** login. Also broadcast to every GMCP-enabled connection on each weather tick (roughly every two minutes real time).
455 456
456 457 ---
457 458
458 459 ### `World.Moons`
459 460
460 461 Phase state for the three moons (Lysenties, Nercuros, Dyphrities). The MUD's astronomy intentionally exposes phase but not fullness or time-to-full, so client scripts can't time alignment-driven procs to the tick.
461 462
462 463 **Shape:**
463 464 ```json
464 465 World.Moons {
465 466 "moons": [
466 467 {"name":"Lysenties","active":true,"phase":4,"phase_name":"full"},
467 468 {"name":"Nercuros","active":true,"phase":2,"phase_name":"first half"},
468 469 {"name":"Dyphrities","active":false,"phase":0,"phase_name":"new"}
469 470 ],
470 471 "eclipse": false,
471 472 "triad": false,
472 473 "near_alignment": false
473 474 }
474 475 ```
475 476
476 477 **Top-level fields:**
477 478 | Field | Type | Notes |
478 479 |---|---|---|
479 480 | `moons` | array | one entry per moon, in fixed order Lysenties / Nercuros / Dyphrities. |
480 481 | `eclipse` | bool | true while a solar or lunar eclipse is active. All moon-driven scalars collapse to zero during eclipse. |
481 482 | `triad` | bool | true when all three moons are full simultaneously. |
482 483 | `near_alignment` | bool | true when phases are close enough to trigger near-alignment buffs/effects. |
483 484
484 485 **Per-moon fields:**
485 486 | Field | Type | Notes |
486 487 |---|---|---|
487 488 | `name` | string | display name (`"Lysenties"`, `"Nercuros"`, `"Dyphrities"`) |
488 489 | `active` | bool | false if the moon is currently dormant (rare; treat as inactive for any moon-derived effects). |
489 490 | `phase` | int | 0-7 phase index. `0` new, `1` waxing, `2` first half, `3` gibbous waxing, `4` full, `5` gibbous waning, `6` second half, `7` waning. |
490 491 | `phase_name` | string | display string for the phase. |
491 492
492 493 **Emitted on:** login. Also broadcast to every GMCP-enabled connection when any phase index flips, any `active` flag toggles, or any of the three world-state booleans changes. The broadcaster compares against a small static cache so re-firing on every weather tick when nothing changed costs nothing on the wire.
493 494
494 495 ---
495 496
496 497 ### `Comm.Channel`
497 498
498 499 One message per chat-like event you hear.
499 500
500 501 **Base shape (all channels):**
501 502 ```json
502 503 Comm.Channel {"channel":"say","speaker":"Durim","text":"Hello there."}
503 504 ```
504 505
505 506 **Extended shape (say, yell, tell, gtell add language and direction metadata):**
506 507 ```json
507 508 Comm.Channel {
508 509 "channel":"say",
509 510 "speaker":"Durim",
510 511 "text":"xak zee nog",
511 512 "language":"foreign",
512 513 "understood":false
513 514 }
514 515 ```
515 516 ```json
516 517 Comm.Channel {
517 518 "channel":"tell",
518 519 "speaker":"Gibble",
519 520 "text":"meet at the altar",
520 521 "language":"common",
521 522 "understood":true,
522 523 "direction":"received"
523 524 }
524 525 ```
525 526
526 527 **Base fields (always present):**
527 528 | Field | Type | Notes |
528 529 |---|---|---|
529 530 | `channel` | string | see table below |
530 531 | `speaker` | string | visible name. For `tell`, this is the sender (you only receive a `tell` packet as the recipient). |
531 532 | `text` | string | color-stripped. Garbled per-listener on language-aware channels. |
532 533
533 534 **Optional fields (say, yell, tell, gtell):**
534 535 | Field | Type | Notes |
535 536 |---|---|---|
536 537 | `language` | string | language name (`"common"`, `"drow"`, `"orcish"`, etc.) when the listener understood. `"foreign"` when they didn't. The actual foreign language never leaks. |
537 538 | `understood` | bool | `true` means `text` is the original. `false` means `text` is phoneme-garbled to match the terminal's foreign-tongue rendering. |
538 539 | `direction` | string | `"received"` on tell packets at the recipient. Outgoing tells do not echo to the sender's GMCP, so `"sent"` never appears in practice. |
539 540
540 541 **Channel values:**
541 542
542 543 | Value | Source | Language-aware | Direction field |
543 544 |---|---|---|---|
544 545 | `say` | `say` | yes | no |
545 546 | `yell` | `yell` | yes | no |
546 547 | `tell` | `tell`, `reply`, language-specific tells (`tarchaic`, `tmogwei`, etc.) | yes | yes (recipient side only) |
547 548 | `gtell` | `gtell` and group-tell projection | yes | no |
548 549 | `pray` | `pray` | no | no |
549 550 | `newbie` | `newbie` | no | no |
550 551 | `cabal` | `cabal chat` | no | no |
551 552 | `clan` | `clan chat` | no | no |
552 553 | `faction` | `faction chat` | no | no |
553 554 | `immortal` | `immtalk` (imms only) | no | no |
554 555 | `imp` | `imptalk` (IMP+ only) | no | no |
555 556
556 557 **Tell delivery:** only the recipient receives a `Comm.Channel`. The sender sees their own outgoing line in plain text but no GMCP echo. This was a deliberate change: when tells echoed both ways, naive triggers treated the echo as if the recipient had just sent a tell back. If your client needs a record of outgoing tells, parse them off the terminal line.
557 558
558 559 **Language semantics:**
559 560 - The speaker's own packet (for say, yell, gtell) is always `understood:true` with the actual language name.
560 561 - Each listener's packet is evaluated separately via the same `can_hear_lang` logic the terminal uses (skill % roll, `com_lan` affect, `IS_IMMORTAL`, illithid race, divine language).
561 562 - When `understood:false`, `text` is phoneme-garbled per the speaker's language to match the terminal output, and `language` is `"foreign"` rather than the real language name.
562 563
563 564 **Imm/imp chat:** broadcast to everyone with the right trust whose channel toggle is on. No language filtering. No `direction` field.
564 565
565 566 **Emitted on:** each matching channel message.
566 567
567 568 **RNG note:** language comprehension is a per-call dice roll for partial-skill listeners (skill 0 or 100 are deterministic). GMCP and terminal output evaluate independently, so a middle-skill listener can occasionally see them disagree on a single message. Treat the GMCP value as authoritative for client-side rendering.
568 569
569 570 ---
570 571
571 572 ## Client to server messages
572 573
573 574 Besides `Core.Hello`, `Client.Fingerprint.Report`, and `Proxy.Ident` (covered above), the server doesn't accept gameplay commands over GMCP. Drive input through the normal text command stream. GMCP is an output channel for state.
574 575
575 576 ---
576 577
577 578 ## Scripting recipes
578 579
579 580 ### Mudlet, HP/mana bars from Char.Vitals
580 581
581 582 ```lua
582 583 registerAnonymousEventHandler("gmcp.Char.Vitals", function()
583 584 local v = gmcp.Char.Vitals
584 585 hpBar:setValue(v.hp, v.maxhp)
585 586 manaBar:setValue(v.mana, v.maxmana)
586 587 moveBar:setValue(v.move, v.maxmove)
587 588 end)
588 589 ```
589 590
590 591 ### Mudlet, combat target widget
591 592
592 593 ```lua
593 594 registerAnonymousEventHandler("gmcp.Char.Combat", function()
594 595 local c = gmcp.Char.Combat
595 596 if not c or not c.target then
596 597 combatHUD:hide()
597 598 else
598 599 combatHUD:show()
599 600 combatName:echo(c.target)
600 601 combatBar:setValue(c.hp_pct, 100)
601 602 end
602 603 end)
603 604 ```
604 605
605 606 ### Mudlet, songs separate from spells
606 607
607 608 ```lua
608 609 registerAnonymousEventHandler("gmcp.Char.Affects", function()
609 610 local spells, songs = {}, {}
610 611 for _, a in ipairs(gmcp.Char.Affects.affects or {}) do
611 612 if a.kind == "song" then
612 613 table.insert(songs, a)
613 614 else
614 615 table.insert(spells, a)
615 616 end
616 617 end
617 618 renderSpells(spells)
618 619 renderSongs(songs)
619 620 end)
620 621 ```
621 622
622 623 ### Mudlet, minimap with zone tints
623 624
624 625 ```lua
625 626 registerAnonymousEventHandler("gmcp.Map.Tiles", function()
626 627 local m = gmcp.Map.Tiles
627 628 local r = m.r
628 629 for y = 1, #m.g do
629 630 for x = 1, #m.g[y] do
630 631 local cell = m.g[y][x]
631 632 if cell == vim.NIL then cell = nil end -- Mudlet uses vim.NIL for JSON null
632 633 if cell then
633 634 local tint = m.areas and m.areas[tostring(cell.ar)] and m.areas[tostring(cell.ar)].color or "#444"
634 635 drawCell(x, y, cell.s, cell.e, tint, cell.l, cell.f, cell.h)
635 636 else
636 637 drawCell(x, y, nil)
637 638 end
638 639 end
639 640 end
640 641 -- Off-floor rooms (towers, dungeons with stacked floors)
641 642 for _, z in ipairs(m.zr or {}) do
642 643 drawZRoom(z.x, z.y, z.z, z.s, z.e, z.ar)
643 644 end
644 645 end)
645 646 ```
646 647
647 648 ### Mudlet, World.Moons phase tracker
648 649
649 650 ```lua
650 651 registerAnonymousEventHandler("gmcp.World.Moons", function()
651 652 local m = gmcp.World.Moons
652 653 for _, moon in ipairs(m.moons) do
653 654 moonWidget[moon.name]:setPhase(moon.phase, moon.phase_name, moon.active)
654 655 end
655 656 eclipseIndicator:setVisible(m.eclipse)
656 657 triadIndicator:setVisible(m.triad)
657 658 end)
658 659 ```
659 660
660 661 ### tintin++, chat log split by channel
661 662
662 663 ```
663 664 #action {Comm.Channel %*} {
664 665 #var CHAN %1[channel];
665 666 #var WHO %1[speaker];
666 667 #var MSG %1[text];
667 668 #line log chat-$CHAN.txt <$WHO>: $MSG
668 669 }
669 670 ```
670 671
671 672 ### Generic, room-change trigger
672 673
673 674 `Room.Info` fires once per look. Stash `room.num` to detect movement:
674 675
675 676 ```
676 677 prevRoom = nil
677 678 onRoomInfo = function(info)
678 679 if info.num ~= prevRoom then
679 680 prevRoom = info.num
680 681 onEnterRoom(info)
681 682 end
682 683 end
683 684 ```
684 685
685 686 ---
686 687
687 688 ## Caveats and gotchas
688 689
689 690 ### Ordering
690 691 Prompt-driven packages (`Char.Vitals`, `Char.Worth`, `Char.Combat`, `Group.Info`) fire together every prompt. Don't assume cross-package atomicity. Treat each as independent state.
691 692
692 693 ### Stale packets
693 694 - `Char.Combat {}` is the explicit "combat ended" signal. Don't wait for a timeout.
694 695 - `Group.Info {}` is the explicit "solo" signal.
695 696 - `Char.Affects` always sends the full list. Replace, don't merge.
696 697
697 698 ### Map timing
698 699 `Map.Tiles` is the largest packet (up to about 80KB at radius 10 with the full payload). Debounce heavy redraw work. Don't redraw per pixel.
699 700
700 701 ### Size limits
701 702 - `Char.Affects`: up to 8KB. Truncates silently on overflow.
702 703 - `Map.Tiles`: up to 96KB.
703 704 - `Group.Info`, `Room.Chars`, `Room.Items`: up to 8KB.
704 705 - `Comm.Channel` text field: up to about 1KB after JSON-escape.
705 706
706 707 ### Invisibility
707 - `Room.Chars` and `Group.Info` apply the normal `can_see()` check. Your client only sees what your character sees.
708 + `Room.Chars` applies the normal `can_see()` check. Your client only sees what your character sees. `Group.Info` is the exception: it always names your own group members (you are grouped with them, so their identity is not hidden), so blindness does not collapse the roster to `someone`.
708 709
709 710 ### Color stripping
710 711 Every string field is passed through `json_escape_strip_color`. You get plain text with quotes and backslashes JSON-escaped. If you want colored names, reconstruct from class/race/title in your client CSS.
711 712
712 713 ### Secret exits
713 714 Non-imms: secret passages and closed-and-secret doors are absent from `Map.Tiles`. Imms see them with `d` value `"hidden"`.
714 715
715 716 ### Reconnection
716 717 After copyover/hotboot the server re-emits the login package set. Don't assume any package's content survives across disconnects.
717 718
718 719 ### When GMCP goes silent
719 720 If you stop receiving packets:
720 721 1. Confirm `IAC DO 201` was sent.
721 722 2. Confirm your terminal isn't stripping IAC sequences (some unix `tail` setups do).
722 723 3. `Core.Hello` isn't required, but sending it keeps server-side fingerprint and telemetry useful.
723 724
724 725 ---
725 726
726 727 ## Source of truth
727 728
728 729 Everything here is what FL actually emits. If a field disagrees with the wire, the code is right and the doc is stale. File an issue or update this doc directly.
729 730