rakonteto.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. #!/usr/bin/env python3
  2. from dataclasses import dataclass
  3. import re
  4. import sys
  5. from typing import Dict, List, Optional, Set, Tuple
  6. HEADER = re.compile(r'===([^=]+)===')
  7. DECL = re.compile(r'([A-Za-z0-9_]+) \(([^)]*)\)')
  8. LINK = re.compile(r'\+.*')
  9. def err(msg):
  10. print(f'\x1b[31m{msg}\x1b[39m')
  11. @dataclass
  12. class Variable:
  13. name: str
  14. current_value: str
  15. allowed_values: Set[str]
  16. @dataclass
  17. class Choice:
  18. action: str
  19. result: Dict[str, str]
  20. def apply(self, state: Dict[str, Variable]):
  21. for var, val in self.result.items():
  22. state[var].current_value = val
  23. @dataclass
  24. class Storylet:
  25. conditions: Optional[Dict[str, str]]
  26. text: List[str]
  27. choices: Optional[List[Choice]]
  28. def prompt(self) -> Optional[Choice]:
  29. for ln in ''.join(self.text).strip().split('\n'):
  30. print('|', ln)
  31. if not self.choices:
  32. return None
  33. for i, chc in enumerate(self.choices):
  34. print(f'\x1b[1m {i}: {chc.action}\x1b[0m')
  35. x = None
  36. total = len(self.choices)
  37. if total > 1:
  38. choice_text = f'0-{total-1}'
  39. else:
  40. choice_text = '0'
  41. while True:
  42. raw = input(f'Your choice ({choice_text}): ')
  43. try:
  44. x = int(raw)
  45. if x >= 0 and x < len(self.choices):
  46. break
  47. except ValueError:
  48. pass
  49. err(f'Invalid choice: {raw}. Choose a number {choice_text}')
  50. return self.choices[x]
  51. def for_state(self, state: Dict[str, Variable]) -> Tuple[int, 'Storylet']:
  52. if self.conditions is None:
  53. return 0, self
  54. matching = 0
  55. for var, desired in self.conditions.items():
  56. if state[var].current_value != desired:
  57. return 0, self
  58. matching += 1
  59. return matching, self
  60. def parse_state(st: str) -> Dict[str, str]:
  61. conditions = {}
  62. st = st.strip().lstrip('[').rstrip(']')
  63. for thing in st.split(','):
  64. key, value = thing.split(':')
  65. conditions[key.strip()] = value.strip()
  66. return conditions
  67. def parse_file(f):
  68. state = {}
  69. chunks = []
  70. curr_chunk = None
  71. for ln in f:
  72. if (match := HEADER.match(ln)):
  73. stuff = match.group(1).strip()
  74. if stuff == 'intro':
  75. conditions = None
  76. else:
  77. conditions = parse_state(stuff)
  78. curr_chunk = Storylet(
  79. conditions=conditions,
  80. text=[],
  81. choices=[],
  82. )
  83. chunks.append(curr_chunk)
  84. elif curr_chunk:
  85. if (match := LINK.match(ln)):
  86. if ln.strip() == '+ END':
  87. curr_chunk.choice = None
  88. else:
  89. choice, result = ln.strip('+').split('->')
  90. result = parse_state(result)
  91. choice = Choice(
  92. action=choice.strip(),
  93. result=result,
  94. )
  95. curr_chunk.choices.append(choice)
  96. else:
  97. curr_chunk.text.append(ln)
  98. elif (match := DECL.match(ln)):
  99. name = match.group(1)
  100. states = match.group(2).split()
  101. curr = [s.strip('+') for s in states if s.startswith('+')][0]
  102. allowed = {s.strip('+') for s in states}
  103. state[name] = Variable(
  104. name=name,
  105. current_value=curr,
  106. allowed_values=allowed,
  107. )
  108. return state, chunks
  109. def debug_state(state):
  110. line = ', '.join(f'{k}: {v.current_value}' for k, v in state.items())
  111. print(f'\x1b[2m current state: {line}\x1b[0m')
  112. def run_story(state, chunks):
  113. current_chunk = [ c for c in chunks if c.conditions is None ]
  114. if not current_chunk:
  115. raise("Cannot find initial storylet!")
  116. current_chunk = current_chunk[0]
  117. while True:
  118. choice = current_chunk.prompt()
  119. if choice is None: return
  120. choice.apply(state)
  121. # debug_state(state)
  122. ranked = sorted([c.for_state(state) for c in chunks], key=lambda x: x[0])
  123. current_chunk = ranked[-1][1]
  124. def main(path):
  125. with open(path) as f:
  126. state, chunks = parse_file(f)
  127. run_story(state, chunks)
  128. if __name__ == '__main__':
  129. main(sys.argv[1])