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