├── src └── swallows │ ├── __init__.py │ ├── engine │ ├── __init__.py │ ├── objects.py │ └── events.py │ └── story │ ├── __init__.py │ ├── world.py │ └── characters.py ├── .hgtags ├── .gitignore ├── script └── the_swallows.py ├── eg ├── not_the_swallows.py └── the_swallows++.py ├── LICENSE └── README.md /src/swallows/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/swallows/engine/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/swallows/story/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | ea8fb05d8f6e0d3a3098370e3e8c3c54cffec9d5 1.0 2 | ea8fb05d8f6e0d3a3098370e3e8c3c54cffec9d5 1.0 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | -------------------------------------------------------------------------------- /script/the_swallows.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # the_swallows.py: a novel generator. 5 | # Chris Pressey, Cat's Eye Technologies 6 | # 7 | 8 | from os.path import realpath, dirname, join 9 | import sys 10 | 11 | # get the ../src/ directory onto the Python module search path 12 | sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src')) 13 | 14 | # now we can import things, like: 15 | from swallows.engine.events import Publisher 16 | from swallows.story.world import alice, bob, house 17 | 18 | ### main ### 19 | 20 | publisher = Publisher( 21 | characters=(alice, bob), 22 | setting=house, 23 | title="Dial S for Swallows", 24 | friffery=True, 25 | #debug=True, 26 | #chapters=1, 27 | ) 28 | publisher.publish() 29 | -------------------------------------------------------------------------------- /eg/not_the_swallows.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # Example of using _The Swallows_ engine, but not its world, 5 | # to produce a different story. 6 | # 7 | 8 | from os.path import realpath, dirname, join 9 | import sys 10 | 11 | # get the ../src/ directory onto the Python module search path 12 | sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src')) 13 | 14 | # now we can: 15 | from swallows.engine.events import Publisher 16 | from swallows.engine.objects import Location, ProperLocation, Male, Female 17 | 18 | ### world ### 19 | 20 | main_street = ProperLocation("Main Street", noun="street") 21 | butchers = Location("butcher's", noun="store") 22 | bakery = Location("bakery", noun="store") 23 | candlestick_factory = Location("candlestick factory", noun="building") 24 | 25 | main_street.set_exits(butchers, bakery, candlestick_factory) 26 | butchers.set_exits(main_street) 27 | bakery.set_exits(main_street) 28 | candlestick_factory.set_exits(main_street) 29 | 30 | downtown = (main_street, butchers, bakery, candlestick_factory) 31 | 32 | class Tweedle(Male): 33 | def live(self): 34 | self.wander() 35 | 36 | tweedledee = Tweedle('Tweedledee') 37 | tweedledum = Tweedle('Tweedledum') 38 | 39 | ### main ### 40 | 41 | publisher = Publisher( 42 | characters=( 43 | tweedledee, 44 | tweedledum, 45 | ), 46 | setting=downtown, 47 | title="TERRIBLE EXAMPLE STORY", 48 | #debug=True, 49 | ) 50 | publisher.publish() 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This distribution is covered under the following licenses: 2 | 3 | All example files (in the `eg` directory) and all files for the engine 4 | (in the `src/swallows/engine` directory) are in the public domain, 5 | specifically, under the UNLICENSE, the text of which follows: 6 | 7 | ----------------------------------------------------------------------------- 8 | 9 | This is free and unencumbered software released into the public domain. 10 | 11 | Anyone is free to copy, modify, publish, use, compile, sell, or 12 | distribute this software, either in source code form or as a compiled 13 | binary, for any purpose, commercial or non-commercial, and by any 14 | means. 15 | 16 | In jurisdictions that recognize copyright laws, the author or authors 17 | of this software dedicate any and all copyright interest in the 18 | software to the public domain. We make this dedication for the benefit 19 | of the public at large and to the detriment of our heirs and 20 | successors. We intend this dedication to be an overt act of 21 | relinquishment in perpetuity of all present and future rights to this 22 | software under copyright law. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 25 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 26 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 27 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 28 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 29 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 30 | OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | For more information, please refer to 33 | 34 | ----------------------------------------------------------------------------- 35 | 36 | The generated novels (in the `doc` directory) are released under the 37 | Creative Commons Attribution 3.0 Unported license ("CC BY 3.0"): 38 | 39 | http://creativecommons.org/licenses/by/3.0/ 40 | 41 | The Swallows world and characters (in the `src/swallows/story` directory) 42 | are released under an MIT-style license; see those files for the license 43 | text. 44 | -------------------------------------------------------------------------------- /eg/the_swallows++.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # 4 | # Example of extending _The Swallows_ world to produce a different story. 5 | # 6 | 7 | from os.path import realpath, dirname, join 8 | import sys 9 | 10 | # get the ../src/ directory onto the Python module search path 11 | sys.path.insert(0, join(dirname(realpath(sys.argv[0])), '..', 'src')) 12 | 13 | # now we can import the classes we will work with 14 | from swallows.engine.events import Publisher 15 | from swallows.story.characters import MaleCharacter 16 | from swallows.story.world import ( 17 | alice, bob, house, upstairs_hall, 18 | revolver, brandy, dead_body 19 | ) 20 | from swallows.engine.objects import ( 21 | ProperContainer, Item, Location 22 | ) 23 | 24 | # we extend the world of The Swallows by adding a new character. 25 | # note that we have to inform the new character of certain important objects 26 | # in the world are, so that he can react sensibly to them. 27 | # (you *can* pass other objects here, for example 'revolver=brandy', in which 28 | # case the character will act fairly nonsensibly, threatening other characters 29 | # with the bottle of brandy and so forth) 30 | fred = MaleCharacter('Fred') 31 | fred.configure_objects( 32 | revolver=revolver, 33 | brandy=brandy, 34 | dead_body=dead_body, 35 | ) 36 | 37 | # we extend the world by adding new locations and objects 38 | # note that locations exited-to and from must be imported from swallows.story.world (above) 39 | # "Location" is imported from swallows.engine.objects 40 | freds_office = ProperLocation("<*> office", owner=fred) 41 | freds_office.set_exits(upstairs_hall) 42 | 43 | upstairs_hall.set_exits(freds_office) # adds to existing (unknown) exits 44 | 45 | # we extend the world by adding some Objects 46 | # "ProperContainer" and "Item" are imported from swallows.engine.objects 47 | desk = ProperContainer("<*> desk", owner=fred, location=freds_office) 48 | pencils = Item('box of pencils', location=desk) 49 | 50 | 51 | ### main ### 52 | 53 | publisher = Publisher( 54 | characters=( 55 | alice, 56 | bob, 57 | fred, 58 | ), 59 | setting=house, 60 | title="My _The Swallows_ Fanfic", 61 | #debug=True, 62 | ) 63 | publisher.publish() 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | The Swallows 2 | ============ 3 | 4 | _See also:_ [NaNoGenLab](https://git.catseye.tc/NaNoGenLab/) (2014) 5 | ∘ [MARYSUE](https://git.catseye.tc/MARYSUE/) (2015) 6 | ∘ [2017 Entries](https://git.catseye.tc/NaNoGenMo-Entries-2017/) 7 | ∘ [2018 Entries](https://git.catseye.tc/NaNoGenMo-Entries-2018/) 8 | ∘ [2019 Entries](https://git.catseye.tc/NaNoGenMo-Entries-2019/) 9 | 10 | - - - - 11 | 12 | _The Swallows_ is a series of computer-generated novels meta-written for 13 | [NaNoGenMo 2013](https://github.com/dariusk/NaNoGenMo) by 14 | Cat's Eye Technologies. Our submission issue can be found 15 | [here](https://github.com/dariusk/NaNoGenMo/issues/39). 16 | 17 | _The Swallows_ is also the name of the first novel in the series. 18 | It follows the madcap adventures of Alice and Bob as they both try 19 | to acquire the golden falcon, which is priceless, or at the very least 20 | [irrationally desirable by all involved](http://tvtropes.org/pmwiki/pmwiki.php/Main/MacGuffin). 21 | 22 | _The Swallows of Summer_, the sequel to _The Swallows_, revisits 23 | Alice and Bob's life three years later. They have a much bigger house 24 | now. They also own more things. 25 | 26 | _Swallows and Sorrows_, the third book in the series, shows us another 27 | period in Alice and Bob's life, not much later. They have taken up drinking — 28 | at least when they are disturbed by things — to calm their nerves. And then 29 | they start arguing. Oh, and the narrator knows how to use pronouns now. 30 | 31 | The fourth novel, _Dial S for Swallows_, probably takes place shortly 32 | thereafter, or possibly at the same time as _Swallows and Sorrows_ but in an 33 | alternate universe. Alice and Bob are no longer psychic (although this may 34 | not have been apparent in the previous books, the fact is that they were 35 | easily able tell what the other was thinking. Now, they only have suspicions. 36 | Also, the previous editor was sacked, and a completely new editor installed 37 | in their place.) 38 | 39 | All four novels can be found, in Markdown format, in the `doc` subdirectory 40 | of this distribution. 41 | 42 | At 49K words (in 15 chapters of 33 paragraphs each,) _The Swallows_ is not 43 | quite long enough to qualify for NaNoGenMo. At 53K words, 44 | _The Swallows of Summer_ is. _Swallows and Sorrows_ is just barely over 45 | 50K words. _Dial S for Swallows_ is over 56K words. 46 | 47 | All novels were generated by the Python script `the_swallows.py` in the 48 | `script` directory, at different points in time. See the repository 49 | history to get the version of the script used for a particular novel. 50 | 51 | I invite the reader who is interested in how the script works to read the 52 | source code. It sometimes even contains comments. 53 | -------------------------------------------------------------------------------- /src/swallows/story/world.py: -------------------------------------------------------------------------------- 1 | # Copyright (c)2013 Chris Pressey, Cat's Eye Technologies 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import random 22 | 23 | from swallows.engine.objects import ( 24 | Location, ProperLocation, Treasure, PluralTreasure, 25 | Container, ProperContainer, 26 | Item, Weapon, Horror 27 | ) 28 | from swallows.story.characters import MaleCharacter, FemaleCharacter 29 | 30 | # TODO 31 | 32 | # World: 33 | # more reacting to the dead body: 34 | # - if they *agree*, take one of the courses of action 35 | # after agreement: 36 | # - calling the police (do they have a landline? it might be entertaining 37 | # if they share one mobile phone between the both of them) 38 | # - i'll have to introduce a new character... the detective. yow. 39 | # - trying to dispose of it... they try to drag it to... the garden? 40 | # i'll have to add a garden. and a shovel. 41 | # an unspeakable thing in the basement! (don't they have enough excitement 42 | # in their lives?) 43 | # bullets for the revolver 44 | 45 | ### world ### 46 | 47 | alice = FemaleCharacter('Alice') 48 | bob = MaleCharacter('Bob') 49 | 50 | kitchen = Location('kitchen') 51 | living_room = Location('living room') 52 | dining_room = Location('dining room') 53 | front_hall = Location('front hall') 54 | driveway = Location('driveway', noun="driveway") 55 | garage = Location('garage', noun="garage") 56 | path_by_the_shed = Location('path by the shed', noun="path") 57 | shed = Location('shed', noun="shed") 58 | upstairs_hall = Location('upstairs hall') 59 | study = Location('study') 60 | bathroom = Location('bathroom') 61 | bobs_bedroom = ProperLocation("<*> bedroom", owner=bob) 62 | alices_bedroom = ProperLocation("<*> bedroom", owner=alice) 63 | 64 | kitchen.set_exits(dining_room, front_hall) 65 | living_room.set_exits(dining_room, front_hall) 66 | dining_room.set_exits(living_room, kitchen) 67 | front_hall.set_exits(kitchen, living_room, driveway, upstairs_hall) 68 | driveway.set_exits(front_hall, garage, path_by_the_shed) 69 | garage.set_exits(driveway) 70 | path_by_the_shed.set_exits(driveway, shed) 71 | shed.set_exits(path_by_the_shed) 72 | upstairs_hall.set_exits(bobs_bedroom, alices_bedroom, front_hall, study, bathroom) 73 | bobs_bedroom.set_exits(upstairs_hall) 74 | alices_bedroom.set_exits(upstairs_hall) 75 | study.set_exits(upstairs_hall) 76 | bathroom.set_exits(upstairs_hall) 77 | 78 | house = (kitchen, living_room, dining_room, front_hall, driveway, garage, 79 | upstairs_hall, bobs_bedroom, alices_bedroom, study, bathroom, 80 | path_by_the_shed, shed) 81 | 82 | falcon = Treasure('golden falcon', location=dining_room) 83 | jewels = PluralTreasure('stolen jewels', location=garage) 84 | 85 | cupboards = Container('cupboards', location=kitchen) 86 | liquor_cabinet = Container('liquor cabinet', location=dining_room) 87 | mailbox = Container('mailbox', location=driveway) 88 | 89 | bobs_bed = ProperContainer("<*> bed", location=bobs_bedroom, owner=bob) 90 | alices_bed = ProperContainer("<*> bed", location=alices_bedroom, owner=alice) 91 | 92 | brandy = Item('bottle of brandy', location=liquor_cabinet) 93 | revolver = Weapon('revolver', location=random.choice([bobs_bed, alices_bed])) 94 | dead_body = Horror('dead body', location=bathroom) 95 | 96 | # when making alice and bob, we let them recognize certain important 97 | # objects in their world 98 | for c in (alice, bob): 99 | c.configure_objects( 100 | revolver=revolver, 101 | brandy=brandy, 102 | dead_body=dead_body, 103 | ) 104 | 105 | ALL_ITEMS = (falcon, jewels, revolver, brandy) 106 | -------------------------------------------------------------------------------- /src/swallows/engine/objects.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sys 3 | 4 | from swallows.engine.events import Event 5 | 6 | ### TOPICS ### 7 | 8 | # a "topic" is just what a character has recently had addressed to 9 | # them. It could be anything, not just words, by another character 10 | # (for example, a gesture.) 11 | 12 | class Topic(object): 13 | def __init__(self, originator, subject=None): 14 | self.originator = originator 15 | self.subject = subject 16 | 17 | 18 | class GreetTopic(Topic): 19 | pass 20 | 21 | 22 | class SpeechTopic(Topic): 23 | pass 24 | 25 | 26 | class QuestionTopic(Topic): 27 | pass 28 | 29 | 30 | ### BELIEFS ### 31 | 32 | # 33 | # a belief is something an Animate believes. they come in a few types: 34 | # 35 | # - a belief that an object is somewhere 36 | # - because they saw it there (memory) 37 | # - because some other character told them it was there 38 | # - a belief that they should do something (a goal), which has subtypes: 39 | # - a belief that an object is desirable & they should try to acquire it 40 | # - a belief that something should be done about something (bland, general) 41 | # - a belief that another Animate believes something 42 | # 43 | # of course, any particular belief may turn out not to be true 44 | # 45 | 46 | # abstract base class 47 | class Belief(object): 48 | # constructor of all subclasses of this class should accept being 49 | # called with only one argument, as a convenience sort of thing 50 | # for BeliefSet.get and .remove, which don't really care about anything 51 | # about the Belief except for its class and its subject. 52 | # although, usually, you do want to pass more than one argument when 53 | # making a real Belief to pass to BeliefSet.add. (clear as mud, right?) 54 | def __init__(self, subject): # kind of silly for an ABC to have a 55 | assert isinstance(subject, Actor) # constructor, but it is to emphasize 56 | self.subject = subject # that all beliefs have a subject, 57 | # which is the thing we believe 58 | # something about 59 | 60 | def __str__(self): 61 | raise NotImplementedError 62 | 63 | 64 | class ItemLocation(Belief): # formerly "Memory" 65 | def __init__(self, subject, location=None, informant=None, concealer=None): 66 | assert isinstance(subject, Actor) 67 | assert isinstance(location, Actor) or location is None 68 | self.subject = subject # the thing we think is somewhere 69 | self.location = location # the place we think it is 70 | self.informant = informant # the actor who told us about it 71 | self.concealer = concealer # the actor who we think hid it there 72 | 73 | def __str__(self): 74 | s = "%s is in %s" % ( 75 | self.subject.render(), 76 | self.location.render() 77 | ) 78 | if self.concealer: 79 | s += " (hidden there by %s)" % self.concealer.render() 80 | if self.informant: 81 | s += " (%s told me so)" % self.informant.render() 82 | return s 83 | 84 | 85 | class Goal(Belief): 86 | def __init__(self, subject, phrase=None): 87 | assert isinstance(subject, Actor) 88 | self.subject = subject # the thing we would like to do something about 89 | self.phrase = phrase # human-readable description 90 | 91 | def __str__(self): 92 | return "I should %s %s" % ( 93 | self.phrase, 94 | self.subject.render() 95 | ) 96 | 97 | 98 | class Desire(Goal): 99 | def __init__(self, subject): 100 | assert isinstance(subject, Actor) 101 | self.subject = subject # the thing we would like to acquire 102 | 103 | def __str__(self): 104 | return "I want %s" % ( 105 | self.subject.render() 106 | ) 107 | 108 | 109 | # oh dear 110 | class BeliefsBelief(Belief): 111 | def __init__(self, subject, belief_set=None): 112 | assert isinstance(subject, Animate) 113 | self.subject = subject # the animate we think holds the belief 114 | if belief_set is None: 115 | belief_set = BeliefSet() 116 | assert isinstance(belief_set, BeliefSet) 117 | self.belief_set = belief_set # the beliefs we think they hold 118 | 119 | def __str__(self): 120 | return "%s believes { %s }" % ( 121 | self.subject.render(), 122 | self.belief_set 123 | ) 124 | 125 | 126 | class BeliefSet(object): 127 | """A BeliefSet works something like a Python set(), but has the 128 | following constraints: 129 | 130 | There can be only one of each type of Belief about a particular 131 | item in the set. 132 | 133 | So it's really kind of a map from Actors to maps from Belief 134 | subclasses to Beliefs. 135 | 136 | But it behooves us (or at least, me) to think of it as a set. 137 | (Besides, it might change.) 138 | 139 | """ 140 | def __init__(self): 141 | self.belief_map = {} 142 | 143 | def add(self, belief): 144 | assert isinstance(belief, Belief) 145 | subject = belief.subject 146 | self.belief_map.setdefault(subject, {})[belief.__class__] = belief 147 | 148 | def remove(self, belief): 149 | # the particular belief passed to us doesn't really matter. we extract 150 | # the class and subject and return any existing belief we may have 151 | assert isinstance(belief, Belief) 152 | subject = belief.subject 153 | beliefs = self.belief_map.setdefault(subject, {}) 154 | if belief.__class__ in beliefs: 155 | del beliefs[belief.__class__] 156 | 157 | def get(self, belief): 158 | # the particular belief passed to us doesn't really matter. we extract 159 | # the class and subject and return any existing belief we may have 160 | assert isinstance(belief, Belief) 161 | subject = belief.subject 162 | return self.belief_map.setdefault(subject, {}).get( 163 | belief.__class__, None 164 | ) 165 | 166 | def subjects(self): 167 | for subject in self.belief_map: 168 | yield subject 169 | 170 | def beliefs_for(self, subject): 171 | beliefs = self.belief_map.setdefault(subject, {}) 172 | for class_ in beliefs: 173 | yield beliefs[class_] 174 | 175 | def beliefs_of_class(self, class_): 176 | for subject in self.subjects(): 177 | for belief in self.beliefs_for(subject): 178 | if belief.__class__ == class_: 179 | yield belief 180 | 181 | def __str__(self): 182 | l = [] 183 | for subject in self.subjects(): 184 | for belief in self.beliefs_for(subject): 185 | l.append(str(belief)) 186 | return ', '.join(l) 187 | 188 | 189 | ### ACTORS (objects in the world) ### 190 | 191 | class Actor(object): 192 | def __init__(self, name, location=None, owner=None, collector=None): 193 | self.name = name 194 | self.collector = collector 195 | self.contents = set() 196 | self.enter = "" 197 | self.owner = owner 198 | self.location = None 199 | if location is not None: 200 | self.move_to(location) 201 | 202 | def notable(self): 203 | return self.treasure() or self.weapon() or self.animate() or self.horror() 204 | 205 | def treasure(self): 206 | return False 207 | 208 | def weapon(self): 209 | return False 210 | 211 | def horror(self): 212 | return False 213 | 214 | def takeable(self): 215 | return False 216 | 217 | def animate(self): 218 | return False 219 | 220 | def container(self): 221 | return False 222 | 223 | def article(self): 224 | return 'the' 225 | 226 | def posessive(self): 227 | return "its" 228 | 229 | def accusative(self): 230 | return "it" 231 | 232 | def pronoun(self): 233 | return "it" 234 | 235 | def was(self): 236 | return "was" 237 | 238 | def is_(self): 239 | return "is" 240 | 241 | def emit(self, *args, **kwargs): 242 | if self.collector: 243 | self.collector.collect(Event(*args, **kwargs)) 244 | 245 | def move_to(self, location): 246 | if self.location: 247 | self.location.contents.remove(self) 248 | self.location = location 249 | self.location.contents.add(self) 250 | 251 | def render(self, event=None): 252 | """Return a string containing what we call this object, in the context 253 | of the given event (which may be None, to get a 'generic' description.) 254 | 255 | """ 256 | name = self.name 257 | repl = None 258 | if self.owner is not None: 259 | repl = self.owner.render() + "'s" 260 | if event: 261 | if event.speaker is self.owner: 262 | repl = 'my' 263 | elif event.addressed_to is self.owner: 264 | repl = 'your' 265 | elif event.initiator() is self.owner: 266 | repl = event.initiator().posessive() 267 | if repl is not None: 268 | name = name.replace('<*>', repl) 269 | article = self.article() 270 | if not article: 271 | return name 272 | return '%s %s' % (article, name) 273 | 274 | def indefinite(self): 275 | article = 'a' 276 | if self.name.startswith(('a', 'e', 'i', 'o', 'u')): 277 | article = 'an' 278 | return '%s %s' % (article, self.name) 279 | 280 | 281 | ### some mixins for Actors ### 282 | 283 | class ProperMixin(object): 284 | def article(self): 285 | return '' 286 | 287 | 288 | class PluralMixin(object): 289 | def posessive(self): 290 | return "their" 291 | 292 | def accusative(self): 293 | return "them" 294 | 295 | def pronoun(self): 296 | return "they" 297 | 298 | def indefinite(self): 299 | article = 'some' 300 | return '%s %s' % (article, self.name) 301 | 302 | def was(self): 303 | return "were" 304 | 305 | def is_(self): 306 | return "are" 307 | 308 | 309 | class MasculineMixin(object): 310 | def posessive(self): 311 | return "his" 312 | 313 | def accusative(self): 314 | return "him" 315 | 316 | def pronoun(self): 317 | return "he" 318 | 319 | 320 | class FeminineMixin(object): 321 | def posessive(self): 322 | return "her" 323 | 324 | def accusative(self): 325 | return "her" 326 | 327 | def pronoun(self): 328 | return "she" 329 | 330 | 331 | ### ANIMATE OBJECTS ### 332 | 333 | class Animate(Actor): 334 | def __init__(self, name, location=None, owner=None, collector=None): 335 | Actor.__init__( 336 | self, name, location=location, owner=owner, collector=None 337 | ) 338 | self.topic = None 339 | self.beliefs = BeliefSet() 340 | 341 | def animate(self): 342 | return True 343 | 344 | # for debugging 345 | def dump_beliefs(self): 346 | for subject in self.beliefs.subjects(): 347 | for belief in self.beliefs.beliefs_for(subject): 348 | print ".oO{ %s }" % belief 349 | 350 | ###--- belief accessors/manipulators ---### 351 | 352 | # these are mostly just aliases for accessing the BeliefSet. 353 | 354 | def remember_location(self, thing, location, concealer=None): 355 | """Update this Animate's beliefs to include a belief that the 356 | given thing is located at the given location. 357 | 358 | Really just a readable alias for believe_location. 359 | 360 | """ 361 | self.believe_location(thing, location, informant=None, concealer=concealer) 362 | 363 | def believe_location(self, thing, location, informant=None, concealer=None): 364 | """Update this Animate's beliefs to include a belief that the 365 | given thing is located at the given location. They may have 366 | been told this by someone. 367 | 368 | """ 369 | self.beliefs.add(ItemLocation( 370 | thing, location, informant=informant, concealer=concealer 371 | )) 372 | 373 | def recall_location(self, thing): 374 | """Return an ItemLocation (belief) about this thing, or None.""" 375 | return self.beliefs.get(ItemLocation(thing)) 376 | 377 | def forget_location(self, thing): 378 | self.beliefs.remove(ItemLocation(thing)) 379 | 380 | def desire(self, thing): 381 | self.beliefs.add(Desire(thing)) 382 | 383 | def quench_desire(self, thing): 384 | # usually called when it has been acquired 385 | self.beliefs.remove(Desire(thing)) 386 | 387 | def does_desire(self, thing): 388 | if thing.treasure(): 389 | return True # omg YES 390 | if thing.weapon(): 391 | return True # could come in handy. (TODO, sophisticate this?) 392 | return self.beliefs.get(Desire(thing)) is not None 393 | 394 | def believed_beliefs_of(self, other): 395 | """Returns a BeliefSet (not a Belief) that this Animate 396 | believes the other Animate holds. 397 | 398 | Typically you would manipulate this BeliefSet directly 399 | with add, remove, get, etc. 400 | 401 | """ 402 | assert isinstance(other, Animate) 403 | # for extra fun, try reading the code of this method out loud! 404 | beliefs_belief = self.beliefs.get(BeliefsBelief(other)) 405 | if beliefs_belief is None: 406 | beliefs_belief = BeliefsBelief(other, BeliefSet()) 407 | self.beliefs.add(beliefs_belief) 408 | return beliefs_belief.belief_set 409 | 410 | ###--- topic stuff ---### 411 | 412 | def address(self, other, topic, phrase, participants=None): 413 | if participants is None: 414 | participants = [self, other] 415 | other.topic = topic 416 | self.emit(phrase, participants, speaker=self, addressed_to=other) 417 | 418 | def greet(self, other, phrase, participants=None): 419 | self.address(other, GreetTopic(self), phrase, participants) 420 | 421 | def speak_to(self, other, phrase, participants=None, subject=None): 422 | self.address(other, SpeechTopic(self, subject=subject), phrase, participants) 423 | 424 | def question(self, other, phrase, participants=None, subject=None): 425 | self.address(other, QuestionTopic(self, subject=subject), phrase, participants) 426 | 427 | ###--- generic actions ---### 428 | 429 | def place_in(self, location): 430 | """Like move_to but quieter. For setting up scenes, etc. 431 | 432 | """ 433 | if self.location is not None: 434 | self.location.contents.remove(self) 435 | self.location = location 436 | self.location.contents.add(self) 437 | # this is needed so that the Editor knows where the character starts. 438 | # the Editor should (does?) strip out all instances of these that 439 | # aren't informative to the reader. 440 | self.emit("<1> in <2>", [self, self.location]) 441 | # a side-effect of the following code is, if they start in a location 442 | # with a horror,they don't react to it. They probably should. 443 | for x in self.location.contents: 444 | if x == self: 445 | continue 446 | if x.notable(): 447 | self.emit("<1> saw <2>", [self, x]) 448 | self.remember_location(x, self.location) 449 | 450 | def move_to(self, location): 451 | assert(location != self.location) 452 | assert(location is not None) 453 | for x in self.location.contents: 454 | # otherwise we get "Bob saw Bob leave the room", eh? 455 | if x is self: 456 | continue 457 | if x.animate(): 458 | x.emit("<1> saw <2> leave the %s" % x.location.noun(), [x, self]) 459 | if self.location is not None: 460 | self.location.contents.remove(self) 461 | previous_location = self.location 462 | self.location = location 463 | assert self not in self.location.contents 464 | self.location.contents.add(self) 465 | self.emit("<1> went to <2>", [self, self.location], 466 | previous_location=previous_location) 467 | 468 | def point_at(self, other, item): 469 | # it would be nice if there was some way to 470 | # indicate the revolver as part of the Topic which will follow, 471 | # or otherwise indicate the context as "at gunpoint" 472 | 473 | assert self.location == other.location 474 | assert item.location == self 475 | self.emit("<1> pointed <3> at <2>", 476 | [self, other, item]) 477 | for actor in self.location.contents: 478 | if actor.animate(): 479 | actor.remember_location(item, self) 480 | 481 | def put_down(self, item): 482 | assert(item.location == self) 483 | self.emit("<1> put down <2>", [self, item]) 484 | item.move_to(self.location) 485 | for actor in self.location.contents: 486 | if actor.animate(): 487 | actor.remember_location(item, self.location) 488 | 489 | def pick_up(self, item): 490 | assert(item.location == self.location) 491 | self.emit("<1> picked up <2>", [self, item]) 492 | item.move_to(self) 493 | for actor in self.location.contents: 494 | if actor.animate(): 495 | actor.remember_location(item, self) 496 | 497 | def give_to(self, other, item): 498 | assert(item.location == self) 499 | assert(self.location == other.location) 500 | self.emit("<1> gave <3> to <2>", [self, other, item]) 501 | item.move_to(other) 502 | for actor in self.location.contents: 503 | if actor.animate(): 504 | actor.remember_location(item, other) 505 | 506 | def wander(self): 507 | self.move_to( 508 | self.location.exits[ 509 | random.randint(0, len(self.location.exits)-1) 510 | ] 511 | ) 512 | 513 | def live(self): 514 | """This gets called on each turn an animate moves. 515 | 516 | You need to implement this for particular animates. 517 | 518 | """ 519 | raise NotImplementedError( 520 | 'Please implement %s.live()' % self.__class__.__name__ 521 | ) 522 | 523 | 524 | class Male(MasculineMixin, ProperMixin, Animate): 525 | pass 526 | 527 | 528 | class Female(FeminineMixin, ProperMixin, Animate): 529 | pass 530 | 531 | 532 | ### LOCATIONS ### 533 | 534 | class Location(Actor): 535 | def __init__(self, name, enter="went to", noun="room", owner=None): 536 | self.name = name 537 | self.enter = enter 538 | self.contents = set() 539 | self.exits = [] 540 | self.noun_ = noun 541 | self.owner = owner 542 | 543 | def noun(self): 544 | return self.noun_ 545 | 546 | def set_exits(self, *exits): 547 | for exit in exits: 548 | assert isinstance(exit, Location) 549 | self.exits = exits 550 | 551 | 552 | class ProperLocation(ProperMixin, Location): 553 | pass 554 | 555 | 556 | ### OTHER INANIMATE OBJECTS ### 557 | 558 | class Item(Actor): 559 | def takeable(self): 560 | return True 561 | 562 | 563 | class Weapon(Item): 564 | def weapon(self): 565 | return True 566 | 567 | 568 | class Container(Actor): 569 | def container(self): 570 | return True 571 | 572 | 573 | class ProperContainer(ProperMixin, Container): 574 | pass 575 | 576 | 577 | class Treasure(Item): 578 | def treasure(self): 579 | return True 580 | 581 | 582 | class PluralTreasure(PluralMixin, Treasure): 583 | pass 584 | 585 | 586 | class Horror(Actor): 587 | def horror(self): 588 | return True 589 | -------------------------------------------------------------------------------- /src/swallows/engine/events.py: -------------------------------------------------------------------------------- 1 | import random 2 | import sys 3 | 4 | # TODO 5 | 6 | # Diction: 7 | # - use indef art when they have no memory of an item that they see 8 | # - dramatic irony would be really nice, but hard to pull off. Well, a certain 9 | # amount happens naturally now, with character pov. but more could be done 10 | # - "Chapter 3. _In which Bob hides the stolen jewels in the mailbox, etc_" -- 11 | # i.e. chapter summaries -- that's a little too fancy to hope for, but with 12 | # a sufficiently smart Editor it could be done 13 | 14 | ### EVENTS ### 15 | 16 | class Event(object): 17 | def __init__(self, phrase, participants, excl=False, 18 | previous_location=None, 19 | speaker=None, 20 | addressed_to=None, 21 | exciting=False): 22 | """participants[0] is always the initiator, and we 23 | record the location that the event was initiated in. 24 | 25 | For now, we assume such an event can be: 26 | - observed by every actor at that location 27 | - affects only actors at that location 28 | 29 | In the future, we *may* have: 30 | - active and passive participants 31 | - active participants all must be present at the location 32 | - passive participants need not be 33 | (probably done by passing a number n: the first n 34 | participants are to be considered active) 35 | 36 | speaker and addressed_to apply to dialogue. 37 | If speaker == None, it means the narrator is speaking. 38 | If addressed_to == None, it means the reader is being spoken to. 39 | 40 | """ 41 | self.phrase = phrase 42 | self.participants = participants 43 | self.location = participants[0].location 44 | self._previous_location = previous_location 45 | self.excl = excl 46 | self.speaker = speaker 47 | self.addressed_to = addressed_to 48 | self.exciting = exciting 49 | 50 | def rephrase(self, new_phrase): 51 | """Does not modify the event. Returns a new copy.""" 52 | return Event(new_phrase, self.participants, excl=self.excl) 53 | 54 | def initiator(self): 55 | return self.participants[0] 56 | 57 | def previous_location(self): 58 | return self._previous_location 59 | 60 | def render(self): 61 | phrase = self.phrase 62 | i = 0 63 | for participant in self.participants: 64 | phrase = phrase.replace('<%d>' % (i + 1), participant.render(event=self)) 65 | phrase = phrase.replace('' % (i + 1), participant.indefinite()) 66 | phrase = phrase.replace('' % (i + 1), participant.posessive()) 67 | phrase = phrase.replace('' % (i + 1), participant.accusative()) 68 | phrase = phrase.replace('' % (i + 1), participant.pronoun()) 69 | phrase = phrase.replace('' % (i + 1), participant.was()) 70 | phrase = phrase.replace('' % (i + 1), participant.is_()) 71 | i = i + 1 72 | return phrase 73 | 74 | def __str__(self): 75 | phrase = self.render() 76 | if self.excl: 77 | phrase = phrase + '!' 78 | else: 79 | phrase = phrase + '.' 80 | return phrase[0].upper() + phrase[1:] 81 | 82 | 83 | class AggregateEvent(Event): 84 | """Attempt at a way to combine multiple events into a single 85 | sentence. Each constituent event must have the same initiator. 86 | 87 | This is definitely not as nice as it could be. 88 | 89 | """ 90 | def __init__(self, template, events, excl=False): 91 | self.template = template 92 | self.events = events 93 | self.excl = excl 94 | self.phrase = 'SEE SUBEVENTS PLZ' 95 | self._initiator = self.events[0].initiator() 96 | for event in self.events: 97 | assert event.initiator() == self._initiator 98 | self.location = self._initiator.location 99 | 100 | def rephrase(self, new_phrase): 101 | #raise NotImplementedError 102 | return self 103 | 104 | def initiator(self): 105 | return self._initiator 106 | 107 | def previous_location(self): 108 | return self.events[0].previous_location() 109 | 110 | def __str__(self): 111 | phrase = self.template % tuple([x.render() for x in self.events]) 112 | if self.excl: 113 | phrase = phrase + '!' 114 | else: 115 | phrase = phrase + '.' 116 | return phrase[0].upper() + phrase[1:] 117 | 118 | 119 | class EventCollector(object): 120 | def __init__(self): 121 | self.events = [] 122 | 123 | def collect(self, event): 124 | if self.events and str(event) == str(self.events[-1]): 125 | raise ValueError('Duplicate event: %s' % event) 126 | if event.phrase == '<1> went to <2>': 127 | assert event.previous_location() is not None 128 | assert event.previous_location() != event.location 129 | self.events.append(event) 130 | 131 | 132 | # not really needed, as emit() does nothing if there is no collector 133 | class Oblivion(EventCollector): 134 | def collect(self, event): 135 | pass 136 | 137 | 138 | oblivion = Oblivion() 139 | 140 | 141 | ### EDITOR AND PUBLISHER ### 142 | 143 | class Editor(object): 144 | """The Editor is remarkably similar to the _peephole optimizer_ in 145 | compiler construction. Instead of replacing sequences of instructions 146 | with more efficient but semantically equivalent sequences of 147 | instructions, it replaces sequences of sentences with more readable 148 | but semantically equivalent sequences of sentences. 149 | 150 | The Editor is also responsible for chopping up the sequence of 151 | sentences into "sensible" paragraphs. (This might be like a compiler 152 | code-rewriting pass that inserts NOPs to ensure instructions are on a 153 | word boundary, or some such.) 154 | 155 | The Editor is also responsible for picking which character to 156 | follow. (I don't think there's a compiler construction analogy for 157 | that.) 158 | 159 | Note that the event stream must start with " was in " 160 | as the first event for each character. Otherwise the Editor don't know 161 | who started where. 162 | 163 | Well, OK, it *used* to look a lot like a peephole optimizer. Soon, it 164 | will make multiple passes. It still looks a lot like the optimization 165 | phase of a compiler, though. 166 | 167 | """ 168 | 169 | def __init__(self, collector, main_characters): 170 | self.events = list(reversed(collector.events)) 171 | self.main_characters = main_characters 172 | self.pov_index = 0 173 | self.transformers = [] 174 | # maps main characters to where they currently are (omnisciently) 175 | self.character_location = {} 176 | # maps main characters to where the reader last saw them 177 | self.last_seen_at = {} 178 | # maps characters to things that happened to them while not narrated 179 | self.exciting_developments = {} 180 | 181 | def add_transformer(self, transformer): 182 | self.transformers.append(transformer) 183 | 184 | def publish(self): 185 | paragraph_num = 1 186 | while len(self.events) > 0: 187 | pov_actor = self.main_characters[self.pov_index] 188 | paragraph_events = self.generate_paragraph_events(pov_actor) 189 | for transformer in self.transformers: 190 | if paragraph_events: 191 | paragraph_events = transformer.transform( 192 | self, paragraph_events, paragraph_num 193 | ) 194 | self.publish_paragraph(paragraph_events) 195 | self.pov_index += 1 196 | if self.pov_index >= len(self.main_characters): 197 | self.pov_index = 0 198 | paragraph_num += 1 199 | 200 | def generate_paragraph_events(self, pov_actor): 201 | quota = random.randint(10, 25) 202 | paragraph_events = [] 203 | while len(paragraph_events) < quota and len(self.events) > 0: 204 | event = self.events.pop() 205 | 206 | if not paragraph_events: 207 | # this is the first sentence of the paragraph 208 | # if the reader wasn't aware they were here, add an event 209 | if self.last_seen_at.get(pov_actor, None) != event.location: 210 | if not (('went to' in event.phrase) or 211 | ('made way to' in event.phrase) or 212 | (event.phrase == '<1> in <2>')): 213 | paragraph_events.append(Event('<1> in <2>', [pov_actor, event.location])) 214 | # if something exciting happened, tell the reader 215 | for (obj, loc) in self.exciting_developments.get(pov_actor, []): 216 | paragraph_events.append(Event('<1> had found <2> in <3>', [pov_actor, obj, loc])) 217 | self.exciting_developments[pov_actor] = [] 218 | 219 | # update our idea of where the character is, even if these are 220 | # not events we will be dumping out 221 | self.character_location[event.initiator()] = event.location 222 | 223 | if event.location == self.character_location[pov_actor]: 224 | paragraph_events.append(event) 225 | # update the reader's idea of where the character is 226 | self.last_seen_at[event.initiator()] = event.location 227 | else: 228 | if event.exciting: 229 | self.exciting_developments.setdefault(event.initiator(), []).append( 230 | (event.participants[1], event.participants[2]) 231 | ) 232 | 233 | return paragraph_events 234 | 235 | def publish_paragraph(self, paragraph_events): 236 | for event in paragraph_events: 237 | sys.stdout.write(str(event) + " ") 238 | #sys.stdout.write("\n") 239 | print 240 | print 241 | 242 | 243 | class Transformer(object): 244 | pass 245 | 246 | 247 | class DeduplicateTransformer(Transformer): 248 | # check for verbatim repeated. this could be 'dangerous' if, say, 249 | # you have two characters, Bob Jones and Bob Smith, and both are 250 | # named 'Bob', and they are actually two different events... but... 251 | # for now that is an edge case. 252 | def transform(self, editor, incoming_events, paragraph_num): 253 | events = [] 254 | for event in incoming_events: 255 | if events: 256 | if str(event) == str(events[-1]): 257 | events[-1].phrase = event.phrase + ', twice' 258 | elif str(event.rephrase(event.phrase + ', twice')) == str(events[-1]): 259 | events[-1].phrase = event.phrase + ', several times' 260 | elif str(event.rephrase(event.phrase + ', several times')) == str(events[-1]): 261 | pass 262 | else: 263 | events.append(event) 264 | else: 265 | events.append(event) 266 | return events 267 | 268 | 269 | class UsePronounsTransformer(Transformer): 270 | # replace repeated proper nouns with pronouns 271 | def transform(self, editor, incoming_events, paragraph_num): 272 | events = [] 273 | for event in incoming_events: 274 | if events: 275 | if event.initiator() == events[-1].initiator(): 276 | event.phrase = event.phrase.replace('<1>', '') 277 | events.append(event) 278 | else: 279 | events.append(event) 280 | return events 281 | 282 | 283 | class MadeTheirWayToTransformer(Transformer): 284 | def transform(self, editor, incoming_events, paragraph_num): 285 | events = [] 286 | for event in incoming_events: 287 | if (events and 288 | event.initiator() == events[-1].initiator()): 289 | if (events[-1].phrase in ('<1> went to <2>',) and 290 | event.phrase == '<1> went to <2>'): 291 | assert event.location == event.participants[1] 292 | assert events[-1].previous_location() is not None 293 | assert events[-1].location == events[-1].participants[1] 294 | events[-1].phrase = '<1> made way to <2>' 295 | events[-1].participants[1] = event.participants[1] 296 | events[-1].location = event.participants[1] 297 | elif (events[-1].phrase in ('<1> made way to <2>',) and 298 | event.phrase == '<1> went to <2>'): 299 | assert event.location == event.participants[1] 300 | assert events[-1].previous_location() is not None 301 | assert events[-1].location == events[-1].participants[1] 302 | events[-1].phrase = '<1> made way to <2>' 303 | events[-1].participants[1] = event.participants[1] 304 | events[-1].location = event.participants[1] 305 | else: 306 | events.append(event) 307 | else: 308 | events.append(event) 309 | return events 310 | 311 | 312 | # well well well 313 | from swallows.engine.objects import Actor 314 | weather = Actor('the weather') 315 | 316 | 317 | class AddWeatherFrifferyTransformer(Transformer): 318 | def transform(self, editor, incoming_events, paragraph_num): 319 | events = [] 320 | if paragraph_num == 1: 321 | choice = random.randint(0, 3) 322 | if choice == 0: 323 | events.append(Event("It was raining", [weather])) 324 | if choice == 1: 325 | events.append(Event("It was snowing", [weather])) 326 | if choice == 2: 327 | events.append(Event("The sun was shining", [weather])) 328 | if choice == 3: 329 | events.append(Event("The day was overcast and humid", [weather])) 330 | return events + incoming_events 331 | 332 | 333 | class AddParagraphStartFrifferyTransformer(Transformer): 334 | def transform(self, editor, incoming_events, paragraph_num): 335 | first_event = incoming_events[0] 336 | if paragraph_num == 1: 337 | return incoming_events 338 | if str(first_event).startswith("'"): 339 | return incoming_events 340 | if " had found " in str(first_event): 341 | return incoming_events 342 | if " was in " in str(first_event): 343 | return incoming_events 344 | choice = random.randint(0, 8) 345 | if choice == 0: 346 | first_event = first_event.rephrase( 347 | "Later on, " + first_event.phrase 348 | ) 349 | if choice == 1: 350 | first_event = first_event.rephrase( 351 | "Suddenly, " + first_event.phrase 352 | ) 353 | if choice == 2: 354 | first_event = first_event.rephrase( 355 | "After a moment's consideration, " + first_event.phrase 356 | ) 357 | if choice == 3: 358 | first_event = first_event.rephrase( 359 | "Feeling anxious, " + first_event.phrase 360 | ) 361 | return [first_event] + incoming_events[1:] 362 | 363 | 364 | class AggregateEventsTransformer(Transformer): 365 | # replace "Bob went to the kitchen. Bob saw the toaster" 366 | # with "Bob went to the kitchen, where he saw the toaster" 367 | def transform(self, editor, incoming_events, paragraph_num): 368 | events = [] 369 | for event in incoming_events: 370 | if events: 371 | if ( event.initiator() == events[-1].initiator() and 372 | events[-1].phrase in ('<1> went to <2>',) and 373 | event.phrase in ('<1> saw <2>',) ): 374 | # this *might* be better if we only do it when <1> 375 | # is the pov character for this paragraph. but it 376 | # does work... 377 | event.phrase = event.phrase.replace('<1>', '') 378 | events[-1] = AggregateEvent( 379 | "%s, where %s", [events[-1], event], 380 | excl = event.excl) 381 | else: 382 | events.append(event) 383 | else: 384 | events.append(event) 385 | return events 386 | 387 | 388 | class DetectWanderingTransformer(Transformer): 389 | # not used yet 390 | # if they 'made their way' to their current location... 391 | def transform(self, editor, incoming_events, paragraph_num): 392 | events = [] 393 | for event in incoming_events: 394 | if (event.phrase == '<1> made way to <2>' and 395 | event.location == event.previous_location()): 396 | event.phrase = '<1> wandered around for a bit, then came back to <2>' 397 | events.append(event) 398 | return events 399 | 400 | 401 | class Publisher(object): 402 | def __init__(self, characters=(), setting=(), friffery=False, 403 | debug=False, title='Untitled', chapters=18, 404 | events_per_chapter=810): 405 | self.characters = characters 406 | self.setting = setting 407 | self.friffery = friffery 408 | self.debug = debug 409 | self.title = title 410 | self.chapters = chapters 411 | self.events_per_chapter = events_per_chapter 412 | 413 | def publish_chapter(self, chapter_num): 414 | collector = EventCollector() 415 | 416 | for character in self.characters: 417 | character.collector = collector 418 | # don't continue a conversation from the previous chapter, please 419 | character.topic = None 420 | character.place_in(random.choice(self.setting)) 421 | 422 | while len(collector.events) < self.events_per_chapter: 423 | for character in self.characters: 424 | character.live() 425 | #print len(collector.events) # , repr([str(e) for e in collector.events]) 426 | 427 | if self.debug: 428 | for character in self.characters: 429 | print "%s'S EVENTS:" % character.name.upper() 430 | for event in collector.events: 431 | if event.participants[0] != character: 432 | continue 433 | print "%r in %s: %s" % ( 434 | [p.render(event=event) for p in event.participants], 435 | event.location.render(), 436 | event.phrase 437 | ) 438 | print 439 | for character in self.characters: 440 | print "%s'S STATE:" % character.name.upper() 441 | character.dump_beliefs() 442 | print 443 | print "- - - - -" 444 | print 445 | 446 | editor = Editor(collector, self.characters) 447 | editor.add_transformer(MadeTheirWayToTransformer()) 448 | editor.add_transformer(DeduplicateTransformer()) 449 | editor.add_transformer(AggregateEventsTransformer()) 450 | editor.add_transformer(DetectWanderingTransformer()) 451 | # this one should be last, so prior transformers don't 452 | # have to worry themselves about looking for pronouns 453 | editor.add_transformer(UsePronounsTransformer()) 454 | # this should be a matter of configuring what transformers 455 | # to use, when you instantiate a Publisher 456 | if self.friffery: 457 | editor.add_transformer(AddWeatherFrifferyTransformer()) 458 | editor.add_transformer(AddParagraphStartFrifferyTransformer()) 459 | editor.publish() 460 | 461 | def publish(self): 462 | print self.title 463 | print "=" * len(self.title) 464 | print 465 | 466 | for chapter in range(1, self.chapters+1): 467 | print "Chapter %d." % chapter 468 | print "-----------" 469 | print 470 | 471 | self.publish_chapter(chapter) 472 | -------------------------------------------------------------------------------- /src/swallows/story/characters.py: -------------------------------------------------------------------------------- 1 | # Copyright (c)2013 Chris Pressey, Cat's Eye Technologies 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a 4 | # copy of this software and associated documentation files (the "Software"), 5 | # to deal in the Software without restriction, including without limitation 6 | # the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | # and/or sell copies of the Software, and to permit persons to whom the 8 | # Software is furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | # DEALINGS IN THE SOFTWARE. 20 | 21 | import random 22 | import sys 23 | 24 | from swallows.engine.objects import ( 25 | Animate, ProperMixin, MasculineMixin, FeminineMixin, 26 | Topic, 27 | GreetTopic, SpeechTopic, QuestionTopic, 28 | Belief, ItemLocation, Goal, 29 | ) 30 | 31 | # TODO 32 | 33 | # They can hide something, then see the other carrying it, then check that 34 | # it's still hidden, and be surprised that it's no longer ther. 35 | # 'Hello, Alice', said Bob. 'Hello, Bob', replied Alice. NEVER GETS OLD 36 | # they should always scream at seeing the dead body. the scream should 37 | # be heard throughout the house and yard. 38 | # ...they check that the brandy is still in the liquor cabinet. is this 39 | # really necessary? 40 | # certain things can't be taken, but can be dragged (like the body) 41 | # path-finder between any two rooms -- not too difficult, even if it 42 | # would be nicer in Prolog. 43 | # "it was so nice" -- actually *have* memories of locations, and feelings 44 | # (good/bad, 0 to 10 or something) about memories 45 | # anxiety memory = the one they're most recently panicked about 46 | # memory of whether the revolver was loaded last time they saw it 47 | # calling their bluff 48 | # making a run for it when at gunpoint (or trying to distract them, 49 | # slap the gun away, scramble for it, etc) 50 | # revolver might jam when they try to shoot it (maybe it should be a 51 | # pistol instead, as those can jam more easily) 52 | # dear me, someone might actually get shot. then what? another dead body? 53 | 54 | 55 | ### some Swallows-specific topics (sort of) 56 | 57 | class WhereQuestionTopic(Topic): 58 | pass 59 | 60 | 61 | class ThreatGiveMeTopic(Topic): 62 | pass 63 | 64 | 65 | class ThreatTellMeTopic(Topic): 66 | pass 67 | 68 | 69 | class ThreatAgreeTopic(Topic): 70 | pass 71 | 72 | 73 | ### some Swallows-specific beliefs 74 | 75 | class SuspicionOfHiding(Belief): 76 | """This character suspects some other character of hiding this thing.""" 77 | def __str__(self): 78 | return "I think someone hid %s" % ( 79 | self.subject.render() 80 | ) 81 | 82 | 83 | ### Base character personalities for The Swallows 84 | 85 | class Character(Animate): 86 | def __init__(self, name, location=None, collector=None): 87 | """Constructor specific to characters. In it, we set up some 88 | Swallows-specific properties ('nerves'). 89 | 90 | """ 91 | Animate.__init__(self, name, location=location, collector=None) 92 | # this should really be *derived* from having a recent memory 93 | # of seeing a dead body in the bathroom. but for now, 94 | self.nerves = 'calm' 95 | 96 | def configure_objects(self, revolver=None, brandy=None, dead_body=None): 97 | """Here we set up some important items that this character needs 98 | to know about. This is maybe a form of dependency injection. 99 | 100 | """ 101 | self.revolver = revolver 102 | self.brandy = brandy 103 | self.dead_body = dead_body 104 | 105 | def believe_location(self, thing, location, informant=None, concealer=None): 106 | # we override this method of Animate in order to also remove 107 | # our suspicion that the item has been hidden. 'cos we found it. 108 | Animate.believe_location(self, thing, location, informant=informant, concealer=concealer) 109 | self.beliefs.remove(SuspicionOfHiding(thing)) 110 | 111 | def move_to(self, location): 112 | """Override some behaviour upon moving to a new location. 113 | 114 | """ 115 | Animate.move_to(self, location) 116 | if random.randint(0, 10) == 0: 117 | self.emit("It was so nice being in <2> again", 118 | [self, self.location], excl=True) 119 | 120 | # okay, look around you. 121 | for x in self.location.contents: 122 | assert x.location == self.location 123 | if x == self: 124 | continue 125 | if x.horror(): 126 | belief = self.recall_location(x) 127 | if belief: 128 | amount = random.choice(['shudder', 'wave']) 129 | emotion = random.choice(['fear', 'disgust', 'sickness', 'loathing']) 130 | self.emit("<1> felt a %s of %s as looked at <2>" % (amount, emotion), [self, x]) 131 | self.remember_location(x, self.location) 132 | else: 133 | verb = random.choice(['screamed', 'yelped', 'went pale']) 134 | self.emit("<1> %s at the sight of " % verb, [self, x], excl=True) 135 | self.remember_location(x, self.location) 136 | self.nerves = 'shaken' 137 | elif x.animate(): 138 | other = x 139 | self.emit("<1> saw <2>", [self, other]) 140 | other.emit("<1> saw <2> walk into the %s" % self.location.noun(), [other, self]) 141 | self.remember_location(x, self.location) 142 | self.greet(x, "'Hello, <2>,' said <1>") 143 | for y in other.contents: 144 | if y.treasure(): 145 | self.emit( 146 | "<1> noticed <2> carrying ", 147 | [self, other, y]) 148 | if self.revolver.location == self: 149 | self.point_at(other, self.revolver) 150 | self.address(other, 151 | ThreatGiveMeTopic(self, subject=y), 152 | "'Please give me <3>, <2>, or I shall shoot you,' said", 153 | [self, other, y]) 154 | return 155 | # check if we suspect something of being hidden. 156 | suspicions = list(self.beliefs.beliefs_of_class(SuspicionOfHiding)) 157 | # if we do... and we can do something about it... 158 | actionable_suspicions = [] 159 | for suspicion in suspicions: 160 | if not suspicion.subject.treasure(): 161 | continue 162 | if self.beliefs.get(ItemLocation(suspicion.subject)): 163 | continue 164 | actionable_suspicions.append(suspicion) 165 | if actionable_suspicions and self.revolver.location == self: 166 | suspicion = random.choice(actionable_suspicions) 167 | self.point_at(other, self.revolver) 168 | self.address(other, 169 | ThreatTellMeTopic(self, subject=suspicion.subject), 170 | "'Tell me where you have hidden <3>, <2>, or I shall shoot you,' said", 171 | [self, other, suspicion.subject]) 172 | return 173 | elif x.notable(): 174 | self.emit("<1> saw <2>", [self, x]) 175 | self.remember_location(x, self.location) 176 | 177 | def live(self): 178 | """Override some behaviour for taking a turn in the story. 179 | 180 | """ 181 | # first, if in a conversation, turn total attention to that 182 | if self.topic is not None: 183 | return self.converse(self.topic) 184 | 185 | # otherwise, if there are items here that you desire, you *must* pick 186 | # them up. 187 | for x in self.location.contents: 188 | if self.does_desire(x): 189 | self.pick_up(x) 190 | return 191 | people_about = False 192 | 193 | # otherwise, fixate on some valuable object (possibly the revolver) 194 | # that you are carrying: 195 | fixated_on = None 196 | for y in self.contents: 197 | if y.treasure(): 198 | fixated_on = y 199 | break 200 | if not fixated_on and random.randint(0, 20) == 0 and self.revolver.location == self: 201 | fixated_on = self.revolver 202 | 203 | # check if you are alone 204 | for x in self.location.contents: 205 | if x.animate() and x is not self: 206 | people_about = True 207 | 208 | choice = random.randint(0, 25) 209 | if choice < 10 and not people_about: 210 | return self.hide_and_seek(fixated_on) 211 | if choice < 20: 212 | return self.wander() 213 | if choice == 20: 214 | self.emit("<1> yawned", [self]) 215 | elif choice == 21: 216 | self.emit("<1> gazed thoughtfully into the distance", [self]) 217 | elif choice == 22: 218 | self.emit("<1> thought heard something", [self]) 219 | elif choice == 23: 220 | self.emit("<1> scratched head", [self]) 221 | elif choice == 24: 222 | self.emit("<1> immediately had a feeling something was amiss", [self]) 223 | else: 224 | return self.wander() 225 | 226 | # 227 | # The following are fairly plot-specific. 228 | # 229 | 230 | def hide_and_seek(self, fixated_on): 231 | # check for some place to hide the thing you're fixating on 232 | containers = [] 233 | for container in self.location.contents: 234 | if container.container(): 235 | # did I hide something here previously? 236 | beliefs_about_container = [] 237 | for thing in self.beliefs.subjects(): 238 | belief = self.recall_location(thing) 239 | if belief and belief.location == container: 240 | beliefs_about_container.append(belief) 241 | containers.append((container, beliefs_about_container)) 242 | if not containers: 243 | # ? ... maybe this should be the responsibility of the caller 244 | return self.wander() 245 | # ok! we now have a list of containers, each of which has zero or 246 | # more beliefs of things being in it. 247 | if fixated_on: 248 | (container, beliefs) = random.choice(containers) 249 | self.emit("<1> hid <2> in <3>", [self, fixated_on, container]) 250 | fixated_on.move_to(container) 251 | self.remember_location(fixated_on, container, concealer=self) 252 | return self.wander() 253 | else: 254 | # we're looking for treasure! 255 | # todo: it would maybe be better to prioritize this selection 256 | (container, beliefs) = random.choice(containers) 257 | # sometimes, we don't care what we think we know about something 258 | # (this lets us, for example, explore things in hopes of brandy) 259 | if beliefs and random.randint(0, 3) == 0: 260 | beliefs = None 261 | if beliefs: 262 | belief = random.choice(beliefs) 263 | thing = belief.subject 264 | picking_up = random.randint(0, 5) == 0 265 | if thing is self.revolver: 266 | picking_up = True 267 | if picking_up: 268 | if belief.concealer is self: 269 | self.emit("<1> retrieved <3> had hidden in <2>", 270 | [self, container, thing]) 271 | else: 272 | self.emit("<1> retrieved <3> from <2>", 273 | [self, container, thing]) 274 | # but! 275 | if thing.location != container: 276 | self.emit("But missing", 277 | [self, thing], excl=True 278 | ) 279 | # forget ALLLLLLL about it, then. so realistic! 280 | self.forget_location(thing) 281 | else: 282 | thing.move_to(self) 283 | self.remember_location(thing, self) 284 | else: 285 | self.emit("<1> checked that <3> still in <2>", 286 | [self, container, thing]) 287 | # but! 288 | if thing.location != container: 289 | self.emit("But missing", 290 | [self, thing], excl=True 291 | ) 292 | self.forget_location(thing) 293 | self.beliefs.add(SuspicionOfHiding(thing)) 294 | else: # no memories of this 295 | self.emit("<1> searched <2>", [self, container]) 296 | desired_things = [] 297 | for thing in container.contents: 298 | # remember what you saw whilst searching this container 299 | self.remember_location(thing, container) 300 | if self.does_desire(thing): 301 | desired_things.append(thing) 302 | if desired_things: 303 | thing = random.choice(desired_things) 304 | self.emit("<1> found <2> there, and took ", 305 | [self, thing, container], exciting=True) 306 | thing.move_to(self) 307 | self.remember_location(thing, self) 308 | 309 | def converse(self, topic): 310 | self.topic = None 311 | other = topic.originator 312 | if isinstance(topic, ThreatGiveMeTopic): 313 | found_object = None 314 | for x in self.contents: 315 | if x is topic.subject: 316 | found_object = x 317 | break 318 | if not found_object: 319 | self.speak_to(other, 320 | "'But I don't have <3>!' protested <1>", 321 | [self, other, topic.subject]) 322 | else: 323 | self.speak_to(other, 324 | "'Please don't shoot!', <1> cried", 325 | [self, other, found_object]) 326 | self.give_to(other, found_object) 327 | elif isinstance(topic, ThreatTellMeTopic): 328 | belief = self.recall_location(topic.subject) 329 | if not belief: 330 | self.speak_to(other, 331 | "'I have no memory of that, <2>,' <1> replied", 332 | [self, other, topic.subject]) 333 | else: 334 | self.speak_to(other, 335 | "'Please don't shoot!', <1> cried, ' in <4>'", 336 | [self, other, topic.subject, belief.location]) 337 | other.believe_location(topic.subject, belief.location, 338 | informant=self, concealer=self) 339 | elif isinstance(topic, ThreatAgreeTopic): 340 | self.speak_to(other, 341 | "'You make a persuasive case for remaining undecided, <2>,' said <1>", 342 | [self, other]) 343 | self.beliefs.remove(Goal(topic.subject)) 344 | # update other's BeliefsBelief about self to no longer 345 | # contain this Goal 346 | other.believed_beliefs_of(self).remove(Goal(topic.subject)) 347 | elif isinstance(topic, GreetTopic): 348 | # emit, because making this a speak_to leads to too much silliness 349 | self.emit("'Hello, <2>,' replied <1>", [self, other]) 350 | # this needs to be more general 351 | self_belief = self.recall_location(self.dead_body) 352 | if self_belief: 353 | self.discuss(other, self_belief) 354 | return 355 | # this need not be *all* the time 356 | for x in other.contents: 357 | if x.notable(): 358 | self.remember_location(x, other) 359 | self.speak_to(other, "'I see you are carrying ,' said <1>", [self, other, x]) 360 | return 361 | choice = random.randint(0, 3) 362 | if choice == 0: 363 | self.question(other, "'Lovely weather we're having, isn't it?' asked <1>") 364 | if choice == 1: 365 | self.speak_to(other, "'I was wondering where you were,' said <1>") 366 | elif isinstance(topic, QuestionTopic): 367 | if topic.subject is not None: 368 | choice = random.randint(0, 1) 369 | if choice == 0: 370 | self.speak_to(other, "'I know nothing about <3>, <2>,' explained <1>", 371 | [self, other, topic.subject]) 372 | if choice == 1: 373 | self.speak_to(other, "'Perhaps, <2>,' replied <1>") 374 | else: 375 | self.speak_to(other, "'Perhaps, <2>,' replied <1>") 376 | elif isinstance(topic, WhereQuestionTopic): 377 | belief = self.recall_location(topic.subject) 378 | if not belief: 379 | self.speak_to(other, 380 | "'I don't know,' <1> answered simply", 381 | [self, other, topic.subject]) 382 | elif belief.concealer == self: 383 | self.question(other, 384 | "'Why do you want to know where <3> is, <2>?'", 385 | [self, other, topic.subject]) 386 | elif topic.subject.location == self: 387 | self.speak_to(other, 388 | "'I've got <3> right here, <2>'", 389 | [self, other, topic.subject]) 390 | self.put_down(topic.subject) 391 | else: 392 | if topic.subject.location.animate(): 393 | self.speak_to(other, 394 | "'I think <3> has <4>,', <1> recalled", 395 | [self, other, belief.location, topic.subject]) 396 | else: 397 | self.speak_to(other, 398 | "'I believe it's in <3>, <2>,', <1> recalled", 399 | [self, other, belief.location]) 400 | other.believe_location( 401 | topic.subject, belief.location, informant=self 402 | ) 403 | elif isinstance(topic, SpeechTopic): 404 | choice = random.randint(0, 5) 405 | if choice == 0: 406 | self.emit("<1> nodded", [self]) 407 | if choice == 1: 408 | self.emit("<1> remained silent", [self]) 409 | if choice == 2: 410 | self.question(other, "'Do you really think so?' asked <1>") 411 | if choice == 3: 412 | self.speak_to(other, "'Yes, it's a shame really,' stated <1>") 413 | if choice == 4: 414 | self.speak_to(other, "'Oh, I know, I know,' said <1>") 415 | if choice == 5: 416 | # -- this is getting really annoying. disable for now. -- 417 | # item = random.choice(ALL_ITEMS) 418 | # self.question(other, "'But what about <3>, <2>?' posed <1>", 419 | # [self, other, item], subject=item) 420 | self.speak_to(other, "'I see, <2>, I see,' said <1>") 421 | 422 | # this is its own method for indentation reasons 423 | def discuss(self, other, self_memory): 424 | assert self_memory 425 | assert isinstance(self_memory, Belief) 426 | # for now, 427 | # self_memory is an ItemLocation belief about something on our mind 428 | assert isinstance(self_memory, ItemLocation) 429 | 430 | # what do I believe the other believes about it? 431 | other_beliefs = self.believed_beliefs_of(other) 432 | other_memory = other_beliefs.get(self_memory) 433 | 434 | if not other_memory: 435 | self.question(other, 436 | "'Did you know there's in <4>?' asked <1>", 437 | [self, other, self_memory.subject, self_memory.location], 438 | subject=self_memory.subject) 439 | # well now they know what we think, anyway 440 | other.believed_beliefs_of(self).add(self_memory) 441 | return 442 | else: 443 | choice = random.randint(0, 2) 444 | if choice == 0: 445 | self.question(other, "'Do you think we should do something about <3>?' asked <1>", 446 | [self, other, self_memory.subject]) 447 | other.believed_beliefs_of(self).add(self_memory) 448 | if choice == 1: 449 | self.speak_to(other, "'I think we should do something about <3>, <2>,' said <1>", 450 | [self, other, self_memory.subject]) 451 | other.believed_beliefs_of(self).add(self_memory) 452 | if choice == 2: 453 | if self.nerves == 'calm': 454 | self.decide_what_to_do_about(other, self_memory.subject) 455 | else: 456 | if self.brandy.location == self: 457 | self.emit("<1> poured self a glass of brandy", 458 | [self, other, self_memory.subject]) 459 | self.quench_desire(self.brandy) 460 | self.nerves = 'calm' 461 | self.put_down(self.brandy) 462 | elif self.recall_location(self.brandy): 463 | self.speak_to(other, 464 | "'I really must pour myself a drink,' moaned <1>", 465 | [self, other, self_memory.subject], 466 | subject=self.brandy) 467 | self.desire(self.brandy) 468 | if random.randint(0, 1) == 0: 469 | self.address(other, WhereQuestionTopic(self, subject=self.brandy), 470 | "'Where did you say <3> was?'", 471 | [self, other, self.brandy]) 472 | else: 473 | self.address(other, WhereQuestionTopic(self, subject=self.brandy), 474 | "'Where is the brandy? I need a drink,' managed <1>", 475 | [self, other, self_memory.subject]) 476 | self.desire(self.brandy) 477 | 478 | # this is its own method for indentation reasons 479 | def decide_what_to_do_about(self, other, thing): 480 | # this should probably be affected by whether this 481 | # character has, oh, i don't know, put the other at 482 | # gunpoint yet, or not, or something 483 | my_goal = self.beliefs.get(Goal(thing)) 484 | if my_goal is None: 485 | if random.randint(0, 1) == 0: 486 | self.beliefs.add(Goal(thing, 'call the police about')) 487 | else: 488 | self.beliefs.add(Goal(thing, 'try to dispose of')) 489 | my_goal = self.beliefs.get(Goal(thing)) 490 | assert my_goal is not None 491 | 492 | # here's where it gets a bit gnarly. 493 | # what do I believe the other believes? 494 | other_beliefs = self.believed_beliefs_of(other) 495 | # more specifically, what are their goals regarding the thing? 496 | other_goal = other_beliefs.get(Goal(thing)) 497 | 498 | # they don't have one yet. tell them ours. 499 | if other_goal is None: 500 | self.speak_to(other, 501 | "'I really think we should %s <3>, <2>,' said <1>" % my_goal.phrase, 502 | [self, other, thing]) 503 | # ok, they know what I think now: 504 | other.believed_beliefs_of(self).add(my_goal) 505 | elif other_goal.phrase == my_goal.phrase: 506 | # TODO make this an AgreementQuestion or smth 507 | self.question(other, 508 | ("'So we're agreed then, we should %s <3>?' asked <1>" % 509 | my_goal.phrase), 510 | [self, other, thing]) 511 | # the other party might not've been aware that they agree 512 | other.believed_beliefs_of(self).add(my_goal) 513 | else: # WE DO NOT AGREE. 514 | if self.revolver.location == self: 515 | self.point_at(other, self.revolver) 516 | self.address(other, 517 | ThreatAgreeTopic(self, subject=thing), 518 | ("'I really feel *very* strongly that we should %s <3>, <2>,' said between clenched teeth" % 519 | my_goal.phrase), 520 | [self, other, thing]) 521 | # just in case they weren't clued in yet 522 | other.believed_beliefs_of(self).add(my_goal) 523 | else: 524 | self.speak_to(other, 525 | ("'I don't think it would be a good idea to %s <3>, <2>,' said <1>" % 526 | my_goal.phrase), 527 | [self, other, thing]) 528 | other.believed_beliefs_of(self).add(my_goal) 529 | 530 | 531 | class MaleCharacter(MasculineMixin, ProperMixin, Character): 532 | pass 533 | 534 | 535 | class FemaleCharacter(FeminineMixin, ProperMixin, Character): 536 | pass 537 | --------------------------------------------------------------------------------