├── .gitignore ├── package.json ├── examples ├── 01_basic.js ├── 02_tags.js ├── 03_relations.js └── 04_rooms_and_objects.js ├── seaduck.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "seaduck", 3 | "version": "0.0.1", 4 | "description": "A bare-bones simulation-driven narrative framework", 5 | "homepage": "https://github.com/aparrish/seaduck", 6 | "main": "seaduck.js", 7 | "scripts": { 8 | "build": "browserify seaduck.js --standalone seaduck > build/seaduck-bundle.js" 9 | }, 10 | "keywords": [], 11 | "author": "Allison Parrish ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "object-hash": "^1.3.0", 15 | "tracery-grammar": "^2.7.3" 16 | }, 17 | "devDependencies": { 18 | "browserify": "^16.2.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /examples/01_basic.js: -------------------------------------------------------------------------------- 1 | let seaduck = require("../seaduck"); 2 | 3 | let n = new seaduck.Narrative({ 4 | "nouns": [ 5 | { 6 | "name": "Chris", 7 | "properties": { 8 | "sleepiness": 0 9 | }, 10 | "tags": ["person"] 11 | }, 12 | { 13 | "name": "king-size bed", 14 | "properties": { 15 | "occupied": false 16 | }, 17 | "tags": ["bed"] 18 | }, 19 | ], 20 | "actions": [ 21 | { 22 | "match": ["Chris"], 23 | "when": function(a) { 24 | return a.properties.sleepiness < 10; 25 | }, 26 | "action": function*(a) { 27 | a.properties.sleepiness++; 28 | yield new seaduck.StoryEvent("moreSleepy", a); 29 | } 30 | }, 31 | { 32 | "match": ["Chris"], 33 | "when": function(a) { 34 | return a.properties.sleepiness == 7; 35 | }, 36 | "action": function*(a) { 37 | yield new seaduck.StoryEvent("reallySleepy", a); 38 | } 39 | }, 40 | { 41 | "match": ["Chris", "king-size bed"], 42 | "when": function(a, b) { 43 | return a.properties.sleepiness >= 10 44 | && !this.isRelated("sleepingIn", a, b) 45 | && !b.properties.occupied; 46 | }, 47 | "action": function*(a, b) { 48 | this.relate("sleepingIn", a, b); 49 | b.properties.occupied = true; 50 | yield new seaduck.StoryEvent("getsInto", a, b); 51 | } 52 | }, 53 | { 54 | "match": ["Chris", "king-size bed"], 55 | "when": function(a, b) { 56 | return this.isRelated("sleepingIn", a, b); 57 | }, 58 | "action": function*(a, b) { 59 | yield new seaduck.StoryEvent("asleep", a, b); 60 | } 61 | } 62 | ], 63 | "traceryDiscourse": { 64 | "moreSleepy": [ 65 | "#nounA# yawns.", 66 | "#nounA#'s eyelids droop.", 67 | "#nounA# nods off for a second, then perks up.", 68 | "#nounA# says, 'I could use a cup of coffee.'", 69 | "'I don't think I can stay awake a minute longer,' says #nounA# to no one in particular.", 70 | "#nounA# checks their watch." 71 | ], 72 | "adverb": ["at last", "finally", "not a moment too soon"], 73 | "getsInto": [ 74 | "#adverb.capitalize#, #nounA# gets into the #nounB#.", 75 | "#adverb.capitalize#, #nounA# climbs into the #nounB#." 76 | ], 77 | "asleep": [ 78 | "#nounA# is asleep in the #nounB#.", 79 | "#nounA# snores beneath the covers of the #nounB#.", 80 | "#nounA# sleep-mumbles peacefully in the #nounB#." 81 | ], 82 | "reallySleepy": [ 83 | "#nounA# is really sleepy.", 84 | "'I'm just about ready to hit the hay,' says #nounA#.", 85 | "You can tell just by looking at them that #nounA# really needs some rest." 86 | ], 87 | "_end": [ 88 | "Good night." 89 | ] 90 | } 91 | }); 92 | 93 | for (let i = 0; i < 100; i++) { 94 | let storyEvents = n.stepAndRender(); 95 | if (storyEvents.length > 0) { 96 | for (let ev of storyEvents) { 97 | console.log(ev); 98 | } 99 | } 100 | else { 101 | break; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/02_tags.js: -------------------------------------------------------------------------------- 1 | let seaduck = require("../seaduck"); 2 | 3 | let n = new seaduck.Narrative({ 4 | "nouns": [ 5 | { 6 | "name": "Chris", 7 | "properties": { 8 | "sleepiness": 0 9 | }, 10 | "tags": ["person"] 11 | }, 12 | { 13 | "name": "Finn", 14 | "properties": { 15 | "sleepiness": 5 16 | }, 17 | "tags": ["person"] 18 | }, 19 | { 20 | "name": "top bunk", 21 | "properties": { 22 | "occupied": false 23 | }, 24 | "tags": ["bed"] 25 | }, 26 | { 27 | "name": "bottom bunk", 28 | "properties": { 29 | "occupied": false 30 | }, 31 | "tags": ["bed"] 32 | } 33 | ], 34 | "actions": [ 35 | { 36 | "match": ["#person"], 37 | "when": function(a) { 38 | return a.properties.sleepiness < 10; 39 | }, 40 | "action": function*(a) { 41 | a.properties.sleepiness++; 42 | yield new seaduck.StoryEvent("moreSleepy", a); 43 | } 44 | }, 45 | { 46 | "match": ["#person"], 47 | "when": function(a) { 48 | return a.properties.sleepiness == 7; 49 | }, 50 | "action": function*(a) { 51 | yield new seaduck.StoryEvent("reallySleepy", a); 52 | } 53 | }, 54 | { 55 | "match": ["#person", "#bed"], 56 | "when": function(a, b) { 57 | return a.properties.sleepiness >= 10 58 | && !this.relatedByTag("sleepingIn", a, "bed") 59 | && !b.properties.occupied; 60 | }, 61 | "action": function*(a, b) { 62 | this.relate("sleepingIn", a, b); 63 | b.properties.occupied = true; 64 | yield new seaduck.StoryEvent("getsInto", a, b); 65 | } 66 | }, 67 | { 68 | "match": ["#person", "#bed"], 69 | "when": function(a, b) { 70 | return this.isRelated("sleepingIn", a, b); 71 | }, 72 | "action": function*(a, b) { 73 | yield new seaduck.StoryEvent("asleep", a, b); 74 | } 75 | } 76 | ], 77 | "traceryDiscourse": { 78 | "moreSleepy": [ 79 | "#nounA# yawns.", 80 | "#nounA#'s eyelids droop.", 81 | "#nounA# nods off for a second, then perks up.", 82 | "#nounA# says, 'I could use a cup of coffee.'", 83 | "'I don't think I can stay awake a minute longer,' says #nounA# to no one in particular.", 84 | "#nounA# checks their watch." 85 | ], 86 | "adverb": ["at last", "finally", "not a moment too soon"], 87 | "getsInto": [ 88 | "#adverb.capitalize#, #nounA# gets into the #nounB#.", 89 | "#adverb.capitalize#, #nounA# climbs into the #nounB#." 90 | ], 91 | "asleep": [ 92 | "#nounA# is asleep in the #nounB#.", 93 | "#nounA# snores beneath the covers of the #nounB#.", 94 | "#nounA# sleep-mumbles peacefully in the #nounB#." 95 | ], 96 | "reallySleepy": [ 97 | "#nounA# is really sleepy.", 98 | "'I'm just about ready to hit the hay,' says #nounA#.", 99 | "You can tell just by looking at them that #nounA# really needs some rest." 100 | ], 101 | "_end": [ 102 | "Good night." 103 | ] 104 | } 105 | }); 106 | 107 | for (let i = 0; i < 100; i++) { 108 | let storyEvents = n.stepAndRender(); 109 | if (storyEvents.length > 0) { 110 | for (let ev of storyEvents) { 111 | console.log(ev); 112 | } 113 | } 114 | else { 115 | break; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /examples/03_relations.js: -------------------------------------------------------------------------------- 1 | let seaduck = require('../seaduck.js') 2 | 3 | let n = new seaduck.Narrative({ 4 | "nouns": [ 5 | { 6 | "name": "Joe", 7 | "properties": { 8 | "happiness": 0, 9 | "hungry": true 10 | }, 11 | "tags": ["person"] 12 | }, 13 | { 14 | "name": "Mary", 15 | "properties": { 16 | "happiness": 0, 17 | "hungry": true 18 | }, 19 | "tags": ["person"] 20 | }, 21 | { 22 | "name": "Horatio", 23 | "properties": { 24 | "happiness": 0, 25 | "hungry": true 26 | }, 27 | "tags": ["person"] 28 | }, 29 | { 30 | "name": "cookie", 31 | "properties": { 32 | "tastiness": 2, 33 | "eaten": false 34 | }, 35 | "tags": ["food"] 36 | }, 37 | { 38 | "name": "spinach", 39 | "properties": { 40 | "tastiness": 1, 41 | "eaten": false 42 | }, 43 | "tags": ["food"] 44 | }, 45 | { 46 | "name": "cake", 47 | "properties": { 48 | "tastiness": 3, 49 | "eaten": false 50 | }, 51 | "tags": ["food"] 52 | } 53 | ], 54 | "initialize": function*() { 55 | for (let noun of this.getNounsByProperty("hungry", true)) { 56 | yield (new seaduck.StoryEvent("isHungry", noun)); 57 | } 58 | }, 59 | "actions": [ 60 | { 61 | "name": "eat", 62 | "match": ["#person", "#food"], 63 | "when": function(a, b) { 64 | return a.properties.hungry 65 | && b.properties.tastiness > 0 66 | && !b.properties.eaten; 67 | }, 68 | "action": function*(a, b) { 69 | yield (new seaduck.StoryEvent("eat", a, b)); 70 | a.properties.hungry = false; 71 | b.properties.eaten = true; 72 | a.properties.happiness += b.properties.tastiness; 73 | if (b.properties.tastiness >= 2) { 74 | yield (new seaduck.StoryEvent("reallyLike", a, b)); 75 | } 76 | } 77 | }, 78 | { 79 | "name": "befriend", 80 | "match": ["#person", "#person"], 81 | "when": function(a, b) { 82 | return ( 83 | (!a.properties.hungry && !b.properties.hungry) 84 | && !this.isRelated("friendship", a, b)); 85 | }, 86 | "action": function*(a, b) { 87 | yield (new seaduck.StoryEvent("makeFriends", a, b)); 88 | this.reciprocal("friendship", a, b); 89 | } 90 | }, 91 | { 92 | "name": "express happiness", 93 | "match": ["#person"], 94 | "when": function(a) { 95 | return !a.properties.hungry 96 | && a.properties.happiness >= 2 97 | && this.allRelatedByTag("friendship", a, "#person").length > 0; 98 | }, 99 | "action": function*(a) { 100 | yield (new seaduck.StoryEvent("isHappy", a)); 101 | } 102 | } 103 | ], 104 | "traceryDiscourse": { 105 | "isHappy": ["#nounA# was happy", "#nounA# felt good!"], 106 | "isHungry": [ 107 | "#nounA# had a rumble in their tummy.", 108 | "#nounA# felt very hungry."], 109 | "makeFriends": [ 110 | "#nounA# made friends with #nounB#.", 111 | "#nounA# and #nounB# became friends."], 112 | "reallyLike": [ 113 | "And let me tell you, #nounA# really enjoyed that #nounB#.", 114 | "#nounA# says, 'This #nounB# is so delicious!'" 115 | ], 116 | "eat": [ 117 | "#nounA# ate a #nounB#.", 118 | "#nounA# gobbled up a #nounB#." 119 | ], 120 | "_end": ["The end.", "And they lived happily ever after."] 121 | } 122 | }); 123 | 124 | for (let i = 0; i < 100; i++) { 125 | let storyEvents = n.stepAndRender(); 126 | if (storyEvents.length > 0) { 127 | for (let ev of storyEvents) { 128 | console.log(ev); 129 | } 130 | } 131 | else { 132 | break; 133 | } 134 | } 135 | 136 | -------------------------------------------------------------------------------- /examples/04_rooms_and_objects.js: -------------------------------------------------------------------------------- 1 | let seaduck = require("../seaduck") 2 | 3 | let n = new seaduck.Narrative({ 4 | "nouns": [ 5 | { 6 | "name": "kitchen", 7 | "properties": {}, 8 | "tags": ["room"] 9 | }, 10 | { 11 | "name": "living room", 12 | "properties": {}, 13 | "tags": ["room"] 14 | }, 15 | { 16 | "name": "study", 17 | "properties": {}, 18 | "tags": ["room"] 19 | }, 20 | { 21 | "name": "Max", 22 | "properties": { 23 | "has_drink": false 24 | }, 25 | "tags": ["person"] 26 | }, 27 | { 28 | "name": "Rory", 29 | "properties": { 30 | "has_drink": false 31 | }, 32 | "tags": ["person"] 33 | }, 34 | { 35 | "name": "coffee", 36 | "properties": {}, 37 | "tags": ["drink"] 38 | }, 39 | { 40 | "name": "tea", 41 | "properties": {}, 42 | "tags": ["drink"] 43 | } 44 | ], 45 | "initialize": function*() { 46 | // set up map 47 | this.reciprocal( 48 | "connects to", this.noun("kitchen"), this.noun("living room")); 49 | this.reciprocal( 50 | "connects to", this.noun("kitchen"), this.noun("study")); 51 | 52 | // put people and objects in rooms 53 | this.relate( 54 | "currently in", this.noun("Max"), this.noun("living room")); 55 | yield new seaduck.StoryEvent( 56 | "in", this.noun("Max"), this.noun("living room")); 57 | 58 | this.relate( 59 | "currently in", this.noun("Rory"), this.noun("study")); 60 | yield new seaduck.StoryEvent( 61 | "in", this.noun("Rory"), this.noun("study")); 62 | 63 | this.relate( 64 | "currently in", this.noun("coffee"), this.noun("kitchen")); 65 | yield new seaduck.StoryEvent( 66 | "in", this.noun("coffee"), this.noun("kitchen")); 67 | 68 | this.relate( 69 | "currently in", this.noun("tea"), this.noun("kitchen")); 70 | yield new seaduck.StoryEvent( 71 | "in", this.noun("tea"), this.noun("kitchen")); 72 | 73 | }, 74 | "actions": [ 75 | { 76 | "name": "take", 77 | "match": ["#person", "#drink"], 78 | "when": function(a, b) { 79 | let aLocation = this.relatedByTag("currently in", a, "room"); 80 | let bLocation = this.relatedByTag("currently in", b, "room"); 81 | return aLocation == bLocation && !a.properties.has_drink; 82 | }, 83 | "action": function*(a, b) { 84 | yield (new seaduck.StoryEvent("take", a, b)); 85 | // remove from all rooms 86 | this.unrelateByTag("currently in", b, "room"); 87 | a.properties.has_drink = true; 88 | } 89 | }, 90 | { 91 | "name": "move", 92 | "match": ["#person"], 93 | "when": function(a) { 94 | return !(this.isRelated("currently in", a, this.noun("study")) 95 | && a.properties.has_drink); 96 | }, 97 | "action": function*(a) { 98 | let current = this.relatedByTag("currently in", a, "room"); 99 | let dests = this.allRelatedByTag("connects to", current, "room"); 100 | let chosenDest = this.choice(dests); 101 | this.unrelate("currently in", a, current); 102 | this.relate("currently in", a, chosenDest); 103 | yield (new seaduck.StoryEvent("moveTo", a, chosenDest)); 104 | } 105 | }, 106 | { 107 | "name": "talk", 108 | "match": ["#person", "#person"], 109 | "when": function(a, b) { 110 | let aLocation = this.relatedByTag("currently in", a, "room"); 111 | let bLocation = this.relatedByTag("currently in", b, "room"); 112 | return aLocation == bLocation; 113 | }, 114 | "action": function*(a, b) { 115 | yield (new seaduck.StoryEvent("chatsWith", a, b)); 116 | } 117 | }, 118 | { 119 | "name": "work", 120 | "match": ["#person"], 121 | "when": function(a) { 122 | return this.isRelated("currently in", a, this.noun("study")) 123 | && a.properties.has_drink; 124 | }, 125 | "action": function*(a) { 126 | yield (new seaduck.StoryEvent("isWorking", a)); 127 | } 128 | }, 129 | { 130 | "name": "play video games", 131 | "match": ["#person"], 132 | "when": function(a) { 133 | return this.isRelated("currently in", a, this.noun("living room")); 134 | }, 135 | "action": function*(a) { 136 | yield (new seaduck.StoryEvent("playGames", a)); 137 | } 138 | } 139 | ], 140 | "traceryDiscourse": { 141 | "in": [ 142 | "#nounA.capitalize# was in the #nounB#." 143 | ], 144 | "take": [ 145 | "#nounA# took #nounB#.", 146 | "'Oh, hey, #nounB#!' said #nounA#, and picked it up." 147 | ], 148 | "moveTo": [ 149 | "After a while, #nounA# went into the #nounB#.", 150 | "#nounA# decided to go into the #nounB#." 151 | ], 152 | "topic": ["the weather", "the garden", "the phase of the moon", 153 | "#nounA#'s family", "the books they've been reading"], 154 | "chatsWith": [ 155 | "#nounA# and #nounB# chatted for a bit.", 156 | "#nounA# asked #nounB# how their day was going.", 157 | "#nounB# told #nounA# about a dream they had last night.", 158 | "#nounA# and #nounB# talked for a bit about #topic#." 159 | ], 160 | "isWorking": [ 161 | "#nounA# typed furiously on their laptop.", 162 | "#nounA# was taking notes while reading a book from the library.", 163 | "#nounA# sighed as they clicked 'Send' on another e-mail." 164 | ], 165 | "videoGame": ["Destiny 2", "Splatoon 2", "Skyrim", "Zelda", "Bejeweled"], 166 | "playGames": [ 167 | "#nounA# sat down to play #videoGame# for a while.", 168 | "#nounA# decided to get a few minutes of #videoGame# in.", 169 | "#nounA# turned on the video game console. 'Ugh I love #videoGame# so much,' said #nounA#." 170 | ], 171 | "_end": [ 172 | "The end." 173 | ] 174 | } 175 | }); 176 | 177 | for (let i = 0; i < 100; i++) { 178 | let storyEvents = n.stepAndRender(); 179 | if (storyEvents.length > 0) { 180 | for (let ev of storyEvents) { 181 | console.log(ev); 182 | } 183 | } 184 | else { 185 | break; 186 | } 187 | } 188 | 189 | 190 | -------------------------------------------------------------------------------- /seaduck.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let hash = require('object-hash'); 4 | let tracery = require('tracery-grammar'); 5 | 6 | function mk(t) { 7 | return t.join("$"); 8 | } 9 | 10 | function filterTagMatch(matchStr, item) { 11 | if (matchStr.charAt(0) == "#") { 12 | let tagStr = matchStr.substring(1); 13 | if (item.tags.includes(tagStr)) { 14 | return true; 15 | } 16 | } 17 | else { 18 | if (item.name == matchStr) { 19 | return true; 20 | } 21 | } 22 | return false; 23 | } 24 | 25 | class Narrative { 26 | constructor(narrative) { 27 | this.narrative = narrative; 28 | this.stepCount = 0; 29 | this.relations = new Map(); 30 | this.eventHistory = []; 31 | this.stateHistory = []; 32 | } 33 | choice(t) { 34 | // convenience function for selecting among alternatives in a list 35 | return t[Math.floor(Math.random()*t.length)]; 36 | } 37 | noun(name) { 38 | // get the noun object in the narrative with the corresponding name 39 | for (let noun of this.narrative.nouns) { 40 | if (noun.name == name) { 41 | return noun; 42 | } 43 | } 44 | } 45 | getNounsByTag(tag) { 46 | // get all nouns in the narrative with this tag 47 | let matches = []; 48 | for (let noun of this.narrative.nouns) { 49 | if (noun.tags.includes(tag)) { 50 | matches.push(noun); 51 | } 52 | } 53 | return matches; 54 | } 55 | getNounsByProperty(prop, val) { 56 | // get all nouns with this property 57 | let matches = []; 58 | for (let noun of this.narrative.nouns) { 59 | if (noun.properties[prop] == val) { 60 | matches.push(noun); 61 | } 62 | } 63 | return matches; 64 | } 65 | relate(rel, a, b) { 66 | // relate a to b with relation rel 67 | this.relations.set(mk([rel, a.name, b.name]), true) 68 | } 69 | unrelate(rel, a, b) { 70 | // remove relation rel between a and b 71 | this.relations.delete(mk([rel, a.name, b.name])); 72 | } 73 | unrelateByTag(rel, a, bTag) { 74 | // remove relation rel between a and nouns tagged with bTag 75 | for (let noun of this.allRelatedByTag(rel, a, bTag)) { 76 | this.unrelate(rel, a, noun); 77 | } 78 | } 79 | reciprocal(rel, a, b) { 80 | // relate a to b reciprocally with relation rel 81 | this.relations.set(mk([rel, a.name, b.name]), true) 82 | this.relations.set(mk([rel, b.name, a.name]), true) 83 | } 84 | unreciprocal(rel, a, b) { 85 | // remove reciprocal relation rel between a and b 86 | this.relations.delete(mk([rel, a.name, b.name])) 87 | this.relations.delete(mk([rel, b.name, a.name])) 88 | } 89 | unreciprocalByTag(rel, a, bTag) { 90 | // remove reciprocal relation rel between a and nouns tagged with bTag 91 | for (let noun of this.allRelatedByTag(rel, a, bTag)) { 92 | this.unrelate(rel, a, noun); 93 | this.unrelate(rel, noun, a); 94 | } 95 | } 96 | isRelated(rel, a, b) { 97 | // return true if a and b are related with rel 98 | return this.relations.get(mk([rel, a.name, b.name])) 99 | } 100 | allRelatedByTag(rel, a, bTag) { 101 | // returns all nouns related to a by rel with tag bTag 102 | let matches = []; 103 | let byTag = this.getNounsByTag(bTag); 104 | for (let b of byTag) { 105 | if (this.isRelated(rel, a, b)) { 106 | matches.push(b); 107 | } 108 | } 109 | return matches; 110 | } 111 | relatedByTag(rel, a, bTag) { 112 | // returns only the first noun related to a by rel with tag bTag 113 | return this.allRelatedByTag(rel, a, bTag)[0]; 114 | } 115 | init() { 116 | // call the initialize function and add events to history 117 | let events = []; 118 | let boundInit = this.narrative.initialize.bind(this); 119 | for (let sEvent of boundInit()) { 120 | this.eventHistory.push(sEvent); 121 | events.push(sEvent); 122 | } 123 | return events; 124 | } 125 | step() { 126 | // step through the simulation 127 | // do nothing if story is over 128 | if (this.eventHistory.length > 0 && 129 | this.eventHistory[this.eventHistory.length-1].ending()) { 130 | return []; 131 | } 132 | // initialize on stepCount 0, if provided 133 | if (this.stepCount == 0 && this.narrative.hasOwnProperty('initialize')) { 134 | this.stepCount++; 135 | return this.init(); 136 | } 137 | 138 | let events = []; 139 | // for matches with two parameters 140 | for (let action of this.narrative.actions) { 141 | if (action.match.length == 2) { 142 | let matchingA = this.narrative.nouns.filter( 143 | function(item) { return filterTagMatch(action.match[0], item); }); 144 | let matchingB = this.narrative.nouns.filter( 145 | function(item) { return filterTagMatch(action.match[1], item); }); 146 | let boundWhen = action.when.bind(this); 147 | let boundAction = action.action.bind(this); 148 | for (let objA of matchingA) { 149 | for (let objB of matchingB) { 150 | if (objA == objB) { 151 | continue; 152 | } 153 | if (boundWhen(objA, objB)) { 154 | for (let sEvent of boundAction(objA, objB)) { 155 | this.eventHistory.push(sEvent); 156 | events.push(sEvent); 157 | } 158 | } 159 | } 160 | } 161 | } 162 | // for matches with one parameter 163 | else if (action.match.length == 1) { 164 | let matching = this.narrative.nouns.filter( 165 | function(item) { return filterTagMatch(action.match[0], item); }); 166 | let boundWhen = action.when.bind(this); 167 | let boundAction = action.action.bind(this); 168 | for (let obj of matching) { 169 | if (boundWhen(obj)) { 170 | for (let sEvent of boundAction(obj)) { 171 | this.eventHistory.push(sEvent); 172 | events.push(sEvent); 173 | } 174 | } 175 | } 176 | } 177 | } 178 | 179 | // hash the current state and store 180 | this.stateHistory.push(hash(this.narrative.nouns) + hash(this.relations)); 181 | 182 | this.stepCount++; 183 | 184 | // if the last two states are identical, or no events generated, the end 185 | let shLen = this.stateHistory.length; 186 | if ( 187 | (shLen >= 2 && this.stateHistory[shLen-1] == this.stateHistory[shLen-2]) || 188 | events.length == 0) { 189 | // _end is a special sentinel value to signal the end of the narration 190 | this.eventHistory.push(new StoryEvent("_end")); 191 | events.push(new StoryEvent("_end")); 192 | } 193 | 194 | return events; 195 | } 196 | renderEvent(ev) { 197 | // renders an event using the associated tracery rule 198 | let discourseCopy = JSON.parse( 199 | JSON.stringify(this.narrative.traceryDiscourse)); 200 | if (ev.a) { 201 | discourseCopy["nounA"] = ev.a.name; 202 | // copy properties as nounA_ 203 | for (let k in ev.a.properties) { 204 | if (ev.a.properties.hasOwnProperty(k)) { 205 | discourseCopy["nounA_"+k] = ev.a.properties[k]; 206 | } 207 | } 208 | } 209 | if (ev.b) { 210 | discourseCopy["nounB"] = ev.b.name; 211 | for (let k in ev.b.properties) { 212 | if (ev.b.properties.hasOwnProperty(k)) { 213 | discourseCopy["nounB_"+k] = ev.b.properties[k]; 214 | } 215 | } 216 | } 217 | let grammar = tracery.createGrammar(discourseCopy); 218 | grammar.addModifiers(tracery.baseEngModifiers); 219 | return grammar.flatten("#"+ev.verb+"#"); 220 | } 221 | stepAndRender() { 222 | // combines step() and renderEvent() 223 | let events = this.step(); 224 | let rendered = []; 225 | for (let ev of events) { 226 | rendered.push(this.renderEvent(ev)); 227 | } 228 | return rendered; 229 | } 230 | } 231 | 232 | class StoryEvent { 233 | constructor(verb, a, b) { 234 | this.verb = verb; 235 | this.arity = 0; 236 | if (a !== undefined) { 237 | this.a = a; 238 | this.arity++; 239 | } 240 | if (b !== undefined) { 241 | this.b = b; 242 | this.arity++; 243 | } 244 | } 245 | dump() { 246 | if (this.arity == 0) { 247 | return [this.verb]; 248 | } 249 | else if (this.arity == 1) { 250 | return [this.a.name, this.verb]; 251 | } 252 | else if (this.arity == 2) { 253 | return [this.a.name, this.verb, this.b.name]; 254 | } 255 | } 256 | ending() { 257 | return this.verb == '_end'; 258 | } 259 | } 260 | 261 | module.exports = {Narrative: Narrative, StoryEvent: StoryEvent}; 262 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sea Duck 2 | 3 | By [Allison Parrish](https://www.decontextualize.com/) 4 | 5 | Sea Duck is a minimally opinionated rough-draft JavaScript framework for 6 | producing narratives through simulation. All you have to do is define a list of 7 | nouns, a list of actions that happen to those nouns (thereby producing events), 8 | and a [Tracery](http://tracery.io) grammar to turn those events into text. 9 | 10 | Note: This library uses a number of ES6 features 11 | ([maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), 12 | [generator 13 | functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*)) 14 | and I haven't used a transpiler or anything to provide backwards compatibility. 15 | Internet Explorer probably won't work (but Edge probably will?). Sorry! 16 | 17 | I made this framework primarily as a toy for experimentation and teaching, 18 | because I couldn't find any similar frameworks (available in a more-or-less 19 | complete form to the general public) that make it easy to get started with 20 | making narratives from simulated events. The idea was to facilitate quick 21 | prototypes for projects along the lines of Darius Kazemi's [Teens Wander Around 22 | A House](http://tinysubversions.com/nanogenmo/novel-2.pdf) ([NaNoGenMo thread 23 | here](https://github.com/dariusk/NaNoGenMo/issues/2)) As a consequence, this 24 | framework doesn't contain a built-in world model, or a system for solving 25 | constraints or action planning (though you could implement any of those things 26 | yourself on top of the framework). 27 | 28 | Pull requests or suggestions for overall architecture are solicited. 29 | 30 | ## Installation 31 | 32 | You can npm install from this git repo and `require('seaduck')` in your code: 33 | 34 | npm install https://github.com/aparrish/seaduck --save 35 | 36 | Or you can [download the bundle](/build/seaduck-bundle.js) (click "Raw" and 37 | then `File > Save as...` or equivalent in your browser) and add it to your 38 | project with a ` 41 | 42 | ... which will create a global `seaduck` variable available to the rest of your 43 | JavaScript code. 44 | 45 | ## Examples 46 | 47 | You can find annotated examples in the `examples/` folder. If you've downloaded 48 | this repo, you can run them like so: 49 | 50 | node examples/01_basic.js 51 | 52 | The examples are also available in adapted form on the [p5.js web 53 | editor](https://editor.p5js.org/): 54 | 55 | * [Example 1: The Basics](https://editor.p5js.org/allison.parrish/sketches/ByC_daWcX) 56 | * [Example 2: With 57 | tags](https://editor.p5js.org/allison.parrish/sketches/S15I3TbcQ) 58 | * [Example 3: 59 | Relations](https://editor.p5js.org/allison.parrish/sketches/Hkrhppb9X) 60 | * [Example 4: Rooms and 61 | objects](https://editor.p5js.org/allison.parrish/sketches/BJflApbcm) 62 | 63 | ## Concepts and usage 64 | 65 | The purpose of a Sea Duck narrative is to generate sentences from events that 66 | transpire as nouns interact according to a set of rules. At each step of the 67 | simulation, the state of the nouns (properties and relations) are checked, and 68 | then potentially changed, generating narrative events in the process, which are 69 | rendered as text using Tracery grammars. A Sea Duck narrative consists of the 70 | following components: 71 | 72 | * Nouns 73 | * Actions 74 | * Relations 75 | * Events 76 | * Discourse 77 | 78 | Create Sea Duck narrative object like so: 79 | 80 | let n = new seaduck.Narrative({ 81 | "nouns": [ 82 | ... list of noun objects ... 83 | ], 84 | "actions": [ 85 | ... list of action definitions ... 86 | ], 87 | "initialize": function() { ...code to execute on startup... }, 88 | "traceryDiscourse": { 89 | ... tracery rules for each kind of event ... 90 | } 91 | }); 92 | 93 | After creation, you can step through the simulation using `.step()`, which 94 | returns a list of `StoryEvent` objects created during the simulation step; or 95 | you can call `.stepAndRender()`, which collects events from `.step()` and 96 | renders them using the associated Tracery grammar. 97 | 98 | ### Nouns and properties 99 | 100 | A `noun` is something that will participate in the narrative (a person, place, 101 | or thing). Here's an example noun: 102 | 103 | { 104 | "name": "Joe", 105 | "properties": { 106 | "happiness": 0, 107 | "hungry": true 108 | }, 109 | "tags": ["person"] 110 | } 111 | 112 | The `name` attribute identifies the noun and is also the string that will be 113 | used to render it in the Tracery output; the `properties` attribute is an 114 | object for storing values associated with this noun; and the `tags` attribute 115 | is a list of strings "tagging" this noun. (The tags are used to group objects 116 | into categories for the purpose of relations and action matching; see below.) 117 | Nouns must have *all* of these attributes defined. 118 | 119 | ### Actions 120 | 121 | An `action` is a series of checks to perform against nouns on each step, along 122 | with code for what to do if those checks pass. A typical action is structured 123 | like this: 124 | 125 | { 126 | "name": "eat", 127 | "match": ["#person", "#food"], 128 | "when": function(a, b) { 129 | return a.properties.hungry && !b.properties.eaten; 130 | }, 131 | "action": function*(a, b) { 132 | yield (new seaduck.StoryEvent("eat", a, b)); 133 | a.properties.hungry = false; 134 | b.properties.eaten = true; 135 | } 136 | } 137 | 138 | The value of the `when` attribute should be a function that takes one or two 139 | `noun`s as parameters and returns `true` or `false`. If this function returns 140 | `true`, then the action check succeeds, and the function associated with 141 | the `action` attribute will be executed. 142 | 143 | Inside the `when` and `action` functions, `this` is bound to the Sea Duck 144 | `Narrative` object itself, to make it easy to call any of its methods. (Read 145 | on for more information on the methods you can call.) 146 | 147 | The `name` attribute of an action is currently ignored (but might be used for 148 | something else in the future). 149 | 150 | ### Matching 151 | 152 | The `match` attribute of an `action` specifies which nouns or groups of nouns 153 | to will be given to the `when` and `action` functions. The list can contain 154 | either one or two items; if one item, then the `when` function will be called 155 | for each matching noun(s) it turn. If two items, then the `when` function will 156 | be called once for each pair of items from both matching lists, with the items 157 | from the corresponding lists being passed as the first and second parameters, 158 | respectively. If the string in the list starts with `#`, then all nouns 159 | matching the tag will be included; otherwise, only the noun whose name exactly 160 | matches will be included. 161 | 162 | For example, the following `match` list: 163 | 164 | "match": ["#person"] 165 | 166 | ... would cause the `when` function to be called once for every noun tagged 167 | "person," while this `match` list: 168 | 169 | "match": ["#dog", "#cat"] 170 | 171 | ... would cause the `when` function to be called for every possible pairing of 172 | nouns labelled `dog` and `cat`. Without the `#` sign, only the noun with 173 | exactly the matching name will be included, so: 174 | 175 | "match": ["Joe", "cupcake"] 176 | 177 | ... would cause the `when` function to be called only once (with the noun named 178 | `Joe` and the noun named `cupcake` as parameters). 179 | 180 | ### Yielding actions 181 | 182 | Note that *updating parameters or manipulating relations* does not itself 183 | create narrative events. It's up to you to decide what changes to story state 184 | "mean" in terms of the events that they create. 185 | 186 | To that end, the `action` function should be a [generator 187 | function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) 188 | that `yield`s `StoryEvent` objects. (Generator functions are used so that 189 | `actions` can potentially generate multiple events; you need to put a `*` after 190 | the word `function` in the function definition to make it a generator 191 | function.) The `StoryEvent` constructor takes at least one and up to three 192 | parameters, specifying the event's verb, the first noun, and the second noun. 193 | For example, the following `action` function would yield three different 194 | events: 195 | 196 | "action": function*(a, b) { 197 | yield new seaduck.StoryEvent("rain"); // just verb 198 | yield new seaduck.StoryEvent("sleep", a); // verb + subject 199 | yield new seaduck.StoryEvent("see", a, b); // verb + subject + object 200 | } 201 | 202 | ### Discourse (rendering events with Tracery) 203 | 204 | When "rendering" `StoryEvent`s as sentences, the name of the verb will be 205 | matched to a Tracery rule. When rendering, the rules `nounA` and `nounB` will 206 | be set to the corresponding nouns in the `StoryEvent`. For example, the 207 | `traceryDiscourse` section to render the events in the example above might look 208 | like: 209 | 210 | "traceryDiscourse": { 211 | "rain": ["It's raining."], 212 | "sleep": ["#nounA# falls asleep."], 213 | "see": ["#nounA# sees #nounB# out of the corner of their eye."] 214 | } 215 | 216 | The properties of each noun are also available as rules in the Tracery grammar 217 | with the names `nounA_` and `nounB_`, where `` is 218 | the name of the property. 219 | 220 | ### Relations 221 | 222 | In Sea Duck, a relation is a named boolean value that relates one noun to 223 | another. You can check for relations between nouns in a `when` function and 224 | create (or destroy) relations between nouns in an `action` function. For 225 | example, if you wanted to create an `in_love` relation between two nouns in an 226 | `action` function, you might write: 227 | 228 | this.relate("in_love", a, b); 229 | 230 | To check to see if two nouns are related to each other with a particular 231 | relationship, use `this.isRelated(...)`: 232 | 233 | if (this.isRelated("in_love", a, b)) { ... } 234 | 235 | The `this.reciprocal()` method creates a two-way relation (so that `a` is 236 | `in_love` with `b` and `b` is `in_love` with `a`): 237 | 238 | this.reciprocal("in_love", a, b); 239 | 240 | You can erase a relation using `this.unrelate()` or `this.unreciprocal()`: 241 | 242 | this.unrelate("in_love", a, b); // a is no longer in_love with b 243 | this.unreciprocal("in_love", a, b); // a & b are no longer in_love w/each other 244 | 245 | The function `this.allRelatedByTag(...)` function gives you a list of all 246 | objects with the given tag that a noun is related to with the named relation. 247 | For example, to find all nouns that `a` is `in_love` with tagged `person`: 248 | 249 | this.allRelatedByTag("in_love", a, "person") // array of related nouns 250 | 251 | If you're pretty sure that there's only one related noun by tag, you can use 252 | `this.relatedByTag(...)` to only get the first result: 253 | 254 | this.relatedByTag("in_love", a, "person") // one noun object 255 | 256 | ### Initializing 257 | 258 | The `initialize` attribute of the object passed to the `Narrative` constructor 259 | should point to a generator function, which will be called when the `.step()` 260 | method is called before any actions are checked. This is a great place to put 261 | any code to initialize relations between objects or generate introductory 262 | story events. For example, the following `initialize` function generates 263 | story events for each `hungry` noun: 264 | 265 | ``` 266 | "initialize": function*() { 267 | for (let noun of this.getNounsByProperty("hungry", true)) { 268 | yield (new seaduck.StoryEvent("isHungry", noun)); 269 | } 270 | } 271 | ``` 272 | 273 | ### Finding noun objects 274 | 275 | Two handy methods for looking up noun objects: 276 | 277 | * `this.getNounsByProperty(prop, val)` returns an array of noun objects for each 278 | noun whose property named by `prop` is equal to `val`; 279 | * `this.noun(name)` returns the noun with the given name. (This is handy in 280 | `initialize` or advanced uses of `action` functions where you don't 281 | have a reference to the object itself.) 282 | 283 | ### When narration ends 284 | 285 | Narration ends when either of the following two conditions obtain: 286 | 287 | * No events are generated by any action 288 | * The state of the narrative (i.e., noun properties and relations) did not 289 | change from one step to the next 290 | 291 | In both cases, Sea Duck inserts a special `StoryEvent` with the verb `_end` to 292 | indicate that the story is over. You can manually end a narration by yielding 293 | an event with this verb from an `action` function. Subsequent calls to 294 | `.step()` (and `.stepAndrender()`) will return empty arrays. 295 | 296 | A typical loop for stepping through the narration simulation might look like 297 | this: 298 | 299 | let n = new seaduck.Narrative(...your narration specification here...); 300 | let maxSteps = 100; // maximum number of steps to perform 301 | 302 | for (let i = 0; i < maxSteps; i++) { 303 | let storyEvents = n.stepAndRender(); 304 | if (storyEvents.length > 0) { 305 | for (let ev of storyEvents) { 306 | console.log(ev); 307 | } 308 | } 309 | else { 310 | break; 311 | } 312 | } 313 | 314 | ## License 315 | 316 | Copyright 2018 Allison Parrish 317 | 318 | Permission is hereby granted, free of charge, to any person obtaining a copy of 319 | this software and associated documentation files (the "Software"), to deal in 320 | the Software without restriction, including without limitation the rights to 321 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 322 | of the Software, and to permit persons to whom the Software is furnished to do 323 | so, subject to the following conditions: 324 | 325 | The above copyright notice and this permission notice shall be included in all 326 | copies or substantial portions of the Software. 327 | 328 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 329 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 330 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 331 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 332 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 333 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 334 | SOFTWARE. 335 | --------------------------------------------------------------------------------