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