123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- #!/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])
|