home · contact · privacy
Fix map geometry initialization bug.
[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 carried thing.'
154     },
155     'command_thing': {
156         'short': 'command',
157         'intro': '',
158         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
159     },
160     'take_thing': {
161         'short': 'take',
162         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
163         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
164     },
165     'drop_thing': {
166         'short': 'drop',
167         'intro': 'Enter number of direction to which you want to drop thing.',
168         'long': 'Drop currently carried thing by entering the target direction index.  Enter nothing to return to play mode..'
169     },
170     'admin_thing_protect': {
171         'short': 'change thing protection',
172         'intro': '@ enter thing protection character:',
173         'long': 'Change protection character for carried thing.'
174     },
175     'enter_face': {
176         'short': 'edit face',
177         'intro': '@ enter face line (enter nothing to abort):',
178         'long': 'Draw your face as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom.  Eat cookies to extend the ASCII characters available for drawing.'
179     },
180     'enter_hat': {
181         'short': 'edit hat',
182         'intro': '@ enter hat line (enter nothing to abort):',
183         'long': 'Draw your hat as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom..'
184     },
185     'write': {
186         'short': 'edit tile',
187         'intro': '',
188         'long': 'This mode allows you to change the map tile you currently stand on (if your world editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.'
189     },
190     'control_pw_type': {
191         'short': 'change protection character password',
192         'intro': '@ enter protection character for which you want to change the password:',
193         'long': 'This mode is the first of two steps to change the password for a protection character.  First enter the protection character for which you want to change the password.'
194     },
195     'control_pw_pw': {
196         'short': 'change protection character password',
197         'intro': '',
198         'long': 'This mode is the second of two steps to change the password for a protection character.  Enter the new password for the protection character you chose.'
199     },
200     'control_tile_type': {
201         'short': 'change tiles protection',
202         'intro': '@ enter protection character which you want to draw:',
203         'long': 'This mode is the first of two steps to change tile protection areas on the map.  First enter the tile protection character you want to write.'
204     },
205     'control_tile_draw': {
206         'short': 'change tiles protection',
207         'intro': '',
208         'long': 'This mode is the second of two steps to change tile protection areas on the map.  Toggle tile protection drawing on/off and move the ?? cursor around the map to draw the selected protection character.'
209     },
210     'annotate': {
211         'short': 'annotate tile',
212         'intro': '',
213         'long': 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your world editing password authorizes you so).  Hit Return to leave.'
214     },
215     'portal': {
216         'short': 'edit portal',
217         'intro': '',
218         'long': 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your world editing password authorizes you so).  Enter or edit a URL to imprint a teleportation target; enter emptiness to remove a pre-existing teleportation target.  Hit Return to leave.'
219     },
220     'chat': {
221         'short': 'chat',
222         'intro': '',
223         'long': 'This mode allows you to engage in chit-chat with other users.  Any line you enter into the input prompt that does not start with a "/" will be sent out to nearby players – but barriers and distance will reduce what they can read, so stand close to them to ensure they get your message.  Lines that start with a "/" are used for commands like:\n\n/nick NAME – re-name yourself to NAME'
224     },
225     'login': {
226         'short': 'login',
227         'intro': '',
228         'long': 'Enter your player name.'
229     },
230     'waiting_for_server': {
231         'short': 'waiting for server response',
232         'intro': '@ waiting for server …',
233         'long': 'Waiting for a server response.'
234     },
235     'post_login_wait': {
236         'short': 'waiting for server response',
237         'intro': '',
238         'long': 'Waiting for a server response.'
239     },
240     'password': {
241         'short': 'set world edit password',
242         'intro': '',
243         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected elements of the world.  Hit return to confirm and leave.'
244     },
245     'admin_enter': {
246         'short': 'become admin',
247         'intro': '@ enter admin password:',
248         'long': 'This mode allows you to become admin if you know an admin password.'
249     },
250     'admin': {
251         'short': 'admin',
252         'intro': '',
253         'long': 'This mode allows you access to actions limited to administrators.'
254     }
255 }
256 let key_descriptions = {
257     'help': 'help',
258     'flatten': 'flatten surroundings',
259     'teleport': 'teleport',
260     'door': 'open/close',
261     'consume': 'consume',
262     'install': '(un-)install',
263     'wear': '(un-)wear',
264     'spin': 'spin',
265     'toggle_map_mode': 'toggle map view',
266     'toggle_tile_draw': 'toggle protection character drawing',
267     'hex_move_upleft': 'up-left',
268     'hex_move_upright': 'up-right',
269     'hex_move_right': 'right',
270     'hex_move_left': 'left',
271     'hex_move_downleft': 'down-left',
272     'hex_move_downright': 'down-right',
273     'square_move_up': 'up',
274     'square_move_left': 'left',
275     'square_move_down': 'down',
276     'square_move_right': 'right',
277 };
278 for (const mode_name of Object.keys(mode_helps)) {
279     key_descriptions['switch_to_' + mode_name] = mode_helps[mode_name].short;
280 };
281
282 let rows_selector = document.getElementById("n_rows");
283 let cols_selector = document.getElementById("n_cols");
284 let key_selectors = document.querySelectorAll('[id^="key_"]');
285
286 for (const key_switch_selector of document.querySelectorAll('[id^="key_switch_to_"]')) {
287     const action = key_switch_selector.id.slice("key_switch_to_".length);
288     key_switch_selector.parentNode.prepend(mode_helps[action].short + ': ');
289 }
290
291 function restore_selector_value(selector) {
292     let stored_selection = window.localStorage.getItem(selector.id);
293     if (stored_selection) {
294         selector.value = stored_selection;
295     }
296 }
297 restore_selector_value(rows_selector);
298 restore_selector_value(cols_selector);
299 for (let key_selector of key_selectors) {
300     restore_selector_value(key_selector);
301 }
302
303 function escapeHTML(str) {
304     return str.
305         replace(/&/g, '&amp;').
306         replace(/</g, '&lt;').
307         replace(/>/g, '&gt;').
308         replace(/'/g, '&apos;').
309         replace(/"/g, '&quot;');
310 };
311
312 let terminal = {
313   initialize: function() {
314     this.rows = rows_selector.value;
315     this.cols = cols_selector.value;
316     this.pre_el = document.getElementById("terminal");
317     this.set_default_colors();
318     this.apply_colors();
319     this.content = [];
320       let line = []
321     for (let y = 0, x = 0; y <= this.rows; x++) {
322         if (x == this.cols) {
323             x = 0;
324             y += 1;
325             this.content.push(line);
326             line = [];
327             if (y == this.rows) {
328                 break;
329             }
330         }
331         line.push(' ');
332     }
333   },
334   apply_colors: function() {
335     this.pre_el.style.color = this.foreground;
336     this.pre_el.style.backgroundColor = this.background;
337   },
338   set_default_colors: function() {
339       this.foreground = 'white';
340       this.background = 'black';
341       this.apply_colors();
342   },
343   set_random_colors: function() {
344       function rand(offset) {
345           return Math.floor(offset + Math.random() * 96).toString(16).padStart(2, '0');
346       }
347       this.foreground = '#' + rand(159) + rand(159) + rand(159);
348       this.background = '#' + rand(0) + rand(0) + rand(0);
349       this.apply_colors();
350   },
351   blink_screen: function() {
352       this.pre_el.style.color = this.background;
353       this.pre_el.style.backgroundColor = this.foreground;
354       setTimeout(() => {
355           this.pre_el.style.color = this.foreground;
356           this.pre_el.style.backgroundColor = this.background;
357       }, 100);
358   },
359   refresh: function() {
360       let pre_content = '';
361       for (let y = 0; y < this.rows; y++) {
362           let line = this.content[y].join('');
363           let chunks = [];
364           if (y in tui.links) {
365               let start_x = 0;
366               for (let span of tui.links[y]) {
367                   chunks.push(escapeHTML(line.slice(start_x, span[0])));
368                   chunks.push('<a target="_blank" href="');
369                   chunks.push(escapeHTML(span[2]));
370                   chunks.push('">');
371                   chunks.push(escapeHTML(line.slice(span[0], span[1])));
372                   chunks.push('</a>');
373                   start_x = span[1];
374               }
375               chunks.push(escapeHTML(line.slice(start_x)));
376           } else {
377               chunks = [escapeHTML(line)];
378           }
379           for (const chunk of chunks) {
380               pre_content += chunk;
381           }
382           pre_content += '\n';
383       }
384       this.pre_el.innerHTML = pre_content;
385   },
386   write: function(start_y, start_x, msg) {
387       for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
388           this.content[start_y][x] = msg[i];
389       }
390   },
391   drawBox: function(start_y, start_x, height, width) {
392     let end_y = start_y + height;
393     let end_x = start_x + width;
394     for (let y = start_y, x = start_x; y < this.rows; x++) {
395       if (x == end_x) {
396         x = start_x;
397         y += 1;
398         if (y == end_y) {
399             break;
400         }
401       };
402       this.content[y][x] = ' ';
403     }
404   },
405 }
406 terminal.initialize();
407
408 let parser = {
409   tokenize: function(str) {
410     let tokens = [];
411     let token = ''
412     let quoted = false;
413     let escaped = false;
414     for (let i = 0; i < str.length; i++) {
415       let c = str[i];
416       if (quoted) {
417         if (escaped) {
418           token += c;
419           escaped = false;
420         } else if (c == '\\') {
421           escaped = true;
422         } else if (c == '"') {
423           quoted = false
424         } else {
425           token += c;
426         }
427       } else if (c == '"') {
428         quoted = true
429       } else if (c === ' ') {
430         if (token.length > 0) {
431           tokens.push(token);
432           token = '';
433         }
434       } else {
435         token += c;
436       }
437     }
438     if (token.length > 0) {
439       tokens.push(token);
440     }
441     return tokens;
442   },
443   parse_yx: function(position_string) {
444     let coordinate_strings = position_string.split(',')
445     let position = [0, 0];
446     position[0] = parseInt(coordinate_strings[0].slice(2));
447     position[1] = parseInt(coordinate_strings[1].slice(2));
448     return position;
449   },
450 }
451
452 class Thing {
453     constructor(yx) {
454         this.position = yx;
455     }
456 }
457
458 let server = {
459     init: function(url) {
460         this.url = url;
461         this.websocket = new WebSocket(this.url);
462         this.websocket.onopen = function(event) {
463             game.thing_types = {};
464             game.terrains = {};
465             server.send(['TASKS']);
466             server.send(['TERRAINS']);
467             server.send(['THING_TYPES']);
468             tui.log_msg("@ server connected! :)");
469             tui.switch_mode('login');
470         };
471         this.websocket.onclose = function(event) {
472             tui.switch_mode('waiting_for_server');
473             tui.log_msg("@ server disconnected :(");
474         };
475             this.websocket.onmessage = this.handle_event;
476         },
477     reconnect_to: function(url) {
478         this.websocket.close();
479         this.init(url);
480     },
481     send: function(tokens) {
482         this.websocket.send(unparser.untokenize(tokens));
483     },
484     handle_event: function(event) {
485         let tokens = parser.tokenize(event.data);
486         if (tokens[0] === 'TURN') {
487             game.turn_complete = false;
488             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_temp(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='play') {
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');
804     } else if (mode_name == 'name_thing' && !game.player.carrying) {
805         return fail('not carrying anything to re-name');
806     } else if (mode_name == 'admin_thing_protect' && !game.player.carrying) {
807         return fail('not carrying anything to protect')
808     } else if (mode_name == 'take_thing' && game.player.carrying) {
809         return fail('already carrying something');
810     } else if (mode_name == 'drop_thing' && !game.player.carrying) {
811         return fail('not carrying anything droppable');
812     } else if (mode_name == 'enter_hat' && !game.player.hat) {
813         return fail('not wearing hat to edit', 'edit');
814     }
815     if (mode_name == 'admin_enter' && this.is_admin) {
816         mode_name = 'admin';
817     };
818     this.mode = this['mode_' + mode_name];
819     if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
820         this.map_mode = 'protections';
821     } else if (this.mode.name != "edit") {
822         this.map_mode = 'terrain + things';
823     };
824     if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
825         explorer.position = game.player.position;
826     }
827     this.inputEl.value = "";
828     this.restore_input_values();
829     for (let el of document.getElementsByTagName("button")) {
830         el.disabled = true;
831     }
832     document.getElementById("help").disabled = false;
833     for (const action of this.mode.available_actions) {
834         if (["move", "move_explorer"].includes(action)) {
835             for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
836                 move_key.disabled = false;
837             }
838         } else if (Object.keys(this.action_tasks).includes(action)) {
839             if (this.task_action_on(action)) {
840                 document.getElementById(action).disabled = false;
841             }
842         } else {
843             document.getElementById(action).disabled = false;
844         };
845     }
846     for (const mode_name of this.mode.available_modes) {
847             document.getElementById('switch_to_' + mode_name).disabled = false;
848     }
849     if (this.mode.intro_msg.length > 0) {
850         this.log_msg(this.mode.intro_msg);
851     }
852     if (this.mode.name == 'login') {
853         if (this.login_name) {
854             server.send(['LOGIN', this.login_name]);
855         } else {
856             this.log_msg("? need login name");
857         }
858     } else if (this.mode.is_single_char_entry) {
859         this.show_help = true;
860     } else if (this.mode.name == 'take_thing') {
861         this.log_msg("Portable things in reach for pick-up:");
862         const y = game.player.position[0]
863         const x = game.player.position[1]
864         let directed_moves = {
865             'HERE': [0, 0], 'LEFT': [0, -1], 'RIGHT': [0, 1]
866         }
867         if (game.map_geometry == 'Square') {
868             directed_moves['UP'] = [-1, 0];
869             directed_moves['DOWN'] = [1, 0];
870         } else if (game.map_geometry == 'Hex') {
871             if (y % 2) {
872                 directed_moves['UPLEFT'] = [-1, 0];
873                 directed_moves['UPRIGHT'] = [-1, 1];
874                 directed_moves['DOWNLEFT'] = [1, 0];
875                 directed_moves['DOWNRIGHT'] = [1, 1];
876             } else {
877                 directed_moves['UPLEFT'] = [-1, -1];
878                 directed_moves['UPRIGHT'] = [-1, 0];
879                 directed_moves['DOWNLEFT'] = [1, -1];
880                 directed_moves['DOWNRIGHT'] = [1, 0];
881             }
882         }
883         console.log(directed_moves);
884         let select_range = {};
885         for (const direction in directed_moves) {
886             const move = directed_moves[direction];
887             select_range[direction] = [y + move[0], x + move[1]];
888         }
889         this.selectables = [];
890         let directions = [];
891         for (const direction in select_range) {
892             for (const t_id in game.things) {
893                 const t = game.things[t_id];
894                 const position = select_range[direction];
895                 if (t.portable
896                     && t.position[0] == position[0]
897                     && t.position[1] == position[1]) {
898                     this.selectables.push(t_id);
899                     directions.push(direction);
900                 }
901             }
902         }
903         if (this.selectables.length == 0) {
904             this.log_msg('none');
905             terminal.blink_screen();
906             this.switch_mode('play');
907             return;
908         } else {
909             for (let [i, t_id] of this.selectables.entries()) {
910                 const t = game.things[t_id];
911                 const direction = directions[i];
912                 this.log_msg(i + ' ' + direction + ': ' + explorer.get_thing_info(t));
913             }
914         }
915     } else if (this.mode.name == 'drop_thing') {
916         this.log_msg('Direction to drop thing to:');
917         this.selectables = ['HERE'].concat(Object.values(this.movement_keys));
918         for (let [i, direction] of this.selectables.entries()) {
919             this.log_msg(i + ': ' + direction);
920         };
921     } else if (this.mode.name == 'enter_hat') {
922         this.log_msg('legal characters: ' + game.players_hat_chars);
923     } else if (this.mode.name == 'command_thing') {
924         server.send(['TASK:COMMAND', 'HELP']);
925     } else if (this.mode.name == 'control_pw_pw') {
926         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
927     } else if (this.mode.name == 'control_tile_draw') {
928         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 + '].')
929     }
930     this.full_refresh();
931   },
932   offset_links: function(offset, links) {
933       for (let y in links) {
934           let real_y = offset[0] + parseInt(y);
935           if (!this.links[real_y]) {
936               this.links[real_y] = [];
937           }
938           for (let link of links[y]) {
939               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
940               this.links[real_y].push(offset_link);
941           }
942       }
943   },
944   restore_input_values: function() {
945       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
946           let info = explorer.annotations[explorer.position];
947           if (info != "(none)") {
948               this.inputEl.value = info;
949           }
950       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
951           let portal = game.portals[explorer.position]
952           this.inputEl.value = portal;
953       } else if (this.mode.name == 'password') {
954           this.inputEl.value = this.password;
955       } else if (this.mode.name == 'name_thing') {
956           if (game.player.carrying && game.player.carrying.name_) {
957               this.inputEl.value = game.player.carrying.name_;
958           }
959       } else if (this.mode.name == 'admin_thing_protect') {
960           if (game.player.carrying && game.player.carrying.protection) {
961               this.inputEl.value = game.player.carrying.protection;
962           }
963       } else if (['enter_face', 'enter_hat'].includes(this.mode.name)) {
964           const start = this.ascii_draw_stage * 6;
965           const end = (this.ascii_draw_stage + 1) * 6;
966           if (this.mode.name == 'enter_face') {
967               this.inputEl.value = game.player.face.slice(start, end);
968           } else if (this.mode.name == 'enter_hat') {
969               this.inputEl.value = game.player.hat.slice(start, end);
970           }
971       }
972   },
973   recalc_input_lines: function() {
974       if (this.mode.has_input_prompt) {
975           let _ = null;
976           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value + '█', this.window_width);
977       } else {
978           this.input_lines = [];
979       }
980       this.height_input = this.input_lines.length;
981   },
982   msg_into_lines_of_width: function(msg, width) {
983       function push_inner_link(y, end_x) {
984           if (!inner_links[y]) {
985               inner_links[y] = [];
986           };
987           inner_links[y].push([url_start_x, end_x, url]);
988       };
989       const matches = msg.matchAll(/https?:\/\/[^\s]+/g)
990       let link_data = {};
991       let url_ends = [];
992       for (const match of matches) {
993           const url = match[0];
994           const url_start = match.index;
995           const url_end = match.index + match[0].length;
996           link_data[url_start] = url;
997           url_ends.push(url_end);
998       }
999       let url_start_x = 0;
1000       let url = '';
1001       let inner_links = {};
1002       let in_link = false;
1003       let chunk = "";
1004       let lines = [];
1005       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
1006           if (x >= width || msg[i] == "\n") {
1007               if (in_link) {
1008                   push_inner_link(y, chunk.length);
1009                   url_start_x = 0;
1010                   if (url_ends[0] == i) {
1011                       in_link = false;
1012                       url_ends.shift();
1013                   }
1014               };
1015               lines.push(chunk);
1016               chunk = "";
1017               x = 0;
1018               if (msg[i] == "\n") {
1019                   x -= 1;
1020               };
1021               y += 1;
1022           };
1023           if (msg[i] != "\n") {
1024               chunk += msg[i];
1025           };
1026           if (i in link_data) {
1027               url_start_x = x;
1028               url = link_data[i];
1029               in_link = true;
1030           } else if (url_ends[0] == i) {
1031               url_ends.shift();
1032               push_inner_link(y, x);
1033               in_link = false;
1034           }
1035       }
1036       lines.push(chunk);
1037       if (in_link) {
1038           push_inner_link(lines.length - 1, chunk.length);
1039       }
1040       return [lines, inner_links];
1041   },
1042   log_msg: function(msg) {
1043       this.log.push(msg);
1044       while (this.log.length > 100) {
1045         this.log.shift();
1046       };
1047       this.full_refresh();
1048   },
1049   pick_selectable: function(task_name) {
1050       const i = parseInt(this.inputEl.value);
1051       if (isNaN(this.inputEl.value) || i < 0 || i >= this.selectables.length) {
1052           tui.log_msg('? invalid index, aborted');
1053       } else {
1054           server.send(['TASK:' + task_name, tui.selectables[i]]);
1055       }
1056       this.inputEl.value = "";
1057       this.switch_mode('play');
1058   },
1059   enter_ascii_art: function(command) {
1060       if (this.inputEl.value.length != 6) {
1061           this.log_msg('? wrong input length, must be 6; try again');
1062           return;
1063       }
1064       this.log_msg('  ' + this.inputEl.value);
1065       this.full_ascii_draw += this.inputEl.value;
1066       this.ascii_draw_stage += 1;
1067       if (this.ascii_draw_stage < 3) {
1068           this.restore_input_values();
1069       } else {
1070           server.send([command, this.full_ascii_draw]);
1071           this.full_ascii_draw = '';
1072           this.ascii_draw_stage = 0;
1073           this.inputEl.value = '';
1074           this.switch_mode('edit');
1075       }
1076   },
1077   draw_map: function() {
1078     if (!game.turn_complete && this.map_lines.length == 0) {
1079         return;
1080     }
1081     if (game.turn_complete) {
1082         let map_lines_split = [];
1083         let line = [];
1084         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
1085             if (j == game.map_size[1]) {
1086                 map_lines_split.push(line);
1087                 line = [];
1088                 j = 0;
1089             };
1090             if (this.map_mode == 'protections') {
1091                 line.push(game.map_control[i] + ' ');
1092             } else {
1093                 line.push(game.map[i] + ' ');
1094             }
1095         };
1096         map_lines_split.push(line);
1097         if (this.map_mode == 'terrain + annotations') {
1098             for (const [coordinate, _] of Object.entries(explorer.annotations)) {
1099                 const yx = coordinate.split(',')
1100                 map_lines_split[yx[0]][yx[1]] = 'A ';
1101             }
1102         } else if (this.map_mode == 'terrain + things') {
1103             for (const p in game.portals) {
1104                 let coordinate = p.split(',')
1105                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1106                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1107             }
1108             let used_positions = [];
1109             function draw_thing(t, used_positions) {
1110                 let symbol = game.thing_types[t.type_];
1111                 let meta_char = ' ';
1112                 if (t.thing_char) {
1113                     meta_char = t.thing_char;
1114                 }
1115                 if (used_positions.includes(t.position.toString())) {
1116                     meta_char = '+';
1117                 };
1118                 if (t.carrying) {
1119                     meta_char = '$';
1120                 }
1121                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1122                 used_positions.push(t.position.toString());
1123             }
1124             for (const thing_id in game.things) {
1125                 let t = game.things[thing_id];
1126                 if (t.type_ != 'Player') {
1127                     draw_thing(t, used_positions);
1128                 }
1129             };
1130             for (const thing_id in game.things) {
1131                 let t = game.things[thing_id];
1132                 if (t.type_ == 'Player') {
1133                     draw_thing(t, used_positions);
1134                 }
1135             };
1136         }
1137         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1138             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1139         } else if (tui.map_mode != 'terrain + things') {
1140             map_lines_split[game.player.position[0]][game.player.position[1]] = '??';
1141         }
1142         this.map_lines = []
1143         if (game.map_geometry == 'Square') {
1144             for (let line_split of map_lines_split) {
1145                 this.map_lines.push(line_split.join(''));
1146             };
1147         } else if (game.map_geometry == 'Hex') {
1148             let indent = 0
1149             for (let line_split of map_lines_split) {
1150                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1151                 if (indent == 0) {
1152                     indent = 1;
1153                 } else {
1154                     indent = 0;
1155                 };
1156             };
1157         }
1158         let window_center = [terminal.rows / 2, this.window_width / 2];
1159         let center_position = [game.player.position[0], game.player.position[1]];
1160         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1161             center_position = [explorer.position[0], explorer.position[1]];
1162         }
1163         center_position[1] = center_position[1] * 2;
1164         this.offset = [center_position[0] - window_center[0],
1165                        center_position[1] - window_center[1]]
1166         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1167             this.offset[1] += 1;
1168         };
1169     };
1170     let term_y = Math.max(0, -this.offset[0]);
1171     let term_x = Math.max(0, -this.offset[1]);
1172     let map_y = Math.max(0, this.offset[0]);
1173     let map_x = Math.max(0, this.offset[1]);
1174     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1175         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1176         terminal.write(term_y, term_x, to_draw);
1177     }
1178   },
1179   draw_face_popup: function() {
1180       const t = game.things[this.draw_face];
1181       if (!t || !t.face) {
1182           this.draw_face = false;
1183           return;
1184       }
1185       const start_x = tui.window_width - 10;
1186       let t_char = ' ';
1187       if (t.thing_char) {
1188           t_char = t.thing_char;
1189       }
1190       function draw_body_part(body_part, end_y) {
1191           terminal.write(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ');
1192           terminal.write(end_y - 3, start_x, '|        |');
1193           terminal.write(end_y - 2, start_x, '| ' + body_part.slice(0, 6) + ' |');
1194           terminal.write(end_y - 1, start_x, '| ' + body_part.slice(6, 12) + ' |');
1195           terminal.write(end_y, start_x, '| ' + body_part.slice(12, 18) + ' |');
1196       }
1197       if (t.face) {
1198           draw_body_part(t.face, terminal.rows - 2);
1199       }
1200       if (t.hat) {
1201           draw_body_part(t.hat, terminal.rows - 5);
1202       }
1203       terminal.write(terminal.rows - 1, start_x, '|        |');
1204   },
1205   draw_mode_line: function() {
1206       let help = 'hit [' + this.keys.help + '] for help';
1207       if (this.mode.has_input_prompt) {
1208           help = 'enter /help for help';
1209       }
1210       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1211   },
1212   draw_turn_line: function(n) {
1213       if (game.turn_complete) {
1214           terminal.write(1, this.window_width, 'TURN: ' + game.turn);
1215       }
1216   },
1217   draw_history: function() {
1218       let log_display_lines = [];
1219       let log_links = {};
1220       let y_offset_in_log = 0;
1221       for (let line of this.log) {
1222           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1223                                                                     this.window_width)
1224           log_display_lines = log_display_lines.concat(new_lines);
1225           for (const y in link_data) {
1226               const rel_y = y_offset_in_log + parseInt(y);
1227               log_links[rel_y] = [];
1228               for (let link of link_data[y]) {
1229                   log_links[rel_y].push(link);
1230               }
1231           }
1232           y_offset_in_log += new_lines.length;
1233       };
1234       let i = log_display_lines.length - 1;
1235       for (let y = terminal.rows - 1 - this.height_input;
1236            y >= this.height_header && i >= 0;
1237            y--, i--) {
1238           terminal.write(y, this.window_width, log_display_lines[i]);
1239       }
1240       for (const key of Object.keys(log_links)) {
1241           if (parseInt(key) <= i) {
1242               delete log_links[key];
1243           }
1244       }
1245       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1246                     this.window_width];
1247       this.offset_links(offset, log_links);
1248   },
1249   draw_info: function() {
1250       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1251       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1252       let offset = [this.height_header, this.window_width];
1253       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1254         terminal.write(y, offset[1], lines[i]);
1255       }
1256       this.offset_links(offset, link_data);
1257   },
1258   draw_input: function() {
1259     if (this.mode.has_input_prompt) {
1260         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1261             terminal.write(y, this.window_width, this.input_lines[i]);
1262         }
1263     }
1264   },
1265   draw_help: function() {
1266       let movement_keys_desc = '';
1267       if (!this.mode.is_intro) {
1268           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1269       }
1270       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1271       if (this.mode.available_actions.length > 0) {
1272           content += "Available actions:\n";
1273           for (let action of this.mode.available_actions) {
1274               if (Object.keys(this.action_tasks).includes(action)) {
1275                   if (!this.task_action_on(action)) {
1276                       continue;
1277                   }
1278               }
1279               if (action == 'move_explorer') {
1280                   action = 'move';
1281               }
1282               if (action == 'move') {
1283                   content += "[" + movement_keys_desc + "] – move\n"
1284               } else {
1285                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1286               }
1287           }
1288           content += '\n';
1289       }
1290       content += this.mode.list_available_modes();
1291       let start_x = 0;
1292       if (!this.mode.has_input_prompt) {
1293           start_x = this.window_width;
1294           this.draw_links = false;
1295       }
1296       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1297       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1298       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1299           terminal.write(y, start_x, lines[i]);
1300       }
1301   },
1302   toggle_tile_draw: function() {
1303       if (tui.tile_draw) {
1304           tui.tile_draw = false;
1305       } else {
1306           tui.tile_draw = true;
1307       }
1308   },
1309   toggle_map_mode: function() {
1310       if (tui.map_mode == 'terrain only') {
1311           tui.map_mode = 'terrain + annotations';
1312       } else if (tui.map_mode == 'terrain + annotations') {
1313           tui.map_mode = 'terrain + things';
1314       } else if (tui.map_mode == 'terrain + things') {
1315           tui.map_mode = 'protections';
1316       } else if (tui.map_mode == 'protections') {
1317           tui.map_mode = 'terrain only';
1318       }
1319   },
1320   full_refresh: function() {
1321     this.draw_links = true;
1322     this.links = {};
1323     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1324     this.recalc_input_lines();
1325     if (this.mode.is_intro) {
1326         this.draw_history();
1327         this.draw_input();
1328     } else {
1329         this.draw_map();
1330         this.draw_turn_line();
1331         this.draw_mode_line();
1332         if (this.mode.shows_info) {
1333           this.draw_info();
1334         } else {
1335           this.draw_history();
1336         }
1337         this.draw_input();
1338     }
1339     if (this.show_help) {
1340         this.draw_help();
1341     }
1342     if (this.draw_face && ['chat', 'play'].includes(this.mode.name)) {
1343         this.draw_face_popup();
1344     }
1345     if (!this.draw_links) {
1346         this.links = {};
1347     }
1348     terminal.refresh();
1349   }
1350 }
1351
1352 let game = {
1353     init: function() {
1354         this.turn = -1;
1355         this.player_id = -1;
1356         this.tasks = {};
1357         this.things = {};
1358         this.things_new = {};
1359         this.fov = "";
1360         this.fov_new = "";
1361         this.map = "";
1362         this.map_new = "";
1363         this.map_control = "";
1364         this.map_control_new = "";
1365         this.map_size = [0,0];
1366         this.map_size_new = [0,0];
1367         this.portals = {};
1368         this.portals_new = {};
1369         this.players_hat_chars = "";
1370     },
1371     get_thing_temp: function(id_, create_if_not_found=false) {
1372         if (id_ in game.things_new) {
1373             return game.things_new[id_];
1374         } else if (create_if_not_found) {
1375             let t = new Thing([0,0]);
1376             game.things_new[id_] = t;
1377             return t;
1378         };
1379     },
1380     get_thing: function(id_, create_if_not_found=false) {
1381         if (id_ in game.things) {
1382             return game.things[id_];
1383         };
1384     },
1385     move: function(start_position, direction) {
1386         let target = [start_position[0], start_position[1]];
1387         if (direction == 'LEFT') {
1388             target[1] -= 1;
1389         } else if (direction == 'RIGHT') {
1390             target[1] += 1;
1391         } else if (game.map_geometry == 'Square') {
1392             if (direction == 'UP') {
1393                 target[0] -= 1;
1394             } else if (direction == 'DOWN') {
1395                 target[0] += 1;
1396             };
1397         } else if (game.map_geometry == 'Hex') {
1398             let start_indented = start_position[0] % 2;
1399             if (direction == 'UPLEFT') {
1400                 target[0] -= 1;
1401                 if (!start_indented) {
1402                     target[1] -= 1;
1403                 }
1404             } else if (direction == 'UPRIGHT') {
1405                 target[0] -= 1;
1406                 if (start_indented) {
1407                     target[1] += 1;
1408                 }
1409             } else if (direction == 'DOWNLEFT') {
1410                 target[0] += 1;
1411                 if (!start_indented) {
1412                     target[1] -= 1;
1413                 }
1414             } else if (direction == 'DOWNRIGHT') {
1415                 target[0] += 1;
1416                 if (start_indented) {
1417                     target[1] += 1;
1418                 }
1419             };
1420         };
1421         if (target[0] < 0 || target[1] < 0 ||
1422             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1423             return null;
1424         };
1425         return target;
1426     },
1427     teleport: function() {
1428         if (game.player.position in this.portals) {
1429             server.reconnect_to(this.portals[game.player.position]);
1430         } else {
1431             terminal.blink_screen();
1432             tui.log_msg('? not standing on portal')
1433         }
1434     }
1435 }
1436
1437 game.init();
1438 tui.init();
1439 tui.full_refresh();
1440 server.init(websocket_location);
1441
1442 let explorer = {
1443     position: [0,0],
1444     annotations: {},
1445     annotations_new: {},
1446     info_cached: false,
1447     move: function(direction) {
1448         let target = game.move(this.position, direction);
1449         if (target) {
1450             this.position = target
1451             this.info_cached = false;
1452             if (tui.tile_draw) {
1453                 this.send_tile_control_command();
1454             }
1455         } else {
1456             terminal.blink_screen();
1457         };
1458     },
1459     get_info: function() {
1460         if (this.info_cached) {
1461             return this.info_cached;
1462         }
1463         let info_to_cache = '';
1464         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1465         if (game.fov[position_i] != '.') {
1466             info_to_cache += 'outside field of view';
1467         } else {
1468             for (let t_id in game.things) {
1469                  let t = game.things[t_id];
1470                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1471                      info_to_cache += "THING: " + this.get_thing_info(t);
1472                      let protection = t.protection;
1473                      if (protection == '.') {
1474                          protection = 'none';
1475                      }
1476                      info_to_cache += " / protection: " + protection + "\n";
1477                      if (t.hat) {
1478                          info_to_cache += t.hat.slice(0, 6) + '\n';
1479                          info_to_cache += t.hat.slice(6, 12) + '\n';
1480                          info_to_cache += t.hat.slice(12, 18) + '\n';
1481                      }
1482                      if (t.face) {
1483                          info_to_cache += t.face.slice(0, 6) + '\n';
1484                          info_to_cache += t.face.slice(6, 12) + '\n';
1485                          info_to_cache += t.face.slice(12, 18) + '\n';
1486                      }
1487                  }
1488             }
1489             let terrain_char = game.map[position_i]
1490             let terrain_desc = '?'
1491             if (game.terrains[terrain_char]) {
1492                 terrain_desc = game.terrains[terrain_char];
1493             };
1494             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1495             let protection = game.map_control[position_i];
1496             if (protection == '.') {
1497                 protection = 'unprotected';
1498             };
1499             info_to_cache += 'PROTECTION: ' + protection + '\n';
1500             if (this.position in game.portals) {
1501                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1502             }
1503             if (this.position in this.annotations) {
1504                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1505             }
1506         }
1507         this.info_cached = info_to_cache;
1508         return this.info_cached;
1509     },
1510     get_thing_info: function(t) {
1511         const symbol = game.thing_types[t.type_];
1512         let info = t.type_ + " / " + symbol;
1513         if (t.thing_char) {
1514             info += t.thing_char;
1515         };
1516         if (t.name_) {
1517             info += " (" + t.name_ + ")";
1518         }
1519         if (t.installed) {
1520             info += " / installed";
1521         }
1522         return info;
1523     },
1524     annotate: function(msg) {
1525         if (msg.length == 0) {
1526             msg = " ";  // triggers annotation deletion
1527         }
1528         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1529     },
1530     set_portal: function(msg) {
1531         if (msg.length == 0) {
1532             msg = " ";  // triggers portal deletion
1533         }
1534         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1535     },
1536     send_tile_control_command: function() {
1537         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1538     }
1539 }
1540
1541 tui.inputEl.addEventListener('input', (event) => {
1542     if (tui.mode.has_input_prompt) {
1543         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1544         if (tui.inputEl.value.length > max_length) {
1545             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1546         };
1547     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1548         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1549         tui.switch_mode('edit');
1550     }
1551     tui.full_refresh();
1552 }, false);
1553 document.onclick = function() {
1554     if (!tui.mode.is_single_char_entry) {
1555         tui.show_help = false;
1556     }
1557 };
1558 tui.inputEl.addEventListener('keydown', (event) => {
1559     tui.show_help = false;
1560     if (['Enter', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
1561         event.preventDefault();
1562     }
1563     if ((!tui.mode.is_intro && event.key == 'Escape')
1564         || (tui.mode.has_input_prompt && event.key == 'Enter'
1565             && tui.inputEl.value.length == 0
1566             && ['chat', 'command_thing', 'take_thing', 'drop_thing',
1567                 'admin_enter'].includes(tui.mode.name))) {
1568         if (!['chat', 'play', 'study', 'edit'].includes(tui.mode.name)) {
1569             tui.log_msg('@ aborted');
1570         }
1571         tui.switch_mode('play');
1572     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1573         tui.show_help = true;
1574         tui.inputEl.value = "";
1575         tui.restore_input_values();
1576     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1577                && !tui.mode.is_single_char_entry) {
1578         tui.show_help = true;
1579     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1580         tui.login_name = tui.inputEl.value;
1581         server.send(['LOGIN', tui.inputEl.value]);
1582         tui.inputEl.value = "";
1583     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1584         tui.enter_ascii_art('PLAYER_FACE');
1585     } else if (tui.mode.name == 'enter_hat' && event.key == 'Enter') {
1586         tui.enter_ascii_art('PLAYER_HAT');
1587     } else if (tui.mode.name == 'command_thing' && event.key == 'Enter') {
1588         server.send(['TASK:COMMAND', tui.inputEl.value]);
1589         tui.inputEl.value = "";
1590     } else if (tui.mode.name == 'take_thing' && event.key == 'Enter') {
1591         tui.pick_selectable('PICK_UP');
1592     } else if (tui.mode.name == 'drop_thing' && event.key == 'Enter') {
1593         tui.pick_selectable('DROP');
1594     } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1595         if (tui.inputEl.value.length == 0) {
1596             tui.log_msg('@ aborted');
1597         } else {
1598             server.send(['SET_MAP_CONTROL_PASSWORD',
1599                         tui.tile_control_char, tui.inputEl.value]);
1600             tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1601         }
1602         tui.switch_mode('admin');
1603     } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1604         explorer.set_portal(tui.inputEl.value);
1605         tui.switch_mode('edit');
1606     } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1607         if (tui.inputEl.value.length == 0) {
1608             tui.inputEl.value = " ";
1609         }
1610         server.send(["THING_NAME", tui.inputEl.value, 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.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>