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