home · contact · privacy
Replace hard-coded client login welcome messages with optional server login messages.
[plomrogue2] / plomrogue / things.py
1 from plomrogue.errors import GameError, PlayError
2 from plomrogue.mapping import YX, FovMap
3 from plomrogue.misc import quote
4 import random
5
6
7
8 class ThingBase:
9     type_ = '?'
10     carrying = False
11
12     def __init__(self, game, id_=0, position=(YX(0, 0), YX(0, 0))):
13         self.game = game
14         if id_ == 0:
15             self.id_ = self.game.new_thing_id()
16         else:
17             self.id_ = id_
18         self.position = position
19
20
21
22 class Thing(ThingBase):
23     blocks_movement = False
24     blocks_sound = False
25     blocks_light = False
26     portable = False
27     protection = '.'
28     commandable = False
29     cookable = False
30     carried = False
31     consumable = False
32     sittable = False
33
34     def __init__(self, *args, **kwargs):
35         super().__init__(*args, **kwargs)
36
37     def proceed(self):
38         pass
39
40     @property
41     def type_(self):
42         return self.__class__.get_type()
43
44     @classmethod
45     def get_type(cls):
46         return cls.__name__[len('Thing_'):]
47
48     def sound(self, name, msg):
49         from plomrogue.mapping import DijkstraMap
50         import re
51
52         def lower_msg_by_volume(msg, volume, largest_audible_distance,
53                                 url_limits = []):
54             factor = largest_audible_distance / 2
55             lowered_msg = ''
56             in_url = False
57             i = 0
58             for c in msg:
59                 c = c
60                 if i in url_limits:
61                     in_url = False if in_url else True
62                 if not in_url:
63                     while random.random() > volume * factor:
64                         if c.isupper():
65                             c = c.lower()
66                         elif c != '.' and c != ' ':
67                             c = '.'
68                         else:
69                             c = ' '
70                 lowered_msg += c
71                 i += 1
72             return lowered_msg
73
74         largest_audible_distance = 20
75         obstacles = [t.position for t in self.game.things if t.blocks_sound]
76         targets = [t.position for t in self.game.things if t.type_ == 'Player']
77         sound_blockers = self.game.get_sound_blockers()
78         dijkstra_map = DijkstraMap(targets, sound_blockers, obstacles,
79                                    self.game.maps, self.position,
80                                    largest_audible_distance, self.game.get_map)
81         url_limits = []
82         for m in re.finditer('https?://[^\s]+', msg):
83             url_limits += [m.start(), m.end()]
84         for c_id in self.game.sessions:
85             listener = self.game.get_player(c_id)
86             target_yx = dijkstra_map.target_yx(*listener.position, True)
87             if not target_yx:
88                 continue
89             listener_distance = dijkstra_map[target_yx]
90             if listener_distance > largest_audible_distance:
91                 continue
92             volume = 1 / max(1, listener_distance)
93             lowered_msg = lower_msg_by_volume(msg, volume,
94                                               largest_audible_distance,
95                                               url_limits)
96             lowered_nick = lower_msg_by_volume(name, volume,
97                                                largest_audible_distance)
98             symbol = ''
99             # if listener.fov_test(self.position[0], self.position[1]):
100             # TODO: We might want to only show chat faces of players that are
101             # in the listener's FOV.  However, if we do a fov_test here,
102             # this might set up a listener._fov where previously there was None,
103             # with ._fov = None serving to Game.send_gamestate() as an indicator
104             # that map view data for listener might be subject to change and
105             # therefore needs to be re-sent.  If we generate an un-set ._fov
106             # here, this inhibits send_gamestate() from sending new map view
107             # data to listener.  We need to re-structure this whole process
108             # if we want to use a FOV test on listener here.
109             if listener_distance < largest_audible_distance / 2:
110                 self.game.io.send('CHATFACE %s' % self.id_, c_id)
111                 if self.type_ == 'Player' and hasattr(self, 'thing_char'):
112                     symbol = '/@' + self.thing_char
113             self.game.io.send('CHAT ' +
114                               quote('vol:%.f%s %s%s: %s' % (volume * 100, '%',
115                                                             lowered_nick, symbol,
116                                                             lowered_msg)),
117                               c_id)
118
119
120
121 class Thing_Item(Thing):
122     symbol_hint = 'i'
123     portable = True
124
125
126
127 class ThingSpawner(Thing):
128     symbol_hint = 'S'
129
130     def proceed(self):
131         for t in [t for t in self.game.things
132                   if t != self and t.position == self.position]:
133             return None
134         return self.game.add_thing(self.child_type, self.position)
135
136
137
138 class Thing_ItemSpawner(ThingSpawner):
139     child_type = 'Item'
140
141
142
143 class Thing_SpawnPointSpawner(ThingSpawner):
144     child_type = 'SpawnPoint'
145
146
147
148 class Thing_SpawnPoint(Thing):
149     symbol_hint = 's'
150     portable = True
151     name = 'username'
152     temporary = False
153
154     def __init__(self, *args, **kwargs):
155         super().__init__(*args, **kwargs)
156         self.created_at = datetime.datetime.now()
157
158     def proceed(self):
159         super().proceed()
160         if self.temporary and datetime.datetime.now() >\
161            self.created_at + datetime.timedelta(minutes=10):
162             self.game.remove_thing(self)
163
164
165
166 class ThingInstallable(Thing):
167     portable = True
168     installable = True
169
170     def install(self):
171         self.portable = False
172
173     def uninstall(self):
174         self.portable = True
175
176
177
178 class Thing_SignSpawner(ThingSpawner):
179     child_type = 'Sign'
180
181
182
183 class Thing_Sign(ThingInstallable):
184     symbol_hint = '?'
185     design_size = YX(16, 36)
186
187     def __init__(self, *args, **kwargs):
188         super().__init__(*args, **kwargs)
189         self.design = 'x' * self.design_size.y * self.design_size.x
190
191
192
193 class Thing_DoorSpawner(ThingSpawner):
194     child_type = 'Door'
195
196     def proceed(self):
197         door = super().proceed()
198         if door:
199             key = self.game.add_thing('DoorKey', self.position)
200             key.door = door
201
202
203
204 class Thing_DoorKey(Thing):
205     portable = True
206     symbol_hint = 'k'
207
208
209
210
211 class Thing_Door(ThingInstallable):
212     symbol_hint = 'D'
213     blocks_movement = False
214     locked = False
215
216     def open(self):
217         self.blocks_movement = False
218         self.blocks_light = False
219         self.blocks_sound = False
220         self.locked = False
221         del self.thing_char
222
223     def close(self):
224         self.blocks_movement = True
225         self.blocks_light = True
226         self.blocks_sound = True
227         self.thing_char = '#'
228
229     def lock(self):
230         self.locked = True
231         self.thing_char = 'L'
232
233
234
235 class Thing_Psychedelic(Thing):
236     symbol_hint = 'P'
237     portable = True
238     cookable = True
239     consumable = True
240
241
242
243 class Thing_PsychedelicSpawner(ThingSpawner):
244     symbol_hint = 'P'
245     child_type = 'Psychedelic'
246
247
248
249 class Thing_Bottle(Thing):
250     symbol_hint = 'B'
251     portable = True
252     full = True
253     thing_char = '~'
254     spinnable = True
255     cookable = True
256     consumable = True
257
258     def empty(self):
259         self.thing_char = '_'
260         self.full = False
261
262     def spin(self):
263         all_players = [t for t in self.game.things if t.type_ == 'Player']
264         # TODO: refactor with ThingPlayer.prepare_multiprocessible_fov_stencil
265         # and ThingPlayer.fov_test
266         fov_radius = 12
267         light_blockers = self.game.get_light_blockers()
268         obstacles = [t.position for t in self.game.things if t.blocks_light]
269         fov = FovMap(light_blockers, obstacles, self.game.maps,
270                      self.position, fov_radius, self.game.get_map)
271         fov.init_terrain()
272         visible_players = []
273         for p in all_players:
274             test_position = fov.target_yx(p.position[0], p.position[1])
275             if fov.inside(test_position) and fov[test_position] == '.':
276                 visible_players += [p]
277         if len(visible_players) == 0:
278             self.sound('BOTTLE', 'no visible players in spin range')
279         pick = random.choice(visible_players)
280         self.sound('BOTTLE', 'BOTTLE picks: ' + pick.name)
281
282
283
284 class Thing_BottleSpawner(ThingSpawner):
285     child_type = 'Bottle'
286
287
288
289 class Thing_Hat(Thing):
290     symbol_hint = 'H'
291     portable = True
292     design = ' +--+ ' + ' |  | ' + '======'
293     spinnable = True
294     cookable = True
295     design_size = YX(3, 6)
296
297     def spin(self):
298         new_design = ''
299         new_design += self.design[12]
300         new_design += self.design[13]
301         new_design += self.design[6]
302         new_design += self.design[7]
303         new_design += self.design[0]
304         new_design += self.design[1]
305         new_design += self.design[14]
306         new_design += self.design[15]
307         new_design += self.design[8]
308         new_design += self.design[9]
309         new_design += self.design[2]
310         new_design += self.design[3]
311         new_design += self.design[16]
312         new_design += self.design[17]
313         new_design += self.design[10]
314         new_design += self.design[11]
315         new_design += self.design[4]
316         new_design += self.design[5]
317         self.design = ''.join(new_design)
318
319
320
321 class Thing_HatRemixer(Thing):
322     symbol_hint = 'H'
323
324     def accept(self, hat):
325         import string
326         new_design = ''
327         legal_chars = string.ascii_letters + string.digits + string.punctuation + ' '
328         for i in range(18):
329             new_design += random.choice(list(legal_chars))
330         hat.design = new_design
331         self.sound('HAT REMIXER', 'remixing a hat …')
332         self.game.changed = True
333         self.game.record_change(self.position, 'other')
334
335
336
337 import datetime
338 class Thing_MusicPlayer(Thing):
339     symbol_hint = 'R'
340     commandable = True
341     portable = True
342     repeat = True
343     next_song_start = datetime.datetime.now()
344     playlist_index = -1
345     playing = True
346     cookable = True
347
348     def __init__(self, *args, **kwargs):
349         super().__init__(*args, **kwargs)
350         self.next_song_start = datetime.datetime.now()
351         self.playlist = []
352
353     def proceed(self):
354         if (not self.playing) or len(self.playlist) == 0:
355             return
356         if datetime.datetime.now() > self.next_song_start:
357             self.playlist_index += 1
358             if self.playlist_index == len(self.playlist):
359                 self.playlist_index = 0
360                 if not self.repeat:
361                     self.playing = False
362                     return
363             song_data = self.playlist[self.playlist_index]
364             self.next_song_start = datetime.datetime.now() +\
365                 datetime.timedelta(seconds=song_data[1])
366             self.sound('MUSICPLAYER', song_data[0])
367             self.game.changed = True
368
369     def interpret(self, command):
370         msg_lines = []
371         if command == 'HELP':
372             msg_lines += ['available commands:']
373             msg_lines += ['HELP – show this help']
374             msg_lines += ['ON/OFF – toggle playback on/off']
375             msg_lines += ['REWIND – return to start of playlist']
376             msg_lines += ['LIST – list programmed item, durations']
377             msg_lines += ['REMOVE – remove current item']
378             msg_lines += ['SKIP – to skip to next item']
379             msg_lines += ['REPEAT – toggle playlist repeat on/off']
380             msg_lines += ['ADD LENGTH ITEM – add ITEM to playlist, with LENGTH in format "minutes:seconds" (something like "0:47" or "11:02")']
381             return msg_lines
382         elif command == 'LIST':
383             msg_lines += ['playlist:']
384             i = 0
385             for entry in self.playlist:
386                 minutes = entry[1] // 60
387                 seconds = entry[1] % 60
388                 if seconds < 10:
389                     seconds = '0%s' % seconds
390                 selector = 'next:' if i == self.playlist_index else '     '
391                 msg_lines += ['%s %s:%s – %s' % (selector, minutes, seconds, entry[0])]
392                 i += 1
393             return msg_lines
394         elif command == 'ON/OFF':
395             self.playing = False if self.playing else True
396             self.game.changed = True
397             if self.playing:
398                 return ['playing']
399             else:
400                 return ['paused']
401         elif command == 'REMOVE':
402             if len(self.playlist) == 0:
403                 return ['playlist already empty']
404             del self.playlist[max(0, self.playlist_index)]
405             self.playlist_index -= 1
406             if self.playlist_index < -1:
407                 self.playlist_index = -1
408             self.game.changed = True
409             return ['removed song']
410         elif command == 'REWIND':
411             self.playlist_index = -1
412             self.next_song_start = datetime.datetime.now()
413             self.game.changed = True
414             return ['back at start of playlist']
415         elif command == 'SKIP':
416             self.next_song_start = datetime.datetime.now()
417             self.game.changed = True
418             return ['skipped']
419         elif command == 'REPEAT':
420             self.repeat = False if self.repeat else True
421             self.game.changed = True
422             if self.repeat:
423                 return ['playlist repeat turned on']
424             else:
425                 return ['playlist repeat turned off']
426         elif command.startswith('ADD '):
427             tokens = command.split(' ', 2)
428             if len(tokens) != 3:
429                 return ['wrong syntax, see HELP']
430             length = tokens[1].split(':')
431             if len(length) != 2:
432                 return ['wrong syntax, see HELP']
433             try:
434                 minutes = int(length[0])
435                 seconds = int(length[1])
436             except ValueError:
437                 return ['wrong syntax, see HELP']
438             self.playlist += [(tokens[2], minutes * 60 + seconds)]
439             self.game.changed = True
440             return ['added']
441         else:
442             return ['cannot understand command']
443
444
445
446 class Thing_BottleDeposit(Thing):
447     bottle_counter = 0
448     symbol_hint = 'O'
449
450     def proceed(self):
451         if self.bottle_counter >= 3:
452             self.bottle_counter = 0
453             choice = random.choice(['MusicPlayer', 'Hat', 'Stimulant', 'Psychedelic'])
454             self.game.add_thing(choice, self.position)
455             msg = 'here is a gift as a reward for ecological consciousness –'
456             if choice == 'MusicPlayer':
457                 msg += 'pick it up and then use "command thing" on it!'
458             elif choice == 'Hat':
459                 msg += 'pick it up and then use "(un-)wear" on it!'
460             elif choice in {'Psychedelic', 'Stimulant'}:
461                 msg += 'pick it up and then use "consume" on it!'
462             self.sound('BOTTLE DEPOSITOR', msg)
463
464     def accept(self):
465         self.bottle_counter += 1
466         self.sound('BOTTLE DEPOSITOR',
467                    'thanks for this empty bottle – deposit %s more for a gift!' %
468                    (3 - self.bottle_counter))
469
470
471
472 class Thing_Stimulant(Thing):
473     symbol_hint = 'e'
474     cookable = True
475     portable = True
476     consumable = True
477
478
479
480 class Thing_StimulantSpawner(ThingSpawner):
481     symbol_hint = 'e'
482     child_type = 'Stimulant'
483
484
485
486 class Thing_Chair(Thing):
487     symbol_hint = 'h'
488     portable = True
489     sittable = True
490
491
492
493 class Thing_ChairSpawner(ThingSpawner):
494     symbol_hint = 'e'
495     child_type = 'Chair'
496
497
498
499 class Thing_Cookie(Thing):
500     symbol_hint = 'c'
501     portable = True
502     consumable = True
503
504     def __init__(self, *args, **kwargs):
505         import string
506         super().__init__(*args, **kwargs)
507         legal_chars = string.ascii_letters + string.digits + string.punctuation + ' '
508         self.thing_char = random.choice(list(legal_chars))
509
510
511
512 class Thing_CookieSpawner(Thing):
513     symbol_hint = 'O'
514
515     def accept(self, thing):
516         self.sound('OVEN', '*heat* *brrzt* here\'s a cookie!')
517         self.game.add_thing('Cookie', self.position)
518
519
520
521 class Thing_Crate(Thing):
522     portable = True
523     symbol_hint = 'C'
524
525     def __init__(self, *args, **kwargs):
526         super().__init__(*args, **kwargs)
527         self.content = []
528
529     def accept(self, thing):
530         self.content += [thing]
531
532     def remove_from_crate(self, thing):
533         self.content.remove(thing)
534
535
536
537 class Thing_CrateSpawner(ThingSpawner):
538     child_type = 'Crate'
539     symbol_hint = 'C'
540
541
542
543 class ThingAnimate(Thing):
544     energy = 50
545
546     def __init__(self, *args, **kwargs):
547         super().__init__(*args, **kwargs)
548         self.next_task = [None]
549         self.task = None
550         self.invalidate('fov')
551         self.invalidate('other')  # currently redundant though
552
553     def invalidate(self, type_):
554         if type_ == 'fov':
555             self._fov = None
556             self._visible_terrain = None
557             self._visible_control = None
558             self.invalidate('other')
559         elif type_ == 'other':
560             self._seen_things = None
561             self._seen_annotation_positions = None
562             self._seen_portal_positions = None
563
564     def set_next_task(self, task_name, args=()):
565         task_class = self.game.tasks[task_name]
566         self.next_task = [task_class(self, args)]
567
568     def get_next_task(self):
569         if self.next_task[0]:
570             task = self.next_task[0]
571             self.next_task = [None]
572             task.check()
573             task.todo += max(0, -self.energy * 10)
574             return task
575
576     def proceed(self):
577         if self.task is None:
578             self.task = self.get_next_task()
579             return
580         try:
581             self.task.check()
582         except (PlayError, GameError) as e:
583             self.task = None
584             raise e
585         self.task.todo -= 1
586         if self.task.todo <= 0:
587             self.task.do()
588             self.game.changed = True
589             self.task = self.get_next_task()
590
591     def prepare_multiprocessible_fov_stencil(self):
592         fov_radius = 3 if self.drunk > 0 else 12
593         light_blockers = self.game.get_light_blockers()
594         obstacles = [t.position for t in self.game.things if t.blocks_light]
595         self._fov = FovMap(light_blockers, obstacles, self.game.maps,
596                            self.position, fov_radius, self.game.get_map)
597
598     def multiprocessible_fov_stencil(self):
599         self._fov.init_terrain()
600
601     @property
602     def fov_stencil(self):
603         if self._fov:
604             return self._fov
605         # due to the pre-multiprocessing in game.send_gamestate,
606         # the following should actually never be called
607         self.prepare_multiprocessible_fov_stencil()
608         self.multiprocessible_fov_stencil()
609         return self._fov
610
611     def fov_test(self, big_yx, little_yx):
612         test_position = self.fov_stencil.target_yx(big_yx, little_yx)
613         if self.fov_stencil.inside(test_position):
614             if self.fov_stencil[test_position] == '.':
615                 return True
616         return False
617
618     def fov_stencil_map(self, map_type):
619         visible_terrain = ''
620         for yx in self.fov_stencil:
621             if self.fov_stencil[yx] == '.':
622                 big_yx, little_yx = self.fov_stencil.source_yxyx(yx)
623                 map_ = self.game.get_map(big_yx, map_type)
624                 visible_terrain += map_[little_yx]
625             else:
626                 visible_terrain += ' '
627         return visible_terrain
628
629     @property
630     def visible_terrain(self):
631         if self._visible_terrain:
632             return self._visible_terrain
633         self._visible_terrain = self.fov_stencil_map('normal')
634         return self._visible_terrain
635
636     @property
637     def visible_control(self):
638         if self._visible_control:
639             return self._visible_control
640         self._visible_control = self.fov_stencil_map('control')
641         return self._visible_control
642
643     @property
644     def seen_things(self):
645         if self._seen_things is not None:
646             return self._seen_things
647         self._seen_things = [t for t in self.game.things
648                              if self.fov_test(*t.position)]
649         return self._seen_things
650
651     @property
652     def seen_annotation_positions(self):
653         if self._seen_annotation_positions is not None:
654             return self._seen_annotation_positions
655         self._seen_annotation_positions = []
656         for big_yx in self.game.annotations:
657             for little_yx in [little_yx for little_yx
658                               in self.game.annotations[big_yx]
659                               if self.fov_test(big_yx, little_yx)]:
660                 self._seen_annotation_positions += [(big_yx, little_yx)]
661         return self._seen_annotation_positions
662
663     @property
664     def seen_portal_positions(self):
665         if self._seen_portal_positions is not None:
666             return self._seen_portal_positions
667         self._seen_portal_positions = []
668         for big_yx in self.game.portals:
669             for little_yx in [little_yx for little_yx
670                               in self.game.portals[big_yx]
671                               if self.fov_test(big_yx, little_yx)]:
672                 self._seen_portal_positions += [(big_yx, little_yx)]
673         return self._seen_portal_positions
674
675
676
677 class Thing_Player(ThingAnimate):
678     symbol_hint = '@'
679     drunk = 0
680     tripping = 0
681     need_for_toilet = 0
682     standing = True
683     dancing = 0
684
685     def __init__(self, *args, **kwargs):
686         super().__init__(*args, **kwargs)
687         self.carrying = None
688
689     def proceed(self):
690         super().proceed()
691         if self.drunk >= 0:
692             self.drunk -= 1
693         if self.tripping >= 0:
694             self.tripping -= 1
695         if self.need_for_toilet > 0:
696             terrain = self.game.maps[self.position[0]][self.position[1]]
697             if terrain in self.game.terrains:
698                 terrain_type = self.game.terrains[terrain]
699                 if 'toilet' in terrain_type.tags:
700                     self.send_msg('CHAT "You use the toilet. What a relief!"')
701                     self.need_for_toilet = 0
702         if self.need_for_toilet > 0:
703             if random.random() > 0.9999:
704                 self.need_for_toilet += 1
705                 self.game.changed = True
706             if 100000 * random.random() < self.need_for_toilet:
707                 self.send_msg('CHAT "You need to go to a toilet."')
708             if self.need_for_toilet > 100:
709                 self.send_msg('CHAT "You pee into your pants. Eww!"')
710                 self.need_for_toilet = 0
711                 self.game.changed = True
712         if self.drunk == 0:
713             self.send_msg('CHAT "You sober up."')
714             self.invalidate('fov')
715             self.game.changed = True
716         if self.tripping == 0:
717             self.send_msg('DEFAULT_COLORS')
718             self.send_msg('CHAT "You sober up."')
719             self.game.changed = True
720         elif self.tripping > 0 and self.tripping % 250 == 0:
721             self.send_msg('RANDOM_COLORS')
722             self.game.changed = True
723         if random.random() > 0.9999:
724             if self.standing:
725                 self.energy -= 1
726             else:
727                 self.energy += 1
728             if self.energy < 0 and self.standing and self.energy % 5 == 0:
729                     self.send_msg('CHAT "All that walking or standing uses up '
730                                   'your energy, which makes you slower.  Find a'
731                                   ' place to sit or lie down to regain it."')
732             self.game.changed = True
733         if self.dancing and random.random() > 0.99 and not self.next_task[0]:
734             self.dancing -= 1
735             direction = random.choice(self.game.map_geometry.directions)
736             self.set_next_task('MOVE', [direction])
737             if random.random() > 0.9:
738                 self.energy -= 1
739                 self.game.changed = True
740         if 1000000 * random.random() < self.energy - 50:
741             self.send_msg('CHAT "Your body tries to '
742                           'dance off its energy surplus."')
743             self.dancing += 50
744             self.game.changed = True
745
746     def send_msg(self, msg):
747         for c_id in self.game.sessions:
748             if self.game.sessions[c_id]['thing_id'] == self.id_:
749                 self.game.io.send(msg, c_id)
750                 break
751
752     def uncarry(self):
753         t = self.carrying
754         t.carried = False
755         self.carrying = None
756         return t
757
758     def add_cookie_char(self, c):
759         if not self.name in self.game.players_hat_chars:
760             self.game.players_hat_chars[self.name] = ' #'  # default
761         if not c in self.game.players_hat_chars[self.name]:
762             self.game.players_hat_chars[self.name] += c
763
764     def get_cookie_chars(self):
765         chars = ' #'  # default
766         if self.name in self.game.players_hat_chars:
767             chars = self.game.players_hat_chars[self.name]
768         chars_split = list(chars)
769         chars_split.sort()
770         return ''.join(chars_split)
771
772     def try_to_sit(self):
773         terrain = self.game.maps[self.position[0]][self.position[1]]
774         if terrain in self.game.terrains:
775             terrain_type = self.game.terrains[terrain]
776             if 'sittable' in terrain_type.tags:
777                 self.standing = False
778                 self.send_msg('CHAT "You sink into the %s. '
779                               'Staying here will replenish your energy."'
780                               % terrain_type.description)
781         for t in [t for t in self.game.things
782                   if t.type_ == 'Chair' and t.position == self.position]:
783             self.standing = False
784             self.send_msg('CHAT "You sink into the Chair. '
785                           'Staying here will replenish your energy."')