home · contact · privacy
From web client, remove unneeded focus-keeping code.
[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"></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 thing here.'
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 thing here.'
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             game.turn = parseInt(tokens[1]);
489         } else if (tokens[0] === 'OTHER_WIPE') {
490             game.portals_new = {};
491             explorer.annotations_new = {};
492             game.things_new = [];
493         } else if (tokens[0] === 'THING') {
494             let t = game.get_thing_temp(tokens[4], true);
495             t.position = parser.parse_yx(tokens[1]);
496             t.type_ = tokens[2];
497             t.protection = tokens[3];
498             t.portable = parseInt(tokens[5]);
499             t.commandable = parseInt(tokens[6]);
500         } else if (tokens[0] === 'THING_NAME') {
501             let t = game.get_thing_temp(tokens[1]);
502             t.name_ = tokens[2];
503         } else if (tokens[0] === 'THING_FACE') {
504             let t = game.get_thing_temp(tokens[1]);
505             t.face = tokens[2];
506         } else if (tokens[0] === 'THING_HAT') {
507             let t = game.get_thing_temp(tokens[1]);
508             t.hat = tokens[2];
509         } else if (tokens[0] === 'THING_CHAR') {
510             let t = game.get_thing_temp(tokens[1]);
511             t.thing_char = tokens[2];
512         } else if (tokens[0] === 'TASKS') {
513             game.tasks = tokens[1].split(',');
514             tui.mode_write.legal = game.tasks.includes('WRITE');
515             tui.mode_command_thing.legal = game.tasks.includes('WRITE');
516             tui.mode_take_thing.legal = game.tasks.includes('PICK_UP');
517             tui.mode_drop_thing.legal = game.tasks.includes('DROP');
518         } else if (tokens[0] === 'THING_TYPE') {
519             game.thing_types[tokens[1]] = tokens[2]
520         } else if (tokens[0] === 'THING_CARRYING') {
521             let t = game.get_thing_temp(tokens[1]);
522             t.carrying = game.get_thing(tokens[2], false);
523         } else if (tokens[0] === 'THING_INSTALLED') {
524             let t = game.get_thing_temp(tokens[1]);
525             t.installed = true;
526         } else if (tokens[0] === 'TERRAIN') {
527             game.terrains[tokens[1]] = tokens[2]
528         } else if (tokens[0] === 'MAP') {
529             game.map_geometry_new = tokens[1];
530             game.map_size_new = parser.parse_yx(tokens[2]);
531             game.map_new = tokens[3]
532         } else if (tokens[0] === 'FOV') {
533             game.fov_new = tokens[1]
534         } else if (tokens[0] === 'MAP_CONTROL') {
535             game.map_control_new = tokens[1]
536         } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
537             game.portals = game.portals_new;
538             game.map_geometry = game.map_geometry_new;
539             game.map_size = game.map_size_new;
540             game.map = game.map_new;
541             game.fov = game.fov_new;
542             tui.init_keys();
543             game.map_control = game.map_control_new;
544             explorer.annotations = explorer.annotations_new;
545             explorer.info_cached = false;
546             game.things = game.things_new;
547             game.player = game.things[game.player_id];
548             game.players_hat_chars = game.players_hat_chars_new;
549             game.turn_complete = true;
550             if (tui.mode.name == 'post_login_wait') {
551                 tui.switch_mode('play');
552             } else {
553                 tui.full_refresh();
554             }
555         } else if (tokens[0] === 'CHAT') {
556              tui.log_msg('# ' + tokens[1], 1);
557         } else if (tokens[0] === 'CHATFACE') {
558             tui.draw_face = tokens[1];
559             tui.full_refresh();
560         } else if (tokens[0] === 'REPLY') {
561              tui.log_msg('#MUSICPLAYER: ' + tokens[1], 1);
562         } else if (tokens[0] === 'PLAYER_ID') {
563             game.player_id = parseInt(tokens[1]);
564         } else if (tokens[0] === 'PLAYERS_HAT_CHARS') {
565             game.players_hat_chars_new = tokens[1];
566         } else if (tokens[0] === 'LOGIN_OK') {
567             this.send(['GET_GAMESTATE']);
568             tui.switch_mode('post_login_wait');
569         } else if (tokens[0] === 'DEFAULT_COLORS') {
570             terminal.set_default_colors();
571         } else if (tokens[0] === 'RANDOM_COLORS') {
572             terminal.set_random_colors();
573         } else if (tokens[0] === 'ADMIN_OK') {
574             tui.is_admin = true;
575             tui.log_msg('@ you now have admin rights');
576             tui.switch_mode('admin');
577         } else if (tokens[0] === 'PORTAL') {
578             let position = parser.parse_yx(tokens[1]);
579             game.portals_new[position] = tokens[2];
580         } else if (tokens[0] === 'ANNOTATION') {
581             let position = parser.parse_yx(tokens[1]);
582             explorer.annotations_new[position] = tokens[2];
583         } else if (tokens[0] === 'UNHANDLED_INPUT') {
584             tui.log_msg('? unknown command');
585         } else if (tokens[0] === 'PLAY_ERROR') {
586             tui.log_msg('? ' + tokens[1]);
587             terminal.blink_screen();
588         } else if (tokens[0] === 'ARGUMENT_ERROR') {
589             tui.log_msg('? syntax error: ' + tokens[1]);
590         } else if (tokens[0] === 'GAME_ERROR') {
591             tui.log_msg('? game error: ' + tokens[1]);
592         } else if (tokens[0] === 'PONG') {
593             ;
594         } else {
595             tui.log_msg('? unhandled input: ' + event.data);
596         }
597     }
598 }
599
600 let unparser = {
601     quote: function(str) {
602         let quoted = ['"'];
603         for (let i = 0; i < str.length; i++) {
604             let c = str[i];
605             if (['"', '\\'].includes(c)) {
606                 quoted.push('\\');
607             };
608             quoted.push(c);
609         }
610         quoted.push('"');
611         return quoted.join('');
612     },
613     to_yx: function(yx_coordinate) {
614         return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
615     },
616     untokenize: function(tokens) {
617         let quoted_tokens = [];
618         for (let token of tokens) {
619             quoted_tokens.push(this.quote(token));
620         }
621         return quoted_tokens.join(" ");
622     }
623 }
624
625 class Mode {
626     constructor(name, has_input_prompt=false, shows_info=false,
627                 is_intro=false, is_single_char_entry=false) {
628         this.name = name;
629         this.short_desc = mode_helps[name].short;
630         this.available_modes = [];
631         this.available_actions = [];
632         this.has_input_prompt = has_input_prompt;
633         this.shows_info= shows_info;
634         this.is_intro = is_intro;
635         this.help_intro = mode_helps[name].long;
636         this.intro_msg = mode_helps[name].intro;
637         this.is_single_char_entry = is_single_char_entry;
638         this.legal = true;
639     }
640     *iter_available_modes() {
641         for (let mode_name of this.available_modes) {
642             let mode = tui['mode_' + mode_name];
643             if (!mode.legal) {
644                 continue;
645             }
646             let key = tui.keys['switch_to_' + mode.name];
647             yield [mode, key]
648         }
649     }
650     list_available_modes() {
651         let msg = ''
652         if (this.available_modes.length > 0) {
653             msg += 'Other modes available from here:\n';
654             for (let [mode, key] of this.iter_available_modes()) {
655                 msg += '[' + key + '] – ' + mode.short_desc + '\n';
656             }
657         }
658         return msg;
659     }
660     mode_switch_on_key(key_event) {
661         for (let [mode, key] of this.iter_available_modes()) {
662             if (key_event.key == key) {
663                 event.preventDefault();
664                 tui.switch_mode(mode.name);
665                 return true;
666             };
667         }
668         return false;
669     }
670 }
671 let tui = {
672   links: {},
673   log: [],
674   input_prompt: '> ',
675   input_lines: [],
676   window_width: terminal.cols / 2,
677   height_turn_line: 1,
678   height_mode_line: 1,
679   height_input: 1,
680   password: 'foo',
681   show_help: false,
682   is_admin: false,
683   tile_draw: false,
684   mode_waiting_for_server: new Mode('waiting_for_server',
685                                      false, false, true),
686   mode_login: new Mode('login', true, false, true),
687   mode_post_login_wait: new Mode('post_login_wait'),
688   mode_chat: new Mode('chat', true),
689   mode_annotate: new Mode('annotate', true, true),
690   mode_play: new Mode('play'),
691   mode_study: new Mode('study', false, true),
692   mode_write: new Mode('write', false, false, false, true),
693   mode_edit: new Mode('edit'),
694   mode_control_pw_type: new Mode('control_pw_type', true),
695   mode_admin_thing_protect: new Mode('admin_thing_protect', true),
696   mode_portal: new Mode('portal', true, true),
697   mode_password: new Mode('password', true),
698   mode_name_thing: new Mode('name_thing', true, true),
699   mode_command_thing: new Mode('command_thing', true),
700   mode_take_thing: new Mode('take_thing', true),
701   mode_drop_thing: new Mode('drop_thing', true),
702   mode_enter_face: new Mode('enter_face', true),
703   mode_enter_hat: new Mode('enter_hat', true),
704   mode_admin_enter: new Mode('admin_enter', true),
705   mode_admin: new Mode('admin'),
706   mode_control_pw_pw: new Mode('control_pw_pw', true),
707   mode_control_tile_type: new Mode('control_tile_type', true),
708   mode_control_tile_draw: new Mode('control_tile_draw'),
709   action_tasks: {
710       'flatten': 'FLATTEN_SURROUNDINGS',
711       'take_thing': 'PICK_UP',
712       'drop_thing': 'DROP',
713       'move': 'MOVE',
714       'door': 'DOOR',
715       'install': 'INSTALL',
716       'wear': 'WEAR',
717       'command': 'COMMAND',
718       'consume': 'INTOXICATE',
719       'spin': 'SPIN',
720   },
721   offset: [0,0],
722   map_lines: [],
723   ascii_draw_stage: 0,
724   full_ascii_draw: '',
725   selectables: [],
726   draw_face: false,
727   init: function() {
728       this.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
729                                         "command_thing", "take_thing", "drop_thing"]
730       this.mode_play.available_actions = ["move", "teleport", "door", "consume",
731                                           "wear", "spin"];
732       this.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
733       this.mode_study.available_actions = ["toggle_map_mode", "move_explorer"];
734       this.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
735                                          "control_tile_type", "chat",
736                                          "study", "play", "edit"]
737       this.mode_admin.available_actions = ["move"];
738       this.mode_control_tile_draw.available_modes = ["admin_enter"]
739       this.mode_control_tile_draw.available_actions = ["toggle_tile_draw"];
740       this.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
741                                         "password", "chat", "study", "play",
742                                         "admin_enter", "enter_face", "enter_hat"]
743       this.mode_edit.available_actions = ["move", "flatten", "install",
744                                           "toggle_map_mode"]
745       this.inputEl = document.getElementById("input");
746       this.switch_mode('waiting_for_server');
747       this.recalc_input_lines();
748       this.height_header = this.height_turn_line + this.height_mode_line;
749       this.init_keys();
750   },
751   init_keys: function() {
752     document.getElementById("move_table").hidden = true;
753     this.keys = {};
754     for (let key_selector of key_selectors) {
755         this.keys[key_selector.id.slice(4)] = key_selector.value;
756     }
757     this.movement_keys = {};
758     let geometry_prefix = 'undefinedMapGeometry_';
759     if (game.map_geometry) {
760         geometry_prefix = game.map_geometry.toLowerCase() + '_';
761     }
762     for (const key_name of Object.keys(key_descriptions)) {
763         if (key_name.startsWith(geometry_prefix)) {
764             let direction = key_name.split('_')[2].toUpperCase();
765             let key = this.keys[key_name];
766             this.movement_keys[key] = direction;
767         }
768     };
769     for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
770         if (move_button.id.startsWith('key_')) {
771             continue;
772         }
773         move_button.hidden = true;
774     };
775     for (const move_button of document.querySelectorAll('[id^="' + geometry_prefix + 'move_"]')) {
776         document.getElementById("move_table").hidden = false;
777         move_button.hidden = false;
778     };
779     for (let el of document.getElementsByTagName("button")) {
780       let action_desc = key_descriptions[el.id];
781       let action_key = '[' + this.keys[el.id] + ']';
782       el.innerHTML = escapeHTML(action_desc) + '<br /><span class="keyboard_controlled">' + escapeHTML(action_key) + '</span>';
783     }
784   },
785   task_action_on: function(action) {
786       return game.tasks.includes(this.action_tasks[action]);
787   },
788   switch_mode: function(mode_name) {
789
790     function fail(msg, return_mode) {
791         tui.log_msg('? ' + msg);
792         terminal.blink_screen();
793         tui.switch_mode(return_mode);
794     }
795
796     if (this.mode && this.mode.name == 'control_tile_draw') {
797         tui.log_msg('@ finished tile protection drawing.')
798     }
799     this.draw_face = false;
800     this.tile_draw = false;
801     if (mode_name == 'command_thing' && (!game.player.carrying
802                                          || !game.player.carrying.commandable)) {
803         return fail('not carrying anything commandable', 'play');
804     } else if (mode_name == 'take_thing' && game.player.carrying) {
805         return fail('already carrying something', 'play');
806     } else if (mode_name == 'drop_thing' && !game.player.carrying) {
807         return fail('not carrying anything droppable', 'play');
808     } else if (mode_name == 'enter_hat' && !game.player.hat) {
809         return fail('not wearing hat to edit', 'edit');
810     }
811     if (mode_name == 'admin_enter' && this.is_admin) {
812         mode_name = 'admin';
813     } else if (['name_thing', 'admin_thing_protect'].includes(mode_name)) {
814         let thing_id = null;
815         for (let t_id in game.things) {
816             if (t_id == game.player_id) {
817                 continue;
818             }
819             let t = game.things[t_id];
820             if (game.player.position[0] == t.position[0]
821                 && game.player.position[1] == t.position[1]) {
822                 thing_id = t_id;
823                 break;
824             }
825         }
826         if (!thing_id) {
827             return fail('not standing over thing', 'fail');
828         } else {
829             this.selected_thing_id = thing_id;
830         }
831     };
832     this.mode = this['mode_' + mode_name];
833     if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
834         this.map_mode = 'protections';
835     } else if (this.mode.name != "edit") {
836         this.map_mode = 'terrain + things';
837     };
838     if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
839         explorer.position = game.player.position;
840     }
841     this.inputEl.value = "";
842     this.restore_input_values();
843     for (let el of document.getElementsByTagName("button")) {
844         el.disabled = true;
845     }
846     document.getElementById("help").disabled = false;
847     for (const action of this.mode.available_actions) {
848         if (["move", "move_explorer"].includes(action)) {
849             for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
850                 move_key.disabled = false;
851             }
852         } else if (Object.keys(this.action_tasks).includes(action)) {
853             if (this.task_action_on(action)) {
854                 document.getElementById(action).disabled = false;
855             }
856         } else {
857             document.getElementById(action).disabled = false;
858         };
859     }
860     for (const mode_name of this.mode.available_modes) {
861             document.getElementById('switch_to_' + mode_name).disabled = false;
862     }
863     if (this.mode.intro_msg.length > 0) {
864         this.log_msg(this.mode.intro_msg);
865     }
866     if (this.mode.name == 'login') {
867         if (this.login_name) {
868             server.send(['LOGIN', this.login_name]);
869         } else {
870             this.log_msg("? need login name");
871         }
872     } else if (this.mode.is_single_char_entry) {
873         this.show_help = true;
874     } else if (this.mode.name == 'take_thing') {
875         this.log_msg("Portable things in reach for pick-up:");
876         const y = game.player.position[0]
877         const x = game.player.position[1]
878         let select_range = [y.toString() + ':' + x.toString(),
879                             (y + 0).toString() + ':' + (x - 1).toString(),
880                             (y + 0).toString() + ':' + (x + 1).toString(),
881                             (y - 1).toString() + ':' + (x).toString(),
882                             (y + 1).toString() + ':' + (x).toString()];
883         if (game.map_geometry == 'Hex') {
884             if (y % 2) {
885                 select_range.push((y - 1).toString() + ':' + (x + 1).toString());
886                 select_range.push((y + 1).toString() + ':' + (x + 1).toString());
887             } else {
888                 select_range.push((y - 1).toString() + ':' + (x - 1).toString());
889                 select_range.push((y + 1).toString() + ':' + (x - 1).toString());
890             }
891         };
892         this.selectables = [];
893         for (const t_id in game.things) {
894             const t = game.things[t_id];
895             if (select_range.includes(t.position[0].toString()
896                                       + ':' + t.position[1].toString())
897                 && t.portable) {
898                 this.selectables.push(t_id);
899             }
900         };
901         if (this.selectables.length == 0) {
902             this.log_msg('none');
903             terminal.blink_screen();
904             this.switch_mode('play');
905             return;
906         } else {
907             for (let [i, t_id] of this.selectables.entries()) {
908                 const t = game.things[t_id];
909                 this.log_msg(i + ': ' + explorer.get_thing_info(t));
910             }
911         }
912     } else if (this.mode.name == 'drop_thing') {
913         this.log_msg('Direction to drop thing to:');
914         this.selectables = ['HERE'].concat(Object.values(this.movement_keys));
915         for (let [i, direction] of this.selectables.entries()) {
916             this.log_msg(i + ': ' + direction);
917         };
918     } else if (this.mode.name == 'enter_hat') {
919         this.log_msg('legal characters: ' + game.players_hat_chars);
920     } else if (this.mode.name == 'command_thing') {
921         server.send(['TASK:COMMAND', 'HELP']);
922     } else if (this.mode.name == 'control_pw_pw') {
923         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
924     } else if (this.mode.name == 'control_tile_draw') {
925         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 + '].')
926     }
927     this.full_refresh();
928   },
929   offset_links: function(offset, links) {
930       for (let y in links) {
931           let real_y = offset[0] + parseInt(y);
932           if (!this.links[real_y]) {
933               this.links[real_y] = [];
934           }
935           for (let link of links[y]) {
936               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
937               this.links[real_y].push(offset_link);
938           }
939       }
940   },
941   restore_input_values: function() {
942       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
943           let info = explorer.annotations[explorer.position];
944           if (info != "(none)") {
945               this.inputEl.value = info;
946           }
947       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
948           let portal = game.portals[explorer.position]
949           this.inputEl.value = portal;
950       } else if (this.mode.name == 'password') {
951           this.inputEl.value = this.password;
952       } else if (this.mode.name == 'name_thing') {
953           let t = game.get_thing(this.selected_thing_id);
954           if (t && t.name_) {
955               this.inputEl.value = t.name_;
956           }
957       } else if (this.mode.name == 'admin_thing_protect') {
958           let t = game.get_thing(this.selected_thing_id);
959           if (t && t.protection) {
960               this.inputEl.value = t.protection;
961           }
962       } else if (['enter_face', 'enter_hat'].includes(this.mode.name)) {
963           const start = this.ascii_draw_stage * 6;
964           const end = (this.ascii_draw_stage + 1) * 6;
965           if (this.mode.name == 'enter_face') {
966               this.inputEl.value = game.player.face.slice(start, end);
967           } else if (this.mode.name == 'enter_hat') {
968               this.inputEl.value = game.player.hat.slice(start, end);
969           }
970       }
971   },
972   recalc_input_lines: function() {
973       if (this.mode.has_input_prompt) {
974           let _ = null;
975           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value + '█', this.window_width);
976       } else {
977           this.input_lines = [];
978       }
979       this.height_input = this.input_lines.length;
980   },
981   msg_into_lines_of_width: function(msg, width) {
982       function push_inner_link(y, end_x) {
983           if (!inner_links[y]) {
984               inner_links[y] = [];
985           };
986           inner_links[y].push([url_start_x, end_x, url]);
987       };
988       const matches = msg.matchAll(/https?:\/\/[^\s]+/g)
989       let link_data = {};
990       let url_ends = [];
991       for (const match of matches) {
992           const url = match[0];
993           const url_start = match.index;
994           const url_end = match.index + match[0].length;
995           link_data[url_start] = url;
996           url_ends.push(url_end);
997       }
998       let url_start_x = 0;
999       let url = '';
1000       let inner_links = {};
1001       let in_link = false;
1002       let chunk = "";
1003       let lines = [];
1004       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
1005           if (x >= width || msg[i] == "\n") {
1006               if (in_link) {
1007                   push_inner_link(y, chunk.length);
1008                   url_start_x = 0;
1009                   if (url_ends[0] == i) {
1010                       in_link = false;
1011                       url_ends.shift();
1012                   }
1013               };
1014               lines.push(chunk);
1015               chunk = "";
1016               x = 0;
1017               if (msg[i] == "\n") {
1018                   x -= 1;
1019               };
1020               y += 1;
1021           };
1022           if (msg[i] != "\n") {
1023               chunk += msg[i];
1024           };
1025           if (i in link_data) {
1026               url_start_x = x;
1027               url = link_data[i];
1028               in_link = true;
1029           } else if (url_ends[0] == i) {
1030               url_ends.shift();
1031               push_inner_link(y, x);
1032               in_link = false;
1033           }
1034       }
1035       lines.push(chunk);
1036       if (in_link) {
1037           push_inner_link(lines.length - 1, chunk.length);
1038       }
1039       return [lines, inner_links];
1040   },
1041   log_msg: function(msg) {
1042       this.log.push(msg);
1043       while (this.log.length > 100) {
1044         this.log.shift();
1045       };
1046       this.full_refresh();
1047   },
1048   pick_selectable: function(task_name) {
1049       const i = parseInt(this.inputEl.value);
1050       if (isNaN(this.inputEl.value) || i < 0 || i >= this.selectables.length) {
1051           tui.log_msg('? invalid index, aborted');
1052       } else {
1053           server.send(['TASK:' + task_name, tui.selectables[i]]);
1054       }
1055       this.inputEl.value = "";
1056       this.switch_mode('play');
1057   },
1058   enter_ascii_art: function(command) {
1059       if (this.inputEl.value.length != 6) {
1060           this.log_msg('? wrong input length, must be 6; try again');
1061           return;
1062       }
1063       this.log_msg('  ' + this.inputEl.value);
1064       this.full_ascii_draw += this.inputEl.value;
1065       this.ascii_draw_stage += 1;
1066       if (this.ascii_draw_stage < 3) {
1067           this.restore_input_values();
1068       } else {
1069           server.send([command, this.full_ascii_draw]);
1070           this.full_ascii_draw = '';
1071           this.ascii_draw_stage = 0;
1072           this.inputEl.value = '';
1073           this.switch_mode('edit');
1074       }
1075   },
1076   draw_map: function() {
1077     if (!game.turn_complete && this.map_lines.length == 0) {
1078         return;
1079     }
1080     if (game.turn_complete) {
1081         let map_lines_split = [];
1082         let line = [];
1083         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
1084             if (j == game.map_size[1]) {
1085                 map_lines_split.push(line);
1086                 line = [];
1087                 j = 0;
1088             };
1089             if (this.map_mode == 'protections') {
1090                 line.push(game.map_control[i] + ' ');
1091             } else {
1092                 line.push(game.map[i] + ' ');
1093             }
1094         };
1095         map_lines_split.push(line);
1096         if (this.map_mode == 'terrain + annotations') {
1097             for (const [coordinate, _] of Object.entries(explorer.annotations)) {
1098                 const yx = coordinate.split(',')
1099                 map_lines_split[yx[0]][yx[1]] = 'A ';
1100             }
1101         } else if (this.map_mode == 'terrain + things') {
1102             for (const p in game.portals) {
1103                 let coordinate = p.split(',')
1104                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1105                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1106             }
1107             let used_positions = [];
1108             function draw_thing(t, used_positions) {
1109                 let symbol = game.thing_types[t.type_];
1110                 let meta_char = ' ';
1111                 if (t.thing_char) {
1112                     meta_char = t.thing_char;
1113                 }
1114                 if (used_positions.includes(t.position.toString())) {
1115                     meta_char = '+';
1116                 };
1117                 if (t.carrying) {
1118                     meta_char = '$';
1119                 }
1120                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1121                 used_positions.push(t.position.toString());
1122             }
1123             for (const thing_id in game.things) {
1124                 let t = game.things[thing_id];
1125                 if (t.type_ != 'Player') {
1126                     draw_thing(t, used_positions);
1127                 }
1128             };
1129             for (const thing_id in game.things) {
1130                 let t = game.things[thing_id];
1131                 if (t.type_ == 'Player') {
1132                     draw_thing(t, used_positions);
1133                 }
1134             };
1135         }
1136         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1137             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1138         } else if (tui.map_mode != 'terrain + things') {
1139             map_lines_split[game.player.position[0]][game.player.position[1]] = '??';
1140         }
1141         this.map_lines = []
1142         if (game.map_geometry == 'Square') {
1143             for (let line_split of map_lines_split) {
1144                 this.map_lines.push(line_split.join(''));
1145             };
1146         } else if (game.map_geometry == 'Hex') {
1147             let indent = 0
1148             for (let line_split of map_lines_split) {
1149                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1150                 if (indent == 0) {
1151                     indent = 1;
1152                 } else {
1153                     indent = 0;
1154                 };
1155             };
1156         }
1157         let window_center = [terminal.rows / 2, this.window_width / 2];
1158         let center_position = [game.player.position[0], game.player.position[1]];
1159         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1160             center_position = [explorer.position[0], explorer.position[1]];
1161         }
1162         center_position[1] = center_position[1] * 2;
1163         this.offset = [center_position[0] - window_center[0],
1164                        center_position[1] - window_center[1]]
1165         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1166             this.offset[1] += 1;
1167         };
1168     };
1169     let term_y = Math.max(0, -this.offset[0]);
1170     let term_x = Math.max(0, -this.offset[1]);
1171     let map_y = Math.max(0, this.offset[0]);
1172     let map_x = Math.max(0, this.offset[1]);
1173     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1174         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1175         terminal.write(term_y, term_x, to_draw);
1176     }
1177   },
1178   draw_face_popup: function() {
1179       const t = game.things[this.draw_face];
1180       if (!t || !t.face) {
1181           this.draw_face = false;
1182           return;
1183       }
1184       const start_x = tui.window_width - 10;
1185       let t_char = ' ';
1186       if (t.thing_char) {
1187           t_char = t.thing_char;
1188       }
1189       function draw_body_part(body_part, end_y) {
1190           terminal.write(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ');
1191           terminal.write(end_y - 3, start_x, '|        |');
1192           terminal.write(end_y - 2, start_x, '| ' + body_part.slice(0, 6) + ' |');
1193           terminal.write(end_y - 1, start_x, '| ' + body_part.slice(6, 12) + ' |');
1194           terminal.write(end_y, start_x, '| ' + body_part.slice(12, 18) + ' |');
1195       }
1196       if (t.face) {
1197           draw_body_part(t.face, terminal.rows - 2);
1198       }
1199       if (t.hat) {
1200           draw_body_part(t.hat, terminal.rows - 5);
1201       }
1202       terminal.write(terminal.rows - 1, start_x, '|        |');
1203   },
1204   draw_mode_line: function() {
1205       let help = 'hit [' + this.keys.help + '] for help';
1206       if (this.mode.has_input_prompt) {
1207           help = 'enter /help for help';
1208       }
1209       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1210   },
1211   draw_turn_line: function(n) {
1212       if (game.turn_complete) {
1213           terminal.write(1, this.window_width, 'TURN: ' + game.turn);
1214       }
1215   },
1216   draw_history: function() {
1217       let log_display_lines = [];
1218       let log_links = {};
1219       let y_offset_in_log = 0;
1220       for (let line of this.log) {
1221           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1222                                                                     this.window_width)
1223           log_display_lines = log_display_lines.concat(new_lines);
1224           for (const y in link_data) {
1225               const rel_y = y_offset_in_log + parseInt(y);
1226               log_links[rel_y] = [];
1227               for (let link of link_data[y]) {
1228                   log_links[rel_y].push(link);
1229               }
1230           }
1231           y_offset_in_log += new_lines.length;
1232       };
1233       let i = log_display_lines.length - 1;
1234       for (let y = terminal.rows - 1 - this.height_input;
1235            y >= this.height_header && i >= 0;
1236            y--, i--) {
1237           terminal.write(y, this.window_width, log_display_lines[i]);
1238       }
1239       for (const key of Object.keys(log_links)) {
1240           if (parseInt(key) <= i) {
1241               delete log_links[key];
1242           }
1243       }
1244       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1245                     this.window_width];
1246       this.offset_links(offset, log_links);
1247   },
1248   draw_info: function() {
1249       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1250       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1251       let offset = [this.height_header, this.window_width];
1252       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1253         terminal.write(y, offset[1], lines[i]);
1254       }
1255       this.offset_links(offset, link_data);
1256   },
1257   draw_input: function() {
1258     if (this.mode.has_input_prompt) {
1259         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1260             terminal.write(y, this.window_width, this.input_lines[i]);
1261         }
1262     }
1263   },
1264   draw_help: function() {
1265       let movement_keys_desc = '';
1266       if (!this.mode.is_intro) {
1267           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1268       }
1269       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1270       if (this.mode.available_actions.length > 0) {
1271           content += "Available actions:\n";
1272           for (let action of this.mode.available_actions) {
1273               if (Object.keys(this.action_tasks).includes(action)) {
1274                   if (!this.task_action_on(action)) {
1275                       continue;
1276                   }
1277               }
1278               if (action == 'move_explorer') {
1279                   action = 'move';
1280               }
1281               if (action == 'move') {
1282                   content += "[" + movement_keys_desc + "] – move\n"
1283               } else {
1284                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1285               }
1286           }
1287           content += '\n';
1288       }
1289       content += this.mode.list_available_modes();
1290       let start_x = 0;
1291       if (!this.mode.has_input_prompt) {
1292           start_x = this.window_width;
1293           this.draw_links = false;
1294       }
1295       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1296       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1297       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1298           terminal.write(y, start_x, lines[i]);
1299       }
1300   },
1301   toggle_tile_draw: function() {
1302       if (tui.tile_draw) {
1303           tui.tile_draw = false;
1304       } else {
1305           tui.tile_draw = true;
1306       }
1307   },
1308   toggle_map_mode: function() {
1309       if (tui.map_mode == 'terrain only') {
1310           tui.map_mode = 'terrain + annotations';
1311       } else if (tui.map_mode == 'terrain + annotations') {
1312           tui.map_mode = 'terrain + things';
1313       } else if (tui.map_mode == 'terrain + things') {
1314           tui.map_mode = 'protections';
1315       } else if (tui.map_mode == 'protections') {
1316           tui.map_mode = 'terrain only';
1317       }
1318   },
1319   full_refresh: function() {
1320     this.draw_links = true;
1321     this.links = {};
1322     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1323     this.recalc_input_lines();
1324     if (this.mode.is_intro) {
1325         this.draw_history();
1326         this.draw_input();
1327     } else {
1328         this.draw_map();
1329         this.draw_turn_line();
1330         this.draw_mode_line();
1331         if (this.mode.shows_info) {
1332           this.draw_info();
1333         } else {
1334           this.draw_history();
1335         }
1336         this.draw_input();
1337     }
1338     if (this.show_help) {
1339         this.draw_help();
1340     }
1341     if (this.draw_face && ['chat', 'play'].includes(this.mode.name)) {
1342         this.draw_face_popup();
1343     }
1344     if (!this.draw_links) {
1345         this.links = {};
1346     }
1347     terminal.refresh();
1348   }
1349 }
1350
1351 let game = {
1352     init: function() {
1353         this.turn = -1;
1354         this.player_id = -1;
1355         this.tasks = {};
1356         this.things = {};
1357         this.things_new = {};
1358         this.fov = "";
1359         this.fov_new = "";
1360         this.map = "";
1361         this.map_new = "";
1362         this.map_control = "";
1363         this.map_control_new = "";
1364         this.map_size = [0,0];
1365         this.map_size_new = [0,0];
1366         this.portals = {};
1367         this.portals_new = {};
1368         this.players_hat_chars = "";
1369     },
1370     get_thing_temp: function(id_, create_if_not_found=false) {
1371         if (id_ in game.things_new) {
1372             return game.things_new[id_];
1373         } else if (create_if_not_found) {
1374             let t = new Thing([0,0]);
1375             game.things_new[id_] = t;
1376             return t;
1377         };
1378     },
1379     get_thing: function(id_, create_if_not_found=false) {
1380         if (id_ in game.things) {
1381             return game.things[id_];
1382         };
1383     },
1384     move: function(start_position, direction) {
1385         let target = [start_position[0], start_position[1]];
1386         if (direction == 'LEFT') {
1387             target[1] -= 1;
1388         } else if (direction == 'RIGHT') {
1389             target[1] += 1;
1390         } else if (game.map_geometry == 'Square') {
1391             if (direction == 'UP') {
1392                 target[0] -= 1;
1393             } else if (direction == 'DOWN') {
1394                 target[0] += 1;
1395             };
1396         } else if (game.map_geometry == 'Hex') {
1397             let start_indented = start_position[0] % 2;
1398             if (direction == 'UPLEFT') {
1399                 target[0] -= 1;
1400                 if (!start_indented) {
1401                     target[1] -= 1;
1402                 }
1403             } else if (direction == 'UPRIGHT') {
1404                 target[0] -= 1;
1405                 if (start_indented) {
1406                     target[1] += 1;
1407                 }
1408             } else if (direction == 'DOWNLEFT') {
1409                 target[0] += 1;
1410                 if (!start_indented) {
1411                     target[1] -= 1;
1412                 }
1413             } else if (direction == 'DOWNRIGHT') {
1414                 target[0] += 1;
1415                 if (start_indented) {
1416                     target[1] += 1;
1417                 }
1418             };
1419         };
1420         if (target[0] < 0 || target[1] < 0 ||
1421             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1422             return null;
1423         };
1424         return target;
1425     },
1426     teleport: function() {
1427         if (game.player.position in this.portals) {
1428             server.reconnect_to(this.portals[game.player.position]);
1429         } else {
1430             terminal.blink_screen();
1431             tui.log_msg('? not standing on portal')
1432         }
1433     }
1434 }
1435
1436 game.init();
1437 tui.init();
1438 tui.full_refresh();
1439 server.init(websocket_location);
1440
1441 let explorer = {
1442     position: [0,0],
1443     annotations: {},
1444     annotations_new: {},
1445     info_cached: false,
1446     move: function(direction) {
1447         let target = game.move(this.position, direction);
1448         if (target) {
1449             this.position = target
1450             this.info_cached = false;
1451             if (tui.tile_draw) {
1452                 this.send_tile_control_command();
1453             }
1454         } else {
1455             terminal.blink_screen();
1456         };
1457     },
1458     get_info: function() {
1459         if (this.info_cached) {
1460             return this.info_cached;
1461         }
1462         let info_to_cache = '';
1463         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1464         if (game.fov[position_i] != '.') {
1465             info_to_cache += 'outside field of view';
1466         } else {
1467             for (let t_id in game.things) {
1468                  let t = game.things[t_id];
1469                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1470                      info_to_cache += "THING: " + this.get_thing_info(t);
1471                      let protection = t.protection;
1472                      if (protection == '.') {
1473                          protection = 'none';
1474                      }
1475                      info_to_cache += " / protection: " + protection + "\n";
1476                      if (t.hat) {
1477                          info_to_cache += t.hat.slice(0, 6) + '\n';
1478                          info_to_cache += t.hat.slice(6, 12) + '\n';
1479                          info_to_cache += t.hat.slice(12, 18) + '\n';
1480                      }
1481                      if (t.face) {
1482                          info_to_cache += t.face.slice(0, 6) + '\n';
1483                          info_to_cache += t.face.slice(6, 12) + '\n';
1484                          info_to_cache += t.face.slice(12, 18) + '\n';
1485                      }
1486                  }
1487             }
1488             let terrain_char = game.map[position_i]
1489             let terrain_desc = '?'
1490             if (game.terrains[terrain_char]) {
1491                 terrain_desc = game.terrains[terrain_char];
1492             };
1493             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1494             let protection = game.map_control[position_i];
1495             if (protection == '.') {
1496                 protection = 'unprotected';
1497             };
1498             info_to_cache += 'PROTECTION: ' + protection + '\n';
1499             if (this.position in game.portals) {
1500                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1501             }
1502             if (this.position in this.annotations) {
1503                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1504             }
1505         }
1506         this.info_cached = info_to_cache;
1507         return this.info_cached;
1508     },
1509     get_thing_info: function(t) {
1510         const symbol = game.thing_types[t.type_];
1511         let info = t.type_ + " / " + symbol;
1512         if (t.thing_char) {
1513             info += t.thing_char;
1514         };
1515         if (t.name_) {
1516             info += " (" + t.name_ + ")";
1517         }
1518         if (t.installed) {
1519             info += " / installed";
1520         }
1521         return info;
1522     },
1523     annotate: function(msg) {
1524         if (msg.length == 0) {
1525             msg = " ";  // triggers annotation deletion
1526         }
1527         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1528     },
1529     set_portal: function(msg) {
1530         if (msg.length == 0) {
1531             msg = " ";  // triggers portal deletion
1532         }
1533         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1534     },
1535     send_tile_control_command: function() {
1536         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1537     }
1538 }
1539
1540 tui.inputEl.addEventListener('input', (event) => {
1541     if (tui.mode.has_input_prompt) {
1542         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1543         if (tui.inputEl.value.length > max_length) {
1544             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1545         };
1546     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1547         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1548         tui.switch_mode('edit');
1549     }
1550     tui.full_refresh();
1551 }, false);
1552 document.onclick = function() {
1553     if (!tui.mode.is_single_char_entry) {
1554         tui.show_help = false;
1555     }
1556 };
1557 tui.inputEl.addEventListener('keydown', (event) => {
1558     tui.show_help = false;
1559     if (['Enter', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
1560         event.preventDefault();
1561     }
1562     if ((!tui.mode.is_intro && event.key == 'Escape')
1563         || (tui.mode.has_input_prompt && event.key == 'Enter'
1564             && tui.inputEl.value.length == 0
1565             && ['chat', 'command_thing', 'take_thing', 'drop_thing',
1566                 'admin_enter'].includes(tui.mode.name))) {
1567         if (!['chat', 'play', 'study', 'edit'].includes(tui.mode.name)) {
1568             tui.log_msg('@ aborted');
1569         }
1570         tui.switch_mode('play');
1571     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1572         tui.show_help = true;
1573         tui.inputEl.value = "";
1574         tui.restore_input_values();
1575     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1576                && !tui.mode.is_single_char_entry) {
1577         tui.show_help = true;
1578     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1579         tui.login_name = tui.inputEl.value;
1580         server.send(['LOGIN', tui.inputEl.value]);
1581         tui.inputEl.value = "";
1582     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1583         tui.enter_ascii_art('PLAYER_FACE');
1584     } else if (tui.mode.name == 'enter_hat' && event.key == 'Enter') {
1585         tui.enter_ascii_art('PLAYER_HAT');
1586     } else if (tui.mode.name == 'command_thing' && event.key == 'Enter') {
1587         server.send(['TASK:COMMAND', tui.inputEl.value]);
1588         tui.inputEl.value = "";
1589     } else if (tui.mode.name == 'take_thing' && event.key == 'Enter') {
1590         tui.pick_selectable('PICK_UP');
1591     } else if (tui.mode.name == 'drop_thing' && event.key == 'Enter') {
1592         tui.pick_selectable('DROP');
1593     } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1594         if (tui.inputEl.value.length == 0) {
1595             tui.log_msg('@ aborted');
1596         } else {
1597             server.send(['SET_MAP_CONTROL_PASSWORD',
1598                         tui.tile_control_char, tui.inputEl.value]);
1599             tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1600         }
1601         tui.switch_mode('admin');
1602     } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1603         explorer.set_portal(tui.inputEl.value);
1604         tui.switch_mode('edit');
1605     } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1606         if (tui.inputEl.value.length == 0) {
1607             tui.inputEl.value = " ";
1608         }
1609         server.send(["THING_NAME", tui.selected_thing_id, tui.inputEl.value,
1610                      tui.password]);
1611         tui.switch_mode('edit');
1612     } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1613         explorer.annotate(tui.inputEl.value);
1614         tui.switch_mode('edit');
1615     } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1616         if (tui.inputEl.value.length == 0) {
1617             tui.inputEl.value = " ";
1618         }
1619         tui.password = tui.inputEl.value
1620         tui.switch_mode('edit');
1621     } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1622         server.send(['BECOME_ADMIN', tui.inputEl.value]);
1623         tui.switch_mode('play');
1624     } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1625         if (tui.inputEl.value.length != 1) {
1626             tui.log_msg('@ entered non-single-char, therefore aborted');
1627             tui.switch_mode('admin');
1628         } else {
1629             tui.tile_control_char = tui.inputEl.value[0];
1630             tui.switch_mode('control_pw_pw');
1631         }
1632     } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1633         if (tui.inputEl.value.length != 1) {
1634             tui.log_msg('@ entered non-single-char, therefore aborted');
1635             tui.switch_mode('admin');
1636         } else {
1637             tui.tile_control_char = tui.inputEl.value[0];
1638             tui.switch_mode('control_tile_draw');
1639         }
1640     } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1641         if (tui.inputEl.value.length != 1) {
1642             tui.log_msg('@ entered non-single-char, therefore aborted');
1643         } else {
1644             server.send(['THING_PROTECTION', tui.selected_thing_id, tui.inputEl.value])
1645             tui.log_msg('@ sent new protection character for thing');
1646         }
1647         tui.switch_mode('admin');
1648     } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1649         let tokens = parser.tokenize(tui.inputEl.value);
1650         if (tokens.length > 0 && tokens[0].length > 0) {
1651             if (tui.inputEl.value[0][0] == '/') {
1652                 if (tokens[0].slice(1) == 'nick') {
1653                     if (tokens.length > 1) {
1654                         server.send(['NICK', tokens[1]]);
1655                     } else {
1656                         tui.log_msg('? need new name');
1657                     }
1658                 } else {
1659                     tui.log_msg('? unknown command');
1660                 }
1661             } else {
1662                     server.send(['ALL', tui.inputEl.value]);
1663             }
1664         } else if (tui.inputEl.valuelength > 0) {
1665                 server.send(['ALL', tui.inputEl.value]);
1666         }
1667         tui.inputEl.value = "";
1668     } else if (tui.mode.name == 'play') {
1669           if (tui.mode.mode_switch_on_key(event)) {
1670               null;
1671           } else if (event.key === tui.keys.consume && tui.task_action_on('consume')) {
1672               server.send(["TASK:INTOXICATE"]);
1673           } else if (event.key === tui.keys.door && tui.task_action_on('door')) {
1674               server.send(["TASK:DOOR"]);
1675           } else if (event.key === tui.keys.wear && tui.task_action_on('wear')) {
1676               server.send(["TASK:WEAR"]);
1677           } else if (event.key === tui.keys.spin && tui.task_action_on('spin')) {
1678               server.send(["TASK:SPIN"]);
1679           } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1680               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1681           } else if (event.key === tui.keys.teleport) {
1682               game.teleport();
1683           };
1684     } else if (tui.mode.name == 'study') {
1685         if (tui.mode.mode_switch_on_key(event)) {
1686               null;
1687         } else if (event.key in tui.movement_keys) {
1688             explorer.move(tui.movement_keys[event.key]);
1689         } else if (event.key == tui.keys.toggle_map_mode) {
1690             tui.toggle_map_mode();
1691         };
1692     } else if (tui.mode.name == 'control_tile_draw') {
1693         if (tui.mode.mode_switch_on_key(event)) {
1694             null;
1695         } else if (event.key in tui.movement_keys) {
1696             explorer.move(tui.movement_keys[event.key]);
1697         } else if (event.key === tui.keys.toggle_tile_draw) {
1698             tui.toggle_tile_draw();
1699         };
1700     } else if (tui.mode.name == 'admin') {
1701         if (tui.mode.mode_switch_on_key(event)) {
1702               null;
1703         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1704             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1705         };
1706     } else if (tui.mode.name == 'edit') {
1707         if (tui.mode.mode_switch_on_key(event)) {
1708               null;
1709         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1710             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1711         } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1712             server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1713           } else if (event.key === tui.keys.install && tui.task_action_on('install')) {
1714               server.send(["TASK:INSTALL", tui.password]);
1715         } else if (event.key == tui.keys.toggle_map_mode) {
1716             tui.toggle_map_mode();
1717         }
1718     }
1719     tui.full_refresh();
1720 }, false);
1721
1722 rows_selector.addEventListener('input', function() {
1723     if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1724         return;
1725     }
1726     window.localStorage.setItem(rows_selector.id, rows_selector.value);
1727     terminal.initialize();
1728     tui.full_refresh();
1729 }, false);
1730 cols_selector.addEventListener('input', function() {
1731     if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1732         return;
1733     }
1734     window.localStorage.setItem(cols_selector.id, cols_selector.value);
1735     terminal.initialize();
1736     tui.window_width = terminal.cols / 2,
1737     tui.full_refresh();
1738 }, false);
1739 for (let key_selector of key_selectors) {
1740     key_selector.addEventListener('input', function() {
1741         window.localStorage.setItem(key_selector.id, key_selector.value);
1742         tui.init_keys();
1743     }, false);
1744 }
1745 window.setInterval(function() {
1746     if (server.websocket.readyState == 1) {
1747         server.send(['PING']);
1748     } else if (server.websocket.readyState != 0) {
1749         server.reconnect_to(server.url);
1750         tui.log_msg('@ attempting reconnect …')
1751     }
1752 }, 1000);
1753 window.setInterval(function() {
1754     if (document.activeElement.tagName.toLowerCase() != 'input') {
1755         tui.inputEl.focus();
1756     };
1757 }, 100);
1758 document.getElementById("help").onclick = function() {
1759     tui.show_help = true;
1760     tui.full_refresh();
1761 };
1762 for (const switchEl  of document.querySelectorAll('[id^="switch_to_"]')) {
1763     const mode = switchEl.id.slice("switch_to_".length);
1764     switchEl.onclick = function() {
1765         tui.switch_mode(mode);
1766         tui.full_refresh();
1767     }
1768 };
1769 document.getElementById("toggle_tile_draw").onclick = function() {
1770     tui.toggle_tile_draw();
1771 }
1772 document.getElementById("toggle_map_mode").onclick = function() {
1773     tui.toggle_map_mode();
1774     tui.full_refresh();
1775 };
1776 document.getElementById("flatten").onclick = function() {
1777     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1778 };
1779 document.getElementById("door").onclick = function() {
1780     server.send(['TASK:DOOR']);
1781 };
1782 document.getElementById("consume").onclick = function() {
1783     server.send(['TASK:INTOXICATE']);
1784 };
1785 document.getElementById("install").onclick = function() {
1786     server.send(['TASK:INSTALL', tui.password]);
1787 };
1788 document.getElementById("wear").onclick = function() {
1789     server.send(['TASK:WEAR']);
1790 };
1791 document.getElementById("spin").onclick = function() {
1792     server.send(['TASK:SPIN']);
1793 };
1794 document.getElementById("teleport").onclick = function() {
1795     game.teleport();
1796 };
1797 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1798     if (move_button.id.startsWith('key_')) {  // not a move button
1799         continue;
1800     };
1801     let direction = move_button.id.split('_')[2].toUpperCase();
1802     let move_repeat;
1803     function move() {
1804         if (tui.mode.available_actions.includes("move")) {
1805             server.send(['TASK:MOVE', direction]);
1806         } else if (tui.mode.available_actions.includes("move_explorer")) {
1807             explorer.move(direction);
1808             tui.full_refresh();
1809         };
1810     }
1811     move_button.onmousedown = function() {
1812         move();
1813         move_repeat = window.setInterval(move, 100);
1814     };
1815     move_button.onmouseup = function() {
1816         window.clearInterval(move_repeat);
1817     }
1818 };
1819 </script>
1820 </body></html>