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