home · contact · privacy
Replace client TURN line with frequently updated toilet need stat.
[plomrogue2] / rogue_chat.html
1 <!DOCTYPE html>
2 <html><head>
3 <style>
4   pre {
5       display: inline-block;
6   }
7   pre a {
8       color: white;
9   }
10 </style>
11 </head><body>
12 <div>
13 terminal rows: <input id="n_rows" type="number" step=4 min=24 value=24 />
14 / terminal columns: <input id="n_cols" type="number" step=4 min=80 value=80 />
15 / <a href="https://plomlompom.com/repos/?p=plomrogue2;a=summary">source code</a> (includes proper terminal/ncurses client)
16 </div>
17 <div style="position: relative; display: inline-block;">
18 <pre id="terminal"></pre>
19 <textarea id="input" style="position: absolute; left: 0; height: 100%; width: 100%; opacity: 0; z-index: -1;"></textarea>
20 </div>
21 <h3>button controls for hard-to-remember keybindings</h3>
22 <table id="move_table" style="float: left">
23   <tr>
24     <td style="text-align: right"><button id="hex_move_upleft"></button></td>
25     <td style="text-align: center"><button id="square_move_up"></button></td>
26     <td><button id="hex_move_upright"></button></td>
27   </tr>
28   <tr>
29     <td style="text-align: right;"><button id="square_move_left"></button><button id="hex_move_left">left</button></td>
30     <td stlye="text-align: center;">move</td>
31     <td><button id="square_move_right"></button><button id="hex_move_right"></button></td>
32   </tr>
33   <tr>
34     <td><button id="hex_move_downleft"></button></td>
35     <td style="text-align: center"><button id="square_move_down"></button></td>
36     <td><button id="hex_move_downright"></button></td>
37   </tr>
38 </table>
39 <table>
40   <tr>
41     <td><button id="help"></button></td>
42   </tr>
43   <tr>
44     <td><button id="switch_to_chat"></button><br /></td>
45   </tr>
46   <tr>
47     <td><button id="switch_to_study"></button></td>
48     <td><button id="toggle_map_mode"></button>
49   </tr>
50   <tr>
51     <td><button id="switch_to_play"></button></td>
52     <td>
53       <button id="switch_to_take_thing"></button>
54       <button id="switch_to_drop_thing"></button>
55       <button id="door"></button>
56       <button id="consume"></button>
57       <button id="switch_to_command_thing"></button>
58       <button id="teleport"></button>
59       <button id="wear"></button>
60       <button id="spin"></button>
61     </td>
62   </tr>
63   <tr>
64     <td><button id="switch_to_edit"></button></td>
65     <td>
66       <button id="switch_to_write"></button>
67       <button id="flatten"></button>
68       <button id="install"></button>
69       <button id="switch_to_annotate"></button>
70       <button id="switch_to_portal"></button>
71       <button id="switch_to_name_thing"></button>
72       <button id="switch_to_password"></button>
73       <button id="switch_to_enter_face"></button>
74       <button id="switch_to_enter_hat"></button>
75     </td>
76   </tr>
77   <tr>
78     <td><button id="switch_to_admin_enter"></button></td>
79     <td>
80       <button id="switch_to_control_pw_type"></button>
81       <button id="switch_to_control_tile_type"></button>
82       <button id="switch_to_admin_thing_protect"></button>
83       <button id="toggle_tile_draw"></button>
84     </td>
85   <tr>
86   </tr>
87 </table>
88 <h3>edit keybindings</h3> (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br />
89 <ul>
90 <li>move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)
91 <li>move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)
92 <li>move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)
93 <li>move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)
94 <li>move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" />
95 <li>move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" />
96 <li>move right (hex grid): <input id="key_hex_move_right" type="text" value="d" />
97 <li>move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" />
98 <li>move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" />
99 <li>move left (hex grid): <input id="key_hex_move_left" type="text" value="a" />
100 <li>help: <input id="key_help" type="text" value="h" />
101 <li>flatten surroundings: <input id="key_flatten" type="text" value="F" />
102 <li>teleport: <input id="key_teleport" type="text" value="p" />
103 <li>spin: <input id="key_spin" type="text" value="S" />
104 <li>open/close: <input id="key_door" type="text" value="D" />
105 <li>consume: <input id="key_consume" type="text" value="C" />
106 <li>install: <input id="key_install" type="text" value="I" />
107 <li>(un-)wear: <input id="key_wear" type="text" value="W" />
108 <li><input id="key_switch_to_drop_thing" type="text" value="u" />
109 <li><input id="key_switch_to_enter_face" type="text" value="f" />
110 <li><input id="key_switch_to_enter_hat" type="text" value="H" />
111 <li><input id="key_switch_to_take_thing" type="text" value="z" />
112 <li><input id="key_switch_to_chat" type="text" value="t" />
113 <li><input id="key_switch_to_play" type="text" value="p" />
114 <li><input id="key_switch_to_study" type="text" value="?" />
115 <li><input id="key_switch_to_edit" type="text" value="E" />
116 <li><input id="key_switch_to_write" type="text" value="m" />
117 <li><input id="key_switch_to_name_thing" type="text" value="N" />
118 <li><input id="key_switch_to_command_thing" type="text" value="O" />
119 <li><input id="key_switch_to_password" type="text" value="P" />
120 <li><input id="key_switch_to_admin_enter" type="text" value="A" />
121 <li><input id="key_switch_to_control_pw_type" type="text" value="C" />
122 <li><input id="key_switch_to_control_tile_type" type="text" value="Q" />
123 <li><input id="key_switch_to_admin_thing_protect" type="text" value="T" />
124 <li><input id="key_switch_to_annotate" type="text" value="M" />
125 <li><input id="key_switch_to_portal" type="text" value="T" />
126 <li>toggle map view: <input id="key_toggle_map_mode" type="text" value="L" />
127 <li>toggle protection character drawing: <input id="key_toggle_tile_draw" type="text" value="m" />
128 </ul>
129 </div>
130 <script>
131 "use strict";
132 //let websocket_location = "wss://plomlompom.com/rogue_chat/";
133 let websocket_location = "ws://localhost:8000/";
134
135 let mode_helps = {
136     'play': {
137         'short': 'play',
138         'intro': '',
139         'long': 'This mode allows you to interact with the map in various ways.'
140     },
141     'study': {
142         'short': 'study',
143         'intro': '',
144         'long': 'This mode allows you to study the map and its tiles in detail.  Move the question mark over a tile, and the right half of the screen will show detailed information on it.  Toggle the map view to show or hide different information layers.'},
145     'edit': {
146         'short': 'world edit',
147         'intro': '',
148         'long': 'This mode allows you to change the game world in various ways.  Individual map tiles can be protected by "protection characters", which you can see by toggling into the protections map view.  You can edit a tile if you set the world edit password that matches its protection character.  The character "." marks the absence of protection:  Such tiles can always be edited.'
149     },
150     'name_thing': {
151         'short': 'name thing',
152         'intro': '',
153         'long': 'Give name to/change name of carried thing.'
154     },
155     'command_thing': {
156         'short': 'command',
157         'intro': '',
158         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
159     },
160     'take_thing': {
161         'short': 'take',
162         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
163         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
164     },
165     'drop_thing': {
166         'short': 'drop',
167         'intro': 'Enter number of direction to which you want to drop thing.',
168         'long': 'Drop currently carried thing by entering the target direction index.  Enter nothing to return to play mode..'
169     },
170     'admin_thing_protect': {
171         'short': 'change thing protection',
172         'intro': '@ enter thing protection character:',
173         'long': 'Change protection character for carried thing.'
174     },
175     'enter_face': {
176         'short': 'edit face',
177         'intro': '@ enter face line (enter nothing to abort):',
178         'long': 'Draw your face as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom.  Eat cookies to extend the ASCII characters available for drawing.'
179     },
180     'enter_hat': {
181         'short': 'edit hat',
182         'intro': '@ enter hat line (enter nothing to abort):',
183         'long': 'Draw your hat as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom..'
184     },
185     'write': {
186         'short': 'edit tile',
187         'intro': '',
188         'long': 'This mode allows you to change the map tile you currently stand on (if your world editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.'
189     },
190     'control_pw_type': {
191         'short': 'change protection character password',
192         'intro': '@ enter protection character for which you want to change the password:',
193         'long': 'This mode is the first of two steps to change the password for a protection character.  First enter the protection character for which you want to change the password.'
194     },
195     'control_pw_pw': {
196         'short': 'change protection character password',
197         'intro': '',
198         'long': 'This mode is the second of two steps to change the password for a protection character.  Enter the new password for the protection character you chose.'
199     },
200     'control_tile_type': {
201         'short': 'change tiles protection',
202         'intro': '@ enter protection character which you want to draw:',
203         'long': 'This mode is the first of two steps to change tile protection areas on the map.  First enter the tile protection character you want to write.'
204     },
205     'control_tile_draw': {
206         'short': 'change tiles protection',
207         'intro': '',
208         'long': 'This mode is the second of two steps to change tile protection areas on the map.  Toggle tile protection drawing on/off and move the ?? cursor around the map to draw the selected protection character.'
209     },
210     'annotate': {
211         'short': 'annotate tile',
212         'intro': '',
213         'long': 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your world editing password authorizes you so).  Hit Return to leave.'
214     },
215     'portal': {
216         'short': 'edit portal',
217         'intro': '',
218         'long': 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your world editing password authorizes you so).  Enter or edit a URL to imprint a teleportation target; enter emptiness to remove a pre-existing teleportation target.  Hit Return to leave.'
219     },
220     'chat': {
221         'short': 'chat',
222         'intro': '',
223         'long': 'This mode allows you to engage in chit-chat with other users.  Any line you enter into the input prompt that does not start with a "/" will be sent out to nearby players – but barriers and distance will reduce what they can read, so stand close to them to ensure they get your message.  Lines that start with a "/" are used for commands like:\n\n/nick NAME – re-name yourself to NAME'
224     },
225     'login': {
226         'short': 'login',
227         'intro': '',
228         'long': 'Enter your player name.'
229     },
230     'waiting_for_server': {
231         'short': 'waiting for server response',
232         'intro': '@ waiting for server …',
233         'long': 'Waiting for a server response.'
234     },
235     'post_login_wait': {
236         'short': 'waiting for server response',
237         'intro': '',
238         'long': 'Waiting for a server response.'
239     },
240     'password': {
241         'short': 'set world edit password',
242         'intro': '',
243         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected elements of the world.  Hit return to confirm and leave.'
244     },
245     'admin_enter': {
246         'short': 'become admin',
247         'intro': '@ enter admin password:',
248         'long': 'This mode allows you to become admin if you know an admin password.'
249     },
250     'admin': {
251         'short': 'admin',
252         'intro': '',
253         'long': 'This mode allows you access to actions limited to administrators.'
254     }
255 }
256 let key_descriptions = {
257     'help': 'help',
258     'flatten': 'flatten surroundings',
259     'teleport': 'teleport',
260     'door': 'open/close',
261     'consume': 'consume',
262     'install': '(un-)install',
263     'wear': '(un-)wear',
264     'spin': 'spin',
265     'toggle_map_mode': 'toggle map view',
266     'toggle_tile_draw': 'toggle protection character drawing',
267     'hex_move_upleft': 'up-left',
268     'hex_move_upright': 'up-right',
269     'hex_move_right': 'right',
270     'hex_move_left': 'left',
271     'hex_move_downleft': 'down-left',
272     'hex_move_downright': 'down-right',
273     'square_move_up': 'up',
274     'square_move_left': 'left',
275     'square_move_down': 'down',
276     'square_move_right': 'right',
277 };
278 for (const mode_name of Object.keys(mode_helps)) {
279     key_descriptions['switch_to_' + mode_name] = mode_helps[mode_name].short;
280 };
281
282 let rows_selector = document.getElementById("n_rows");
283 let cols_selector = document.getElementById("n_cols");
284 let key_selectors = document.querySelectorAll('[id^="key_"]');
285
286 for (const key_switch_selector of document.querySelectorAll('[id^="key_switch_to_"]')) {
287     const action = key_switch_selector.id.slice("key_switch_to_".length);
288     key_switch_selector.parentNode.prepend(mode_helps[action].short + ': ');
289 }
290
291 function restore_selector_value(selector) {
292     let stored_selection = window.localStorage.getItem(selector.id);
293     if (stored_selection) {
294         selector.value = stored_selection;
295     }
296 }
297 restore_selector_value(rows_selector);
298 restore_selector_value(cols_selector);
299 for (let key_selector of key_selectors) {
300     restore_selector_value(key_selector);
301 }
302
303 function escapeHTML(str) {
304     return str.
305         replace(/&/g, '&amp;').
306         replace(/</g, '&lt;').
307         replace(/>/g, '&gt;').
308         replace(/'/g, '&apos;').
309         replace(/"/g, '&quot;');
310 };
311
312 let terminal = {
313   initialize: function() {
314     this.rows = rows_selector.value;
315     this.cols = cols_selector.value;
316     this.pre_el = document.getElementById("terminal");
317     this.set_default_colors();
318     this.apply_colors();
319     this.content = [];
320       let line = []
321     for (let y = 0, x = 0; y <= this.rows; x++) {
322         if (x == this.cols) {
323             x = 0;
324             y += 1;
325             this.content.push(line);
326             line = [];
327             if (y == this.rows) {
328                 break;
329             }
330         }
331         line.push(' ');
332     }
333   },
334   apply_colors: function() {
335     this.pre_el.style.color = this.foreground;
336     this.pre_el.style.backgroundColor = this.background;
337   },
338   set_default_colors: function() {
339       this.foreground = 'white';
340       this.background = 'black';
341       this.apply_colors();
342   },
343   set_random_colors: function() {
344       function rand(offset) {
345           return Math.floor(offset + Math.random() * 96).toString(16).padStart(2, '0');
346       }
347       this.foreground = '#' + rand(159) + rand(159) + rand(159);
348       this.background = '#' + rand(0) + rand(0) + rand(0);
349       this.apply_colors();
350   },
351   blink_screen: function() {
352       this.pre_el.style.color = this.background;
353       this.pre_el.style.backgroundColor = this.foreground;
354       setTimeout(() => {
355           this.pre_el.style.color = this.foreground;
356           this.pre_el.style.backgroundColor = this.background;
357       }, 100);
358   },
359   refresh: function() {
360       let pre_content = '';
361       for (let y = 0; y < this.rows; y++) {
362           let line = this.content[y].join('');
363           let chunks = [];
364           if (y in tui.links) {
365               let start_x = 0;
366               for (let span of tui.links[y]) {
367                   chunks.push(escapeHTML(line.slice(start_x, span[0])));
368                   chunks.push('<a target="_blank" href="');
369                   chunks.push(escapeHTML(span[2]));
370                   chunks.push('">');
371                   chunks.push(escapeHTML(line.slice(span[0], span[1])));
372                   chunks.push('</a>');
373                   start_x = span[1];
374               }
375               chunks.push(escapeHTML(line.slice(start_x)));
376           } else {
377               chunks = [escapeHTML(line)];
378           }
379           for (const chunk of chunks) {
380               pre_content += chunk;
381           }
382           pre_content += '\n';
383       }
384       this.pre_el.innerHTML = pre_content;
385   },
386   write: function(start_y, start_x, msg) {
387       for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
388           this.content[start_y][x] = msg[i];
389       }
390   },
391   drawBox: function(start_y, start_x, height, width) {
392     let end_y = start_y + height;
393     let end_x = start_x + width;
394     for (let y = start_y, x = start_x; y < this.rows; x++) {
395       if (x == end_x) {
396         x = start_x;
397         y += 1;
398         if (y == end_y) {
399             break;
400         }
401       };
402       this.content[y][x] = ' ';
403     }
404   },
405 }
406 terminal.initialize();
407
408 let parser = {
409   tokenize: function(str) {
410     let tokens = [];
411     let token = ''
412     let quoted = false;
413     let escaped = false;
414     for (let i = 0; i < str.length; i++) {
415       let c = str[i];
416       if (quoted) {
417         if (escaped) {
418           token += c;
419           escaped = false;
420         } else if (c == '\\') {
421           escaped = true;
422         } else if (c == '"') {
423           quoted = false
424         } else {
425           token += c;
426         }
427       } else if (c == '"') {
428         quoted = true
429       } else if (c === ' ') {
430         if (token.length > 0) {
431           tokens.push(token);
432           token = '';
433         }
434       } else {
435         token += c;
436       }
437     }
438     if (token.length > 0) {
439       tokens.push(token);
440     }
441     return tokens;
442   },
443   parse_yx: function(position_string) {
444     let coordinate_strings = position_string.split(',')
445     let position = [0, 0];
446     position[0] = parseInt(coordinate_strings[0].slice(2));
447     position[1] = parseInt(coordinate_strings[1].slice(2));
448     return position;
449   },
450 }
451
452 class Thing {
453     constructor(yx) {
454         this.position = yx;
455     }
456 }
457
458 let server = {
459     init: function(url) {
460         this.url = url;
461         this.websocket = new WebSocket(this.url);
462         this.websocket.onopen = function(event) {
463             game.thing_types = {};
464             game.terrains = {};
465             server.send(['TASKS']);
466             server.send(['TERRAINS']);
467             server.send(['THING_TYPES']);
468             tui.log_msg("@ server connected! :)");
469             tui.switch_mode('login');
470         };
471         this.websocket.onclose = function(event) {
472             tui.switch_mode('waiting_for_server');
473             tui.log_msg("@ server disconnected :(");
474         };
475             this.websocket.onmessage = this.handle_event;
476         },
477     reconnect_to: function(url) {
478         this.websocket.close();
479         this.init(url);
480     },
481     send: function(tokens) {
482         this.websocket.send(unparser.untokenize(tokens));
483     },
484     handle_event: function(event) {
485         let tokens = parser.tokenize(event.data);
486         if (tokens[0] === 'TURN') {
487             game.turn_complete = false;
488         } else if (tokens[0] === 'OTHER_WIPE') {
489             game.portals_new = {};
490             explorer.annotations_new = {};
491             game.things_new = [];
492         } else if (tokens[0] === 'BLADDER_PRESSURE') {
493             game.bladder_pressure_new = parseInt(tokens[1])
494         } else if (tokens[0] === 'THING') {
495             let t = game.get_thing_temp(tokens[4], true);
496             t.position = parser.parse_yx(tokens[1]);
497             t.type_ = tokens[2];
498             t.protection = tokens[3];
499             t.portable = parseInt(tokens[5]);
500             t.commandable = parseInt(tokens[6]);
501         } else if (tokens[0] === 'THING_NAME') {
502             let t = game.get_thing_temp(tokens[1]);
503             t.name_ = tokens[2];
504         } else if (tokens[0] === 'THING_FACE') {
505             let t = game.get_thing_temp(tokens[1]);
506             t.face = tokens[2];
507         } else if (tokens[0] === 'THING_HAT') {
508             let t = game.get_thing_temp(tokens[1]);
509             t.hat = tokens[2];
510         } else if (tokens[0] === 'THING_CHAR') {
511             let t = game.get_thing_temp(tokens[1]);
512             t.thing_char = tokens[2];
513         } else if (tokens[0] === 'TASKS') {
514             game.tasks = tokens[1].split(',');
515             tui.mode_write.legal = game.tasks.includes('WRITE');
516             tui.mode_command_thing.legal = game.tasks.includes('WRITE');
517             tui.mode_take_thing.legal = game.tasks.includes('PICK_UP');
518             tui.mode_drop_thing.legal = game.tasks.includes('DROP');
519         } else if (tokens[0] === 'THING_TYPE') {
520             game.thing_types[tokens[1]] = tokens[2]
521         } else if (tokens[0] === 'THING_CARRYING') {
522             let t = game.get_thing_temp(tokens[1]);
523             t.carrying = game.get_thing_temp(tokens[2], false);
524         } else if (tokens[0] === 'THING_INSTALLED') {
525             let t = game.get_thing_temp(tokens[1]);
526             t.installed = true;
527         } else if (tokens[0] === 'TERRAIN') {
528             game.terrains[tokens[1]] = tokens[2]
529         } else if (tokens[0] === 'MAP') {
530             game.map_geometry_new = tokens[1];
531             game.map_size_new = parser.parse_yx(tokens[2]);
532             game.map_new = tokens[3]
533         } else if (tokens[0] === 'FOV') {
534             game.fov_new = tokens[1]
535         } else if (tokens[0] === 'MAP_CONTROL') {
536             game.map_control_new = tokens[1]
537         } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
538             game.portals = game.portals_new;
539             game.map_geometry = game.map_geometry_new;
540             game.map_size = game.map_size_new;
541             game.map = game.map_new;
542             game.fov = game.fov_new;
543             tui.init_keys();
544             game.map_control = game.map_control_new;
545             explorer.annotations = explorer.annotations_new;
546             explorer.info_cached = false;
547             game.things = game.things_new;
548             game.player = game.things[game.player_id];
549             game.players_hat_chars = game.players_hat_chars_new;
550             game.bladder_pressure = game.bladder_pressure_new
551             game.turn_complete = true;
552             if (tui.mode.name == 'post_login_wait') {
553                 tui.switch_mode('play');
554             } else {
555                 tui.full_refresh();
556             }
557         } else if (tokens[0] === 'CHAT') {
558              tui.log_msg('# ' + tokens[1], 1);
559         } else if (tokens[0] === 'CHATFACE') {
560             tui.draw_face = tokens[1];
561             tui.full_refresh();
562         } else if (tokens[0] === 'REPLY') {
563              tui.log_msg('#MUSICPLAYER: ' + tokens[1], 1);
564         } else if (tokens[0] === 'PLAYER_ID') {
565             game.player_id = parseInt(tokens[1]);
566         } else if (tokens[0] === 'PLAYERS_HAT_CHARS') {
567             game.players_hat_chars_new = tokens[1];
568         } else if (tokens[0] === 'LOGIN_OK') {
569             this.send(['GET_GAMESTATE']);
570             tui.switch_mode('post_login_wait');
571         } else if (tokens[0] === 'DEFAULT_COLORS') {
572             terminal.set_default_colors();
573         } else if (tokens[0] === 'RANDOM_COLORS') {
574             terminal.set_random_colors();
575         } else if (tokens[0] === 'ADMIN_OK') {
576             tui.is_admin = true;
577             tui.log_msg('@ you now have admin rights');
578             tui.switch_mode('admin');
579         } else if (tokens[0] === 'PORTAL') {
580             let position = parser.parse_yx(tokens[1]);
581             game.portals_new[position] = tokens[2];
582         } else if (tokens[0] === 'ANNOTATION') {
583             let position = parser.parse_yx(tokens[1]);
584             explorer.annotations_new[position] = tokens[2];
585         } else if (tokens[0] === 'UNHANDLED_INPUT') {
586             tui.log_msg('? unknown command');
587         } else if (tokens[0] === 'PLAY_ERROR') {
588             tui.log_msg('? ' + tokens[1]);
589             terminal.blink_screen();
590         } else if (tokens[0] === 'ARGUMENT_ERROR') {
591             tui.log_msg('? syntax error: ' + tokens[1]);
592         } else if (tokens[0] === 'GAME_ERROR') {
593             tui.log_msg('? game error: ' + tokens[1]);
594         } else if (tokens[0] === 'PONG') {
595             ;
596         } else {
597             tui.log_msg('? unhandled input: ' + event.data);
598         }
599     }
600 }
601
602 let unparser = {
603     quote: function(str) {
604         let quoted = ['"'];
605         for (let i = 0; i < str.length; i++) {
606             let c = str[i];
607             if (['"', '\\'].includes(c)) {
608                 quoted.push('\\');
609             };
610             quoted.push(c);
611         }
612         quoted.push('"');
613         return quoted.join('');
614     },
615     to_yx: function(yx_coordinate) {
616         return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
617     },
618     untokenize: function(tokens) {
619         let quoted_tokens = [];
620         for (let token of tokens) {
621             quoted_tokens.push(this.quote(token));
622         }
623         return quoted_tokens.join(" ");
624     }
625 }
626
627 class Mode {
628     constructor(name, has_input_prompt=false, shows_info=false,
629                 is_intro=false, is_single_char_entry=false) {
630         this.name = name;
631         this.short_desc = mode_helps[name].short;
632         this.available_modes = [];
633         this.available_actions = [];
634         this.has_input_prompt = has_input_prompt;
635         this.shows_info= shows_info;
636         this.is_intro = is_intro;
637         this.help_intro = mode_helps[name].long;
638         this.intro_msg = mode_helps[name].intro;
639         this.is_single_char_entry = is_single_char_entry;
640         this.legal = true;
641     }
642     *iter_available_modes() {
643         for (let mode_name of this.available_modes) {
644             let mode = tui['mode_' + mode_name];
645             if (!mode.legal) {
646                 continue;
647             }
648             let key = tui.keys['switch_to_' + mode.name];
649             yield [mode, key]
650         }
651     }
652     list_available_modes() {
653         let msg = ''
654         if (this.available_modes.length > 0) {
655             msg += 'Other modes available from here:\n';
656             for (let [mode, key] of this.iter_available_modes()) {
657                 msg += '[' + key + '] – ' + mode.short_desc + '\n';
658             }
659         }
660         return msg;
661     }
662     mode_switch_on_key(key_event) {
663         for (let [mode, key] of this.iter_available_modes()) {
664             if (key_event.key == key) {
665                 event.preventDefault();
666                 tui.switch_mode(mode.name);
667                 return true;
668             };
669         }
670         return false;
671     }
672 }
673 let tui = {
674   links: {},
675   log: [],
676   input_prompt: '> ',
677   input_lines: [],
678   window_width: terminal.cols / 2,
679   height_turn_line: 1,
680   height_mode_line: 1,
681   height_input: 1,
682   password: 'foo',
683   show_help: false,
684   is_admin: false,
685   tile_draw: false,
686   mode_waiting_for_server: new Mode('waiting_for_server',
687                                      false, false, true),
688   mode_login: new Mode('login', true, false, true),
689   mode_post_login_wait: new Mode('post_login_wait'),
690   mode_chat: new Mode('chat', true),
691   mode_annotate: new Mode('annotate', true, true),
692   mode_play: new Mode('play'),
693   mode_study: new Mode('study', false, true),
694   mode_write: new Mode('write', false, false, false, true),
695   mode_edit: new Mode('edit'),
696   mode_control_pw_type: new Mode('control_pw_type', true),
697   mode_admin_thing_protect: new Mode('admin_thing_protect', true),
698   mode_portal: new Mode('portal', true, true),
699   mode_password: new Mode('password', true),
700   mode_name_thing: new Mode('name_thing', true, true),
701   mode_command_thing: new Mode('command_thing', true),
702   mode_take_thing: new Mode('take_thing', true),
703   mode_drop_thing: new Mode('drop_thing', true),
704   mode_enter_face: new Mode('enter_face', true),
705   mode_enter_hat: new Mode('enter_hat', true),
706   mode_admin_enter: new Mode('admin_enter', true),
707   mode_admin: new Mode('admin'),
708   mode_control_pw_pw: new Mode('control_pw_pw', true),
709   mode_control_tile_type: new Mode('control_tile_type', true),
710   mode_control_tile_draw: new Mode('control_tile_draw'),
711   action_tasks: {
712       'flatten': 'FLATTEN_SURROUNDINGS',
713       'take_thing': 'PICK_UP',
714       'drop_thing': 'DROP',
715       'move': 'MOVE',
716       'door': 'DOOR',
717       'install': 'INSTALL',
718       'wear': 'WEAR',
719       'command': 'COMMAND',
720       'consume': 'INTOXICATE',
721       'spin': 'SPIN',
722   },
723   offset: [0,0],
724   map_lines: [],
725   ascii_draw_stage: 0,
726   full_ascii_draw: '',
727   selectables: [],
728   draw_face: false,
729   init: function() {
730       this.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
731                                         "command_thing", "take_thing", "drop_thing"]
732       this.mode_play.available_actions = ["move", "teleport", "door", "consume",
733                                           "wear", "spin"];
734       this.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
735       this.mode_study.available_actions = ["toggle_map_mode", "move_explorer"];
736       this.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
737                                          "control_tile_type", "chat",
738                                          "study", "play", "edit"]
739       this.mode_admin.available_actions = ["move"];
740       this.mode_control_tile_draw.available_modes = ["admin_enter"]
741       this.mode_control_tile_draw.available_actions = ["toggle_tile_draw"];
742       this.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
743                                         "password", "chat", "study", "play",
744                                         "admin_enter", "enter_face", "enter_hat"]
745       this.mode_edit.available_actions = ["move", "flatten", "install",
746                                           "toggle_map_mode"]
747       this.inputEl = document.getElementById("input");
748       this.switch_mode('waiting_for_server');
749       this.recalc_input_lines();
750       this.height_header = this.height_turn_line + this.height_mode_line;
751       this.init_keys();
752   },
753   init_keys: function() {
754     document.getElementById("move_table").hidden = true;
755     this.keys = {};
756     for (let key_selector of key_selectors) {
757         this.keys[key_selector.id.slice(4)] = key_selector.value;
758     }
759     this.movement_keys = {};
760     let geometry_prefix = 'undefinedMapGeometry_';
761     if (game.map_geometry) {
762         geometry_prefix = game.map_geometry.toLowerCase() + '_';
763     }
764     for (const key_name of Object.keys(key_descriptions)) {
765         if (key_name.startsWith(geometry_prefix)) {
766             let direction = key_name.split('_')[2].toUpperCase();
767             let key = this.keys[key_name];
768             this.movement_keys[key] = direction;
769         }
770     };
771     for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
772         if (move_button.id.startsWith('key_')) {
773             continue;
774         }
775         move_button.hidden = true;
776     };
777     for (const move_button of document.querySelectorAll('[id^="' + geometry_prefix + 'move_"]')) {
778         document.getElementById("move_table").hidden = false;
779         move_button.hidden = false;
780     };
781     for (let el of document.getElementsByTagName("button")) {
782       let action_desc = key_descriptions[el.id];
783       let action_key = '[' + this.keys[el.id] + ']';
784       el.innerHTML = escapeHTML(action_desc) + '<br /><span class="keyboard_controlled">' + escapeHTML(action_key) + '</span>';
785     }
786   },
787   task_action_on: function(action) {
788       return game.tasks.includes(this.action_tasks[action]);
789   },
790   switch_mode: function(mode_name) {
791
792     function fail(msg, return_mode='play') {
793         tui.log_msg('? ' + msg);
794         terminal.blink_screen();
795         tui.switch_mode(return_mode);
796     }
797
798     if (this.mode && this.mode.name == 'control_tile_draw') {
799         tui.log_msg('@ finished tile protection drawing.')
800     }
801     this.draw_face = false;
802     this.tile_draw = false;
803     if (mode_name == 'command_thing' && (!game.player.carrying
804                                          || !game.player.carrying.commandable)) {
805         return fail('not carrying anything commandable');
806     } else if (mode_name == 'name_thing' && !game.player.carrying) {
807         return fail('not carrying anything to re-name');
808     } else if (mode_name == 'admin_thing_protect' && !game.player.carrying) {
809         return fail('not carrying anything to protect')
810     } else if (mode_name == 'take_thing' && game.player.carrying) {
811         return fail('already carrying something');
812     } else if (mode_name == 'drop_thing' && !game.player.carrying) {
813         return fail('not carrying anything droppable');
814     } else if (mode_name == 'enter_hat' && !game.player.hat) {
815         return fail('not wearing hat to edit', 'edit');
816     }
817     if (mode_name == 'admin_enter' && this.is_admin) {
818         mode_name = 'admin';
819     };
820     this.mode = this['mode_' + mode_name];
821     if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
822         this.map_mode = 'protections';
823     } else if (this.mode.name != "edit") {
824         this.map_mode = 'terrain + things';
825     };
826     if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
827         explorer.position = game.player.position;
828     }
829     this.inputEl.value = "";
830     this.restore_input_values();
831     for (let el of document.getElementsByTagName("button")) {
832         el.disabled = true;
833     }
834     document.getElementById("help").disabled = false;
835     for (const action of this.mode.available_actions) {
836         if (["move", "move_explorer"].includes(action)) {
837             for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
838                 move_key.disabled = false;
839             }
840         } else if (Object.keys(this.action_tasks).includes(action)) {
841             if (this.task_action_on(action)) {
842                 document.getElementById(action).disabled = false;
843             }
844         } else {
845             document.getElementById(action).disabled = false;
846         };
847     }
848     for (const mode_name of this.mode.available_modes) {
849             document.getElementById('switch_to_' + mode_name).disabled = false;
850     }
851     if (this.mode.intro_msg.length > 0) {
852         this.log_msg(this.mode.intro_msg);
853     }
854     if (this.mode.name == 'login') {
855         if (this.login_name) {
856             server.send(['LOGIN', this.login_name]);
857         } else {
858             this.log_msg("? need login name");
859         }
860     } else if (this.mode.is_single_char_entry) {
861         this.show_help = true;
862     } else if (this.mode.name == 'take_thing') {
863         this.log_msg("Portable things in reach for pick-up:");
864         const y = game.player.position[0]
865         const x = game.player.position[1]
866         let directed_moves = {
867             'HERE': [0, 0], 'LEFT': [0, -1], 'RIGHT': [0, 1]
868         }
869         if (game.map_geometry == 'Square') {
870             directed_moves['UP'] = [-1, 0];
871             directed_moves['DOWN'] = [1, 0];
872         } else if (game.map_geometry == 'Hex') {
873             if (y % 2) {
874                 directed_moves['UPLEFT'] = [-1, 0];
875                 directed_moves['UPRIGHT'] = [-1, 1];
876                 directed_moves['DOWNLEFT'] = [1, 0];
877                 directed_moves['DOWNRIGHT'] = [1, 1];
878             } else {
879                 directed_moves['UPLEFT'] = [-1, -1];
880                 directed_moves['UPRIGHT'] = [-1, 0];
881                 directed_moves['DOWNLEFT'] = [1, -1];
882                 directed_moves['DOWNRIGHT'] = [1, 0];
883             }
884         }
885         console.log(directed_moves);
886         let select_range = {};
887         for (const direction in directed_moves) {
888             const move = directed_moves[direction];
889             select_range[direction] = [y + move[0], x + move[1]];
890         }
891         this.selectables = [];
892         let directions = [];
893         for (const direction in select_range) {
894             for (const t_id in game.things) {
895                 const t = game.things[t_id];
896                 const position = select_range[direction];
897                 if (t.portable
898                     && t.position[0] == position[0]
899                     && t.position[1] == position[1]) {
900                     this.selectables.push(t_id);
901                     directions.push(direction);
902                 }
903             }
904         }
905         if (this.selectables.length == 0) {
906             this.log_msg('none');
907             terminal.blink_screen();
908             this.switch_mode('play');
909             return;
910         } else {
911             for (let [i, t_id] of this.selectables.entries()) {
912                 const t = game.things[t_id];
913                 const direction = directions[i];
914                 this.log_msg(i + ' ' + direction + ': ' + explorer.get_thing_info(t));
915             }
916         }
917     } else if (this.mode.name == 'drop_thing') {
918         this.log_msg('Direction to drop thing to:');
919         this.selectables = ['HERE'].concat(Object.values(this.movement_keys));
920         for (let [i, direction] of this.selectables.entries()) {
921             this.log_msg(i + ': ' + direction);
922         };
923     } else if (this.mode.name == 'enter_hat') {
924         this.log_msg('legal characters: ' + game.players_hat_chars);
925     } else if (this.mode.name == 'command_thing') {
926         server.send(['TASK:COMMAND', 'HELP']);
927     } else if (this.mode.name == 'control_pw_pw') {
928         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
929     } else if (this.mode.name == 'control_tile_draw') {
930         this.log_msg('@ can draw protection character "' + this.tile_control_char + '", turn drawing on/off with [' + this.keys.toggle_tile_draw + '], finish with [' +  this.keys.switch_to_admin_enter + '].')
931     }
932     this.full_refresh();
933   },
934   offset_links: function(offset, links) {
935       for (let y in links) {
936           let real_y = offset[0] + parseInt(y);
937           if (!this.links[real_y]) {
938               this.links[real_y] = [];
939           }
940           for (let link of links[y]) {
941               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
942               this.links[real_y].push(offset_link);
943           }
944       }
945   },
946   restore_input_values: function() {
947       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
948           let info = explorer.annotations[explorer.position];
949           if (info != "(none)") {
950               this.inputEl.value = info;
951           }
952       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
953           let portal = game.portals[explorer.position]
954           this.inputEl.value = portal;
955       } else if (this.mode.name == 'password') {
956           this.inputEl.value = this.password;
957       } else if (this.mode.name == 'name_thing') {
958           if (game.player.carrying && game.player.carrying.name_) {
959               this.inputEl.value = game.player.carrying.name_;
960           }
961       } else if (this.mode.name == 'admin_thing_protect') {
962           if (game.player.carrying && game.player.carrying.protection) {
963               this.inputEl.value = game.player.carrying.protection;
964           }
965       } else if (['enter_face', 'enter_hat'].includes(this.mode.name)) {
966           const start = this.ascii_draw_stage * 6;
967           const end = (this.ascii_draw_stage + 1) * 6;
968           if (this.mode.name == 'enter_face') {
969               this.inputEl.value = game.player.face.slice(start, end);
970           } else if (this.mode.name == 'enter_hat') {
971               this.inputEl.value = game.player.hat.slice(start, end);
972           }
973       }
974   },
975   recalc_input_lines: function() {
976       if (this.mode.has_input_prompt) {
977           let _ = null;
978           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value + '█', this.window_width);
979       } else {
980           this.input_lines = [];
981       }
982       this.height_input = this.input_lines.length;
983   },
984   msg_into_lines_of_width: function(msg, width) {
985       function push_inner_link(y, end_x) {
986           if (!inner_links[y]) {
987               inner_links[y] = [];
988           };
989           inner_links[y].push([url_start_x, end_x, url]);
990       };
991       let link_data = {};
992       let url_ends = [];
993       const regexp = RegExp('https?://[^\\s]+', 'g');
994       let match;
995       while ((match = regexp.exec(msg)) !== null) {
996           const url = match[0];
997           const url_start = match.index;
998           const url_end = match.index + match[0].length;
999           link_data[url_start] = url;
1000           url_ends.push(url_end);
1001       }
1002       let url_start_x = 0;
1003       let url = '';
1004       let inner_links = {};
1005       let in_link = false;
1006       let chunk = "";
1007       let lines = [];
1008       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
1009           if (x >= width || msg[i] == "\n") {
1010               if (in_link) {
1011                   push_inner_link(y, chunk.length);
1012                   url_start_x = 0;
1013                   if (url_ends[0] == i) {
1014                       in_link = false;
1015                       url_ends.shift();
1016                   }
1017               };
1018               lines.push(chunk);
1019               chunk = "";
1020               x = 0;
1021               if (msg[i] == "\n") {
1022                   x -= 1;
1023               };
1024               y += 1;
1025           };
1026           if (msg[i] != "\n") {
1027               chunk += msg[i];
1028           };
1029           if (i in link_data) {
1030               url_start_x = x;
1031               url = link_data[i];
1032               in_link = true;
1033           } else if (url_ends[0] == i) {
1034               url_ends.shift();
1035               push_inner_link(y, x);
1036               in_link = false;
1037           }
1038       }
1039       lines.push(chunk);
1040       if (in_link) {
1041           push_inner_link(lines.length - 1, chunk.length);
1042       }
1043       return [lines, inner_links];
1044   },
1045   log_msg: function(msg) {
1046       this.log.push(msg);
1047       while (this.log.length > 100) {
1048         this.log.shift();
1049       };
1050       this.full_refresh();
1051   },
1052   pick_selectable: function(task_name) {
1053       const i = parseInt(this.inputEl.value);
1054       if (isNaN(this.inputEl.value) || i < 0 || i >= this.selectables.length) {
1055           tui.log_msg('? invalid index, aborted');
1056       } else {
1057           server.send(['TASK:' + task_name, tui.selectables[i]]);
1058       }
1059       this.inputEl.value = "";
1060       this.switch_mode('play');
1061   },
1062   enter_ascii_art: function(command) {
1063       if (this.inputEl.value.length != 6) {
1064           this.log_msg('? wrong input length, must be 6; try again');
1065           return;
1066       }
1067       this.log_msg('  ' + this.inputEl.value);
1068       this.full_ascii_draw += this.inputEl.value;
1069       this.ascii_draw_stage += 1;
1070       if (this.ascii_draw_stage < 3) {
1071           this.restore_input_values();
1072       } else {
1073           server.send([command, this.full_ascii_draw]);
1074           this.full_ascii_draw = '';
1075           this.ascii_draw_stage = 0;
1076           this.inputEl.value = '';
1077           this.switch_mode('edit');
1078       }
1079   },
1080   draw_map: function() {
1081     if (!game.turn_complete && this.map_lines.length == 0) {
1082         return;
1083     }
1084     if (game.turn_complete) {
1085         let map_lines_split = [];
1086         let line = [];
1087         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
1088             if (j == game.map_size[1]) {
1089                 map_lines_split.push(line);
1090                 line = [];
1091                 j = 0;
1092             };
1093             if (this.map_mode == 'protections') {
1094                 line.push(game.map_control[i] + ' ');
1095             } else {
1096                 line.push(game.map[i] + ' ');
1097             }
1098         };
1099         map_lines_split.push(line);
1100         if (this.map_mode == 'terrain + annotations') {
1101             for (const [coordinate, _] of Object.entries(explorer.annotations)) {
1102                 const yx = coordinate.split(',')
1103                 map_lines_split[yx[0]][yx[1]] = 'A ';
1104             }
1105         } else if (this.map_mode == 'terrain + things') {
1106             for (const p in game.portals) {
1107                 let coordinate = p.split(',')
1108                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1109                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1110             }
1111             let used_positions = [];
1112             function draw_thing(t, used_positions) {
1113                 let symbol = game.thing_types[t.type_];
1114                 let meta_char = ' ';
1115                 if (t.thing_char) {
1116                     meta_char = t.thing_char;
1117                 }
1118                 if (used_positions.includes(t.position.toString())) {
1119                     meta_char = '+';
1120                 };
1121                 if (t.carrying) {
1122                     meta_char = '$';
1123                 }
1124                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1125                 used_positions.push(t.position.toString());
1126             }
1127             for (const thing_id in game.things) {
1128                 let t = game.things[thing_id];
1129                 if (t.type_ != 'Player') {
1130                     draw_thing(t, used_positions);
1131                 }
1132             };
1133             for (const thing_id in game.things) {
1134                 let t = game.things[thing_id];
1135                 if (t.type_ == 'Player') {
1136                     draw_thing(t, used_positions);
1137                 }
1138             };
1139         }
1140         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1141             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1142         } else if (tui.map_mode != 'terrain + things') {
1143             map_lines_split[game.player.position[0]][game.player.position[1]] = '??';
1144         }
1145         this.map_lines = []
1146         if (game.map_geometry == 'Square') {
1147             for (let line_split of map_lines_split) {
1148                 this.map_lines.push(line_split.join(''));
1149             };
1150         } else if (game.map_geometry == 'Hex') {
1151             let indent = 0
1152             for (let line_split of map_lines_split) {
1153                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1154                 if (indent == 0) {
1155                     indent = 1;
1156                 } else {
1157                     indent = 0;
1158                 };
1159             };
1160         }
1161         let window_center = [terminal.rows / 2, this.window_width / 2];
1162         let center_position = [game.player.position[0], game.player.position[1]];
1163         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1164             center_position = [explorer.position[0], explorer.position[1]];
1165         }
1166         center_position[1] = center_position[1] * 2;
1167         this.offset = [center_position[0] - window_center[0],
1168                        center_position[1] - window_center[1]]
1169         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1170             this.offset[1] += 1;
1171         };
1172     };
1173     let term_y = Math.max(0, -this.offset[0]);
1174     let term_x = Math.max(0, -this.offset[1]);
1175     let map_y = Math.max(0, this.offset[0]);
1176     let map_x = Math.max(0, this.offset[1]);
1177     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1178         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1179         terminal.write(term_y, term_x, to_draw);
1180     }
1181   },
1182   draw_face_popup: function() {
1183       const t = game.things[this.draw_face];
1184       if (!t || !t.face) {
1185           this.draw_face = false;
1186           return;
1187       }
1188       const start_x = tui.window_width - 10;
1189       let t_char = ' ';
1190       if (t.thing_char) {
1191           t_char = t.thing_char;
1192       }
1193       function draw_body_part(body_part, end_y) {
1194           terminal.write(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ');
1195           terminal.write(end_y - 3, start_x, '|        |');
1196           terminal.write(end_y - 2, start_x, '| ' + body_part.slice(0, 6) + ' |');
1197           terminal.write(end_y - 1, start_x, '| ' + body_part.slice(6, 12) + ' |');
1198           terminal.write(end_y, start_x, '| ' + body_part.slice(12, 18) + ' |');
1199       }
1200       if (t.face) {
1201           draw_body_part(t.face, terminal.rows - 2);
1202       }
1203       if (t.hat) {
1204           draw_body_part(t.hat, terminal.rows - 5);
1205       }
1206       terminal.write(terminal.rows - 1, start_x, '|        |');
1207   },
1208   draw_mode_line: function() {
1209       let help = 'hit [' + this.keys.help + '] for help';
1210       if (this.mode.has_input_prompt) {
1211           help = 'enter /help for help';
1212       }
1213       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1214   },
1215   draw_stats_line: function(n) {
1216       terminal.write(1, this.window_width, 'BLADDER: ' + game.bladder_pressure);
1217   },
1218   draw_history: function() {
1219       let log_display_lines = [];
1220       let log_links = {};
1221       let y_offset_in_log = 0;
1222       for (let line of this.log) {
1223           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1224                                                                     this.window_width)
1225           log_display_lines = log_display_lines.concat(new_lines);
1226           for (const y in link_data) {
1227               const rel_y = y_offset_in_log + parseInt(y);
1228               log_links[rel_y] = [];
1229               for (let link of link_data[y]) {
1230                   log_links[rel_y].push(link);
1231               }
1232           }
1233           y_offset_in_log += new_lines.length;
1234       };
1235       let i = log_display_lines.length - 1;
1236       for (let y = terminal.rows - 1 - this.height_input;
1237            y >= this.height_header && i >= 0;
1238            y--, i--) {
1239           terminal.write(y, this.window_width, log_display_lines[i]);
1240       }
1241       for (const key of Object.keys(log_links)) {
1242           if (parseInt(key) <= i) {
1243               delete log_links[key];
1244           }
1245       }
1246       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1247                     this.window_width];
1248       this.offset_links(offset, log_links);
1249   },
1250   draw_info: function() {
1251       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1252       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1253       let offset = [this.height_header, this.window_width];
1254       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1255         terminal.write(y, offset[1], lines[i]);
1256       }
1257       this.offset_links(offset, link_data);
1258   },
1259   draw_input: function() {
1260     if (this.mode.has_input_prompt) {
1261         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1262             terminal.write(y, this.window_width, this.input_lines[i]);
1263         }
1264     }
1265   },
1266   draw_help: function() {
1267       let movement_keys_desc = '';
1268       if (!this.mode.is_intro) {
1269           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1270       }
1271       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1272       if (this.mode.available_actions.length > 0) {
1273           content += "Available actions:\n";
1274           for (let action of this.mode.available_actions) {
1275               if (Object.keys(this.action_tasks).includes(action)) {
1276                   if (!this.task_action_on(action)) {
1277                       continue;
1278                   }
1279               }
1280               if (action == 'move_explorer') {
1281                   action = 'move';
1282               }
1283               if (action == 'move') {
1284                   content += "[" + movement_keys_desc + "] – move\n"
1285               } else {
1286                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1287               }
1288           }
1289           content += '\n';
1290       }
1291       content += this.mode.list_available_modes();
1292       let start_x = 0;
1293       if (!this.mode.has_input_prompt) {
1294           start_x = this.window_width;
1295           this.draw_links = false;
1296       }
1297       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1298       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1299       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1300           terminal.write(y, start_x, lines[i]);
1301       }
1302   },
1303   toggle_tile_draw: function() {
1304       if (tui.tile_draw) {
1305           tui.tile_draw = false;
1306       } else {
1307           tui.tile_draw = true;
1308       }
1309   },
1310   toggle_map_mode: function() {
1311       if (tui.map_mode == 'terrain only') {
1312           tui.map_mode = 'terrain + annotations';
1313       } else if (tui.map_mode == 'terrain + annotations') {
1314           tui.map_mode = 'terrain + things';
1315       } else if (tui.map_mode == 'terrain + things') {
1316           tui.map_mode = 'protections';
1317       } else if (tui.map_mode == 'protections') {
1318           tui.map_mode = 'terrain only';
1319       }
1320   },
1321   full_refresh: function() {
1322     this.draw_links = true;
1323     this.links = {};
1324     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1325     this.recalc_input_lines();
1326     if (this.mode.is_intro) {
1327         this.draw_history();
1328         this.draw_input();
1329     } else {
1330         this.draw_map();
1331         this.draw_stats_line();
1332         this.draw_mode_line();
1333         if (this.mode.shows_info) {
1334           this.draw_info();
1335         } else {
1336           this.draw_history();
1337         }
1338         this.draw_input();
1339     }
1340     if (this.show_help) {
1341         this.draw_help();
1342     }
1343     if (this.draw_face && ['chat', 'play'].includes(this.mode.name)) {
1344         this.draw_face_popup();
1345     }
1346     if (!this.draw_links) {
1347         this.links = {};
1348     }
1349     terminal.refresh();
1350   }
1351 }
1352
1353 let game = {
1354     init: function() {
1355         this.turn = -1;
1356         this.player_id = -1;
1357         this.tasks = {};
1358         this.things = {};
1359         this.things_new = {};
1360         this.fov = "";
1361         this.fov_new = "";
1362         this.map = "";
1363         this.map_new = "";
1364         this.map_control = "";
1365         this.map_control_new = "";
1366         this.map_size = [0,0];
1367         this.map_size_new = [0,0];
1368         this.portals = {};
1369         this.portals_new = {};
1370         this.players_hat_chars = "";
1371         this.bladder_pressure = 0;
1372         this.bladder_pressure_new = 0;
1373     },
1374     get_thing_temp: function(id_, create_if_not_found=false) {
1375         if (id_ in game.things_new) {
1376             return game.things_new[id_];
1377         } else if (create_if_not_found) {
1378             let t = new Thing([0,0]);
1379             game.things_new[id_] = t;
1380             return t;
1381         };
1382     },
1383     get_thing: function(id_, create_if_not_found=false) {
1384         if (id_ in game.things) {
1385             return game.things[id_];
1386         };
1387     },
1388     move: function(start_position, direction) {
1389         let target = [start_position[0], start_position[1]];
1390         if (direction == 'LEFT') {
1391             target[1] -= 1;
1392         } else if (direction == 'RIGHT') {
1393             target[1] += 1;
1394         } else if (game.map_geometry == 'Square') {
1395             if (direction == 'UP') {
1396                 target[0] -= 1;
1397             } else if (direction == 'DOWN') {
1398                 target[0] += 1;
1399             };
1400         } else if (game.map_geometry == 'Hex') {
1401             let start_indented = start_position[0] % 2;
1402             if (direction == 'UPLEFT') {
1403                 target[0] -= 1;
1404                 if (!start_indented) {
1405                     target[1] -= 1;
1406                 }
1407             } else if (direction == 'UPRIGHT') {
1408                 target[0] -= 1;
1409                 if (start_indented) {
1410                     target[1] += 1;
1411                 }
1412             } else if (direction == 'DOWNLEFT') {
1413                 target[0] += 1;
1414                 if (!start_indented) {
1415                     target[1] -= 1;
1416                 }
1417             } else if (direction == 'DOWNRIGHT') {
1418                 target[0] += 1;
1419                 if (start_indented) {
1420                     target[1] += 1;
1421                 }
1422             };
1423         };
1424         if (target[0] < 0 || target[1] < 0 ||
1425             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1426             return null;
1427         };
1428         return target;
1429     },
1430     teleport: function() {
1431         if (game.player.position in this.portals) {
1432             server.reconnect_to(this.portals[game.player.position]);
1433         } else {
1434             terminal.blink_screen();
1435             tui.log_msg('? not standing on portal')
1436         }
1437     }
1438 }
1439
1440 game.init();
1441 tui.init();
1442 tui.full_refresh();
1443 server.init(websocket_location);
1444
1445 let explorer = {
1446     position: [0,0],
1447     annotations: {},
1448     annotations_new: {},
1449     info_cached: false,
1450     move: function(direction) {
1451         let target = game.move(this.position, direction);
1452         if (target) {
1453             this.position = target
1454             this.info_cached = false;
1455             if (tui.tile_draw) {
1456                 this.send_tile_control_command();
1457             }
1458         } else {
1459             terminal.blink_screen();
1460         };
1461     },
1462     get_info: function() {
1463         if (this.info_cached) {
1464             return this.info_cached;
1465         }
1466         let info_to_cache = '';
1467         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1468         if (game.fov[position_i] != '.') {
1469             info_to_cache += 'outside field of view';
1470         } else {
1471             for (let t_id in game.things) {
1472                  let t = game.things[t_id];
1473                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1474                      info_to_cache += "THING: " + this.get_thing_info(t);
1475                      let protection = t.protection;
1476                      if (protection == '.') {
1477                          protection = 'none';
1478                      }
1479                      info_to_cache += " / protection: " + protection + "\n";
1480                      if (t.hat) {
1481                          info_to_cache += t.hat.slice(0, 6) + '\n';
1482                          info_to_cache += t.hat.slice(6, 12) + '\n';
1483                          info_to_cache += t.hat.slice(12, 18) + '\n';
1484                      }
1485                      if (t.face) {
1486                          info_to_cache += t.face.slice(0, 6) + '\n';
1487                          info_to_cache += t.face.slice(6, 12) + '\n';
1488                          info_to_cache += t.face.slice(12, 18) + '\n';
1489                      }
1490                  }
1491             }
1492             let terrain_char = game.map[position_i]
1493             let terrain_desc = '?'
1494             if (game.terrains[terrain_char]) {
1495                 terrain_desc = game.terrains[terrain_char];
1496             };
1497             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1498             let protection = game.map_control[position_i];
1499             if (protection == '.') {
1500                 protection = 'unprotected';
1501             };
1502             info_to_cache += 'PROTECTION: ' + protection + '\n';
1503             if (this.position in game.portals) {
1504                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1505             }
1506             if (this.position in this.annotations) {
1507                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1508             }
1509         }
1510         this.info_cached = info_to_cache;
1511         return this.info_cached;
1512     },
1513     get_thing_info: function(t) {
1514         const symbol = game.thing_types[t.type_];
1515         let info = t.type_ + " / " + symbol;
1516         if (t.thing_char) {
1517             info += t.thing_char;
1518         };
1519         if (t.name_) {
1520             info += " (" + t.name_ + ")";
1521         }
1522         if (t.installed) {
1523             info += " / installed";
1524         }
1525         return info;
1526     },
1527     annotate: function(msg) {
1528         if (msg.length == 0) {
1529             msg = " ";  // triggers annotation deletion
1530         }
1531         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1532     },
1533     set_portal: function(msg) {
1534         if (msg.length == 0) {
1535             msg = " ";  // triggers portal deletion
1536         }
1537         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1538     },
1539     send_tile_control_command: function() {
1540         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1541     }
1542 }
1543
1544 tui.inputEl.addEventListener('input', (event) => {
1545     if (tui.mode.has_input_prompt) {
1546         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1547         if (tui.inputEl.value.length > max_length) {
1548             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1549         };
1550     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1551         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1552         tui.switch_mode('edit');
1553     }
1554     tui.full_refresh();
1555 }, false);
1556 document.onclick = function() {
1557     if (!tui.mode.is_single_char_entry) {
1558         tui.show_help = false;
1559     }
1560 };
1561 tui.inputEl.addEventListener('keydown', (event) => {
1562     tui.show_help = false;
1563     if (['Enter', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
1564         event.preventDefault();
1565     }
1566     if ((!tui.mode.is_intro && event.key == 'Escape')
1567         || (tui.mode.has_input_prompt && event.key == 'Enter'
1568             && tui.inputEl.value.length == 0
1569             && ['chat', 'command_thing', 'take_thing', 'drop_thing',
1570                 'admin_enter'].includes(tui.mode.name))) {
1571         if (!['chat', 'play', 'study', 'edit'].includes(tui.mode.name)) {
1572             tui.log_msg('@ aborted');
1573         }
1574         tui.switch_mode('play');
1575     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1576         tui.show_help = true;
1577         tui.inputEl.value = "";
1578         tui.restore_input_values();
1579     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1580                && !tui.mode.is_single_char_entry) {
1581         tui.show_help = true;
1582     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1583         tui.login_name = tui.inputEl.value;
1584         server.send(['LOGIN', tui.inputEl.value]);
1585         tui.inputEl.value = "";
1586     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1587         tui.enter_ascii_art('PLAYER_FACE');
1588     } else if (tui.mode.name == 'enter_hat' && event.key == 'Enter') {
1589         tui.enter_ascii_art('PLAYER_HAT');
1590     } else if (tui.mode.name == 'command_thing' && event.key == 'Enter') {
1591         server.send(['TASK:COMMAND', tui.inputEl.value]);
1592         tui.inputEl.value = "";
1593     } else if (tui.mode.name == 'take_thing' && event.key == 'Enter') {
1594         tui.pick_selectable('PICK_UP');
1595     } else if (tui.mode.name == 'drop_thing' && event.key == 'Enter') {
1596         tui.pick_selectable('DROP');
1597     } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1598         if (tui.inputEl.value.length == 0) {
1599             tui.log_msg('@ aborted');
1600         } else {
1601             server.send(['SET_MAP_CONTROL_PASSWORD',
1602                         tui.tile_control_char, tui.inputEl.value]);
1603             tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1604         }
1605         tui.switch_mode('admin');
1606     } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1607         explorer.set_portal(tui.inputEl.value);
1608         tui.switch_mode('edit');
1609     } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1610         if (tui.inputEl.value.length == 0) {
1611             tui.inputEl.value = " ";
1612         }
1613         server.send(["THING_NAME", tui.inputEl.value, tui.password]);
1614         tui.switch_mode('edit');
1615     } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1616         explorer.annotate(tui.inputEl.value);
1617         tui.switch_mode('edit');
1618     } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1619         if (tui.inputEl.value.length == 0) {
1620             tui.inputEl.value = " ";
1621         }
1622         tui.password = tui.inputEl.value
1623         tui.switch_mode('edit');
1624     } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1625         server.send(['BECOME_ADMIN', tui.inputEl.value]);
1626         tui.switch_mode('play');
1627     } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1628         if (tui.inputEl.value.length != 1) {
1629             tui.log_msg('@ entered non-single-char, therefore aborted');
1630             tui.switch_mode('admin');
1631         } else {
1632             tui.tile_control_char = tui.inputEl.value[0];
1633             tui.switch_mode('control_pw_pw');
1634         }
1635     } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1636         if (tui.inputEl.value.length != 1) {
1637             tui.log_msg('@ entered non-single-char, therefore aborted');
1638             tui.switch_mode('admin');
1639         } else {
1640             tui.tile_control_char = tui.inputEl.value[0];
1641             tui.switch_mode('control_tile_draw');
1642         }
1643     } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1644         if (tui.inputEl.value.length != 1) {
1645             tui.log_msg('@ entered non-single-char, therefore aborted');
1646         } else {
1647             server.send(['THING_PROTECTION', tui.inputEl.value])
1648             tui.log_msg('@ sent new protection character for thing');
1649         }
1650         tui.switch_mode('admin');
1651     } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1652         let tokens = parser.tokenize(tui.inputEl.value);
1653         if (tokens.length > 0 && tokens[0].length > 0) {
1654             if (tui.inputEl.value[0][0] == '/') {
1655                 if (tokens[0].slice(1) == 'nick') {
1656                     if (tokens.length > 1) {
1657                         server.send(['NICK', tokens[1]]);
1658                     } else {
1659                         tui.log_msg('? need new name');
1660                     }
1661                 } else {
1662                     tui.log_msg('? unknown command');
1663                 }
1664             } else {
1665                     server.send(['ALL', tui.inputEl.value]);
1666             }
1667         } else if (tui.inputEl.valuelength > 0) {
1668                 server.send(['ALL', tui.inputEl.value]);
1669         }
1670         tui.inputEl.value = "";
1671     } else if (tui.mode.name == 'play') {
1672           if (tui.mode.mode_switch_on_key(event)) {
1673               null;
1674           } else if (event.key === tui.keys.consume && tui.task_action_on('consume')) {
1675               server.send(["TASK:INTOXICATE"]);
1676           } else if (event.key === tui.keys.door && tui.task_action_on('door')) {
1677               server.send(["TASK:DOOR"]);
1678           } else if (event.key === tui.keys.wear && tui.task_action_on('wear')) {
1679               server.send(["TASK:WEAR"]);
1680           } else if (event.key === tui.keys.spin && tui.task_action_on('spin')) {
1681               server.send(["TASK:SPIN"]);
1682           } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1683               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1684           } else if (event.key === tui.keys.teleport) {
1685               game.teleport();
1686           };
1687     } else if (tui.mode.name == 'study') {
1688         if (tui.mode.mode_switch_on_key(event)) {
1689               null;
1690         } else if (event.key in tui.movement_keys) {
1691             explorer.move(tui.movement_keys[event.key]);
1692         } else if (event.key == tui.keys.toggle_map_mode) {
1693             tui.toggle_map_mode();
1694         };
1695     } else if (tui.mode.name == 'control_tile_draw') {
1696         if (tui.mode.mode_switch_on_key(event)) {
1697             null;
1698         } else if (event.key in tui.movement_keys) {
1699             explorer.move(tui.movement_keys[event.key]);
1700         } else if (event.key === tui.keys.toggle_tile_draw) {
1701             tui.toggle_tile_draw();
1702         };
1703     } else if (tui.mode.name == 'admin') {
1704         if (tui.mode.mode_switch_on_key(event)) {
1705               null;
1706         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1707             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1708         };
1709     } else if (tui.mode.name == 'edit') {
1710         if (tui.mode.mode_switch_on_key(event)) {
1711               null;
1712         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1713             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1714         } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1715             server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1716           } else if (event.key === tui.keys.install && tui.task_action_on('install')) {
1717               server.send(["TASK:INSTALL", tui.password]);
1718         } else if (event.key == tui.keys.toggle_map_mode) {
1719             tui.toggle_map_mode();
1720         }
1721     }
1722     tui.full_refresh();
1723 }, false);
1724
1725 rows_selector.addEventListener('input', function() {
1726     if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1727         return;
1728     }
1729     window.localStorage.setItem(rows_selector.id, rows_selector.value);
1730     terminal.initialize();
1731     tui.full_refresh();
1732 }, false);
1733 cols_selector.addEventListener('input', function() {
1734     if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1735         return;
1736     }
1737     window.localStorage.setItem(cols_selector.id, cols_selector.value);
1738     terminal.initialize();
1739     tui.window_width = terminal.cols / 2,
1740     tui.full_refresh();
1741 }, false);
1742 for (let key_selector of key_selectors) {
1743     key_selector.addEventListener('input', function() {
1744         window.localStorage.setItem(key_selector.id, key_selector.value);
1745         tui.init_keys();
1746     }, false);
1747 }
1748 window.setInterval(function() {
1749     if (server.websocket.readyState == 1) {
1750         server.send(['PING']);
1751     } else if (server.websocket.readyState != 0) {
1752         server.reconnect_to(server.url);
1753         tui.log_msg('@ attempting reconnect …')
1754     }
1755 }, 1000);
1756 window.setInterval(function() {
1757     if (document.activeElement.tagName.toLowerCase() != 'input') {
1758         tui.inputEl.focus();
1759     };
1760 }, 100);
1761 document.getElementById("help").onclick = function() {
1762     tui.show_help = true;
1763     tui.full_refresh();
1764 };
1765 for (const switchEl  of document.querySelectorAll('[id^="switch_to_"]')) {
1766     const mode = switchEl.id.slice("switch_to_".length);
1767     switchEl.onclick = function() {
1768         tui.switch_mode(mode);
1769         tui.full_refresh();
1770     }
1771 };
1772 document.getElementById("toggle_tile_draw").onclick = function() {
1773     tui.toggle_tile_draw();
1774 }
1775 document.getElementById("toggle_map_mode").onclick = function() {
1776     tui.toggle_map_mode();
1777     tui.full_refresh();
1778 };
1779 document.getElementById("flatten").onclick = function() {
1780     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1781 };
1782 document.getElementById("door").onclick = function() {
1783     server.send(['TASK:DOOR']);
1784 };
1785 document.getElementById("consume").onclick = function() {
1786     server.send(['TASK:INTOXICATE']);
1787 };
1788 document.getElementById("install").onclick = function() {
1789     server.send(['TASK:INSTALL', tui.password]);
1790 };
1791 document.getElementById("wear").onclick = function() {
1792     server.send(['TASK:WEAR']);
1793 };
1794 document.getElementById("spin").onclick = function() {
1795     server.send(['TASK:SPIN']);
1796 };
1797 document.getElementById("teleport").onclick = function() {
1798     game.teleport();
1799 };
1800 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1801     if (move_button.id.startsWith('key_')) {  // not a move button
1802         continue;
1803     };
1804     let direction = move_button.id.split('_')[2].toUpperCase();
1805     let move_repeat;
1806     function move() {
1807         if (tui.mode.available_actions.includes("move")) {
1808             server.send(['TASK:MOVE', direction]);
1809         } else if (tui.mode.available_actions.includes("move_explorer")) {
1810             explorer.move(direction);
1811             tui.full_refresh();
1812         };
1813     }
1814     move_button.onmousedown = function() {
1815         move();
1816         move_repeat = window.setInterval(move, 100);
1817     };
1818     move_button.onmouseup = function() {
1819         window.clearInterval(move_repeat);
1820     }
1821 };
1822 </script>
1823 </body></html>