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