Browse Source

Add a basic prototype story and version

Getty Ritter 1 year ago
commit
7a56160a6f
2 changed files with 233 additions and 0 deletions
  1. 77 0
      examples/mini.rakonteto
  2. 156 0
      prototype/rakonteto.py

+ 77 - 0
examples/mini.rakonteto

@@ -0,0 +1,77 @@
+red_pill (+lack have)
+blue_pill (+lack have)
+chickenshit (+no yes)
+
+=== intro ===
+
+Morpheus's face is impassive behind his mirror-like sunglasses. He
+nods at his hands, each of which has a shining oblong object: a
+pill. One is red, one is blue. Do you take the blue pill? Or do you
+take the red pill?
+
++ Take the red pill. -> [red_pill: have]
++ Take the blue pill. -> [blue_pill: have]
+
+=== [red_pill: have, blue_pill: lack] ===
+
+You have the red pill in hand. Morpheus has not yet pulled his hands
+away, and you eye the other pill in his palm.
+
++ Take the blue pill, too. -> [blue_pill: have]
++ See how deep the rabbit-hole goes. -> [chickenshit: yes]
+
+=== [red_pill: lack, blue_pill: have] ===
+
+Your fingers tremble as you grab the blue pill. Morpheus's hand
+lingers for a moment.
+
++ Snatch the red pill. -> [red_pill: have]
++ Wuss out and take the pill. -> [chickenshit: yes]
+
+=== [blue_pill: have, red_pill: lack, chickenshit: yes] ===
+
+You pop it into your mouth and the flavor hits you immediately.
+
+"This is just a blue Mike-and-Ike," you tell Morpheus.
+
+He nods impassively.
+
+"The blue ones are, like, the worst ones," you say.
+
+"No, Neo," he says, turning as he speaks. "The worst ones... are the
+yellow ones."
+
++ END
+
+=== [red_pill: have, blue_pill: lack, chickenshit: yes] ===
+
+You put the pill into your mouth and you begin to feel the world
+around you reform itself into pure information before dissolving
+violently into digital nothingness. After what could be seconds or
+what could be an eternity, you find yourself emerged in a pool of
+orange goo, covered in digital connectors. The pod is vacated, and the
+goo carries your body through pipes and futuristic tubing until you
+are found by a junky, floating vehicle. When the hatch opens, you are
+rescued by none other than Morpheus.
+
+It takes you a long time to stop shivering, but you finally are able
+to summon the ability to speak.
+
+"That technology... that goo..." you ask Morpheus. "Was that... is it
+a sex thing?"
+
+His piercing eyes turn to meet your gaze. "Yes," he says.
+
++ END
+
+=== [red_pill: have, blue_pill: have] ===
+
+You have both pills. In the moment before you down them, you see for
+the first time a hint of human emotion in Morpheus, a facial
+expression which even behind those impassive sunglasses conveys the
+universally appreciable emotion: "What the fuck, man?"
+
+You open palm slam both pills directly into your fuckin' gob. Let's
+go, man.
+
++ END

+ 156 - 0
prototype/rakonteto.py

@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+
+from dataclasses import dataclass
+import re
+import sys
+from typing import Dict, List, Optional, Set, Tuple
+
+HEADER = re.compile(r'===([^=]+)===')
+DECL = re.compile(r'([A-Za-z0-9_]+) \(([^)]*)\)')
+LINK = re.compile(r'\+.*')
+
+def err(msg):
+    print(f'\x1b[31m{msg}\x1b[39m')
+
+@dataclass
+class Variable:
+    name: str
+    current_value: str
+    allowed_values: Set[str]
+
+@dataclass
+class Choice:
+    action: str
+    result: Dict[str, str]
+
+    def apply(self, state: Dict[str, Variable]):
+        for var, val in self.result.items():
+            state[var].current_value = val
+
+@dataclass
+class Storylet:
+    conditions: Optional[Dict[str, str]]
+    text: List[str]
+    choices: Optional[List[Choice]]
+
+    def prompt(self) -> Optional[Choice]:
+        for ln in ''.join(self.text).strip().split('\n'):
+            print('|', ln)
+
+        if not self.choices:
+            return None
+
+        for i, chc in enumerate(self.choices):
+            print(f'\x1b[1m  {i}: {chc.action}\x1b[0m')
+
+        x = None
+        total = len(self.choices)
+        if total > 1:
+            choice_text = f'0-{total-1}'
+        else:
+            choice_text = '0'
+
+        while True:
+            raw = input(f'Your choice ({choice_text}): ')
+            try:
+                x = int(raw)
+                if x >= 0 and x < len(self.choices):
+                    break
+            except ValueError:
+                pass
+            err(f'Invalid choice: {raw}. Choose a number {choice_text}')
+
+        return self.choices[x]
+
+    def for_state(self, state: Dict[str, Variable]) -> Tuple[int, 'Storylet']:
+        if self.conditions is None:
+            return 0, self
+
+        matching = 0
+        for var, desired in self.conditions.items():
+            if state[var].current_value != desired:
+                return 0, self
+            matching += 1
+
+        return matching, self
+
+def parse_state(st: str) -> Dict[str, str]:
+    conditions = {}
+    st = st.strip().lstrip('[').rstrip(']')
+    for thing in st.split(','):
+        key, value = thing.split(':')
+        conditions[key.strip()] = value.strip()
+    return conditions
+
+def parse_file(f):
+    state = {}
+    chunks = []
+    curr_chunk = None
+
+    for ln in f:
+        if (match := HEADER.match(ln)):
+            stuff = match.group(1).strip()
+            if stuff == 'intro':
+                conditions = None
+            else:
+                conditions = parse_state(stuff)
+            curr_chunk = Storylet(
+                conditions=conditions,
+                text=[],
+                choices=[],
+            )
+            chunks.append(curr_chunk)
+
+        elif curr_chunk:
+            if (match := LINK.match(ln)):
+                if ln.strip() == '+ END':
+                    curr_chunk.choice = None
+                else:
+                    choice, result = ln.strip('+').split('->')
+                    result = parse_state(result)
+                    choice = Choice(
+                        action=choice.strip(),
+                        result=result,
+                    )
+                    curr_chunk.choices.append(choice)
+            else:
+                curr_chunk.text.append(ln)
+
+        elif (match := DECL.match(ln)):
+            name = match.group(1)
+            states = match.group(2).split()
+            curr = [s.strip('+') for s in states if s.startswith('+')][0]
+            allowed = {s.strip('+') for s in states}
+            state[name] = Variable(
+                name=name,
+                current_value=curr,
+                allowed_values=allowed,
+            )
+
+    return state, chunks
+
+def debug_state(state):
+    line = ', '.join(f'{k}: {v.current_value}' for k, v in state.items())
+    print(f'\x1b[2m    current state: {line}\x1b[0m')
+
+def run_story(state, chunks):
+    current_chunk = [ c for c in chunks if c.conditions is None ]
+    if not current_chunk:
+        raise("Cannot find initial storylet!")
+
+    current_chunk = current_chunk[0]
+    while True:
+        choice = current_chunk.prompt()
+        if choice is None: return
+        choice.apply(state)
+        # debug_state(state)
+        ranked = sorted([c.for_state(state) for c in chunks], key=lambda x: x[0])
+        current_chunk = ranked[-1][1]
+
+def main(path):
+    with open(path) as f:
+        state, chunks = parse_file(f)
+    run_story(state, chunks)
+
+if __name__ == '__main__':
+    main(sys.argv[1])