17eb1f1deccfea2f743e51cf5436301c200834f8
[sicpelago] / game.py
1 if __name__ == '__main__':
2     __path__ = ["."]
3
4 from .scheme import *
5 from .ucb import main
6
7 from collections.abc import Iterable, Iterator
8 from typing import Any, Optional, Union
9
10 import itertools
11 import os
12 import random
13 import shutil
14 import textwrap
15 import yaml # PyYAML
16
17 SchemeValue = Any # TODO
18 Symbol = str # TODO
19
20 class Locked(SpecialForm):
21     def __init__(self, name: Symbol):
22         def no_access(_vals, _env):
23             raise SchemeError(f"You don't have access to '{name}' yet!")
24         super().__init__(name, no_access)
25
26     def __str__(self):
27         return '#[locked ' + self.name + ']'
28
29 @primitive('#unlock')
30 def unlock(name: SchemeValue, quiet: bool = False):
31     check_type(name, scheme_symbolp, 0, '#unlock')
32     if isinstance(TheLockingFrame.bindings.get(name, None), Locked):
33         del TheLockingFrame.bindings[name]
34         return name
35     if not quiet:
36         raise SchemeError(f"'{name}' is not locked")
37
38 def do_undef(vals: SchemeList, env: Frame):
39     check_form(vals, 1, 1)
40     name = vals[0]
41     if not scheme_symbolp(name):
42         raise SchemeError("can only undefine symbols")
43     if name in env.bindings:
44         del env.bindings[name]
45         return okay
46     raise SchemeError(f"'{name}' not found in current frame (can't undef from parents)")
47
48 text_wrapper = textwrap.TextWrapper()
49
50 @primitive("#show")
51 def show_problem(which: SchemeValue):
52     check_type(which, scheme_stringp, 0, '#solve')
53     which = eval(which)
54     problem = Problems.get(which, None)
55     if not problem:
56         raise SchemeError(f"{which} is not a valid problem number")
57     print()
58     print(f"# {problem}")
59     print()
60     text_wrapper.width = shutil.get_terminal_size().columns
61     for paragraph in problem.prompt().splitlines():
62         if not paragraph.startswith("  "):
63             paragraph = text_wrapper.fill(paragraph)
64         print(paragraph)
65     print()
66
67 @primitive("#help")
68 def show_help():
69     print("Welcome to SICPelago!")
70     print()
71     print(f"Your goal is to solve problems and collect the {len(MacGuffins)} sacred relics.")
72     print("At the start of the game, you only have access to `lambda` and `quote`.")
73     print("Use the commands below to guide you on your way:")
74     print()
75     print("  (#help) - show this message")
76     print("  (#progress) - show how many relics you have collected")
77     print("  (#inventory) - list all abilities you have access to")
78     print("  (#list) - list all problems")
79     print('  (#show "1.1") - print the prompt for a specific problem')
80     print('  (#solve "1.1" (lambda (x) x)) - attempt to solve a problem')
81     print("\tAll problem solutions take the form of a single function.")
82     print("  (#unlock 'cons) - unlock an ability directly (i.e. cheat)")
83     print()
84     print("Good luck!")
85     print()
86
87 @primitive("#progress")
88 def show_progress():
89     print(f"Relics collected: {macguffinsCollected}/{len(MacGuffins)}")
90
91 @primitive("#list")
92 def show_all_problems():
93     for k in Problems:
94         bullet = "•" if ProblemRewards.get(k, None) else "✔︎"
95         print(f"{bullet} {Problems[k]}")
96
97 @primitive("#inventory")
98 def show_all_items():
99     unlocked = set(TheGlobalFrame.bindings.keys()) - TheLockingFrame.bindings.keys()
100     for name in sorted(unlocked):
101         if name.startswith('#'):
102             continue
103         print(name)
104
105 @primitive("#win")
106 def victory():
107     unlock("exit")
108     print()
109     print()
110     print(f"\tYOU COLLECTED THE {len(MacGuffins)} SACRED RELICS!")
111     print("\tYour time was: idk I didn't set this up yet")
112     print("\tThank you for playing SICPelago!")
113     print("\tYou may now (exit)")
114     print()
115     print()
116
117 def receive_reward(reward):
118     if scheme_symbolp(reward):
119         return unlock(reward, quiet=True)
120     if isinstance(reward, MacGuffin):
121         global macguffinsCollected
122         macguffinsCollected += 1
123         if macguffinsCollected == len(MacGuffins):
124             victory()
125         return reward
126
127 problem_solved_hook = lambda problem: False
128
129 @primitive('#solve')
130 def solve(which: SchemeValue, solution: SchemeValue):
131     check_type(which, scheme_stringp, 0, '#solve')
132     check_type(solution, scheme_procedurep, 1, '#solve')
133
134     which = eval(which)
135     if which not in ProblemRewards:
136         raise SchemeError(f"{which} is not a valid problem number")
137     rewards = ProblemRewards[which]
138     if not rewards:
139         raise SchemeError(f"already solved problem {which}")
140
141     if solution is TheGlobalFrame.bindings['#win']:
142         pass # For testing purposes
143     else:
144         test_env = TheGlobalFrame.make_call_frame(Pair("f", nil), Pair(solution, nil))
145         for (input, expected) in Problems[which].data['tests'].items():
146             expected = scheme_eval(read_line(expected), env=test_env)
147             actual = scheme_eval(read_line(input), env=test_env)
148             if expected != actual:
149                 raise SchemeError(f"incorrect output for {input}: {actual}")
150
151     ProblemRewards[which] = None
152     if problem_solved_hook(Problems[which]):
153         return
154
155     print('You unlocked:')
156     for reward in rewards:
157         print(f'- {reward}')
158         receive_reward(reward)
159
160
161 class MacGuffin:
162     def __init__(self, name: str):
163         self.name = name
164     def __str__(self):
165         return self.name
166     def __repr__(self):
167         return repr(self.name)
168
169 MacGuffins = [
170     'Icosahedron of Alyssa, Sage of Pairs',
171     'Torus of Louis, Sage of Symbols',
172     'Cube of Ben, Sage of Booleans',
173     'Octahedron of Lem, Sage of Numbers',
174     'Pyramid of Cy, Sage of Promises',
175     'Dodecahedron of Eva, Sage of Procedures',
176     'Sphere of the Lost Sage of Nil',
177 ]
178
179 macguffinsCollected = 0
180
181 class Problem:
182     def __init__(self, data: dict[str, Any]):
183         self.data = data
184
185     def __str__(self):
186         return self.data['name']
187
188     def label_for(data):
189         return data['name'].partition(' ')[0]
190
191     def prompt(self):
192         return self.data['prompt']
193
194 def shadow_all_forms_except(frame: Frame, *exceptions: Symbol):
195     for name in frame.global_frame().bindings:
196         if name not in exceptions and not name.startswith("#"):
197             frame.define(name, Locked(name))
198
199 def load_problems(problems: dict[str, Problem], yaml_path: str):
200     with open(yaml_path, 'r') as input:
201         for problem_data in yaml.safe_load(input):
202             problems[Problem.label_for(problem_data)] = Problem(problem_data)
203
204 def generate_problem_rewards_except(problems: Iterable[str], frame_of_locks: Frame, *exceptions: Symbol) -> dict[str, list[Union[Symbol, MacGuffin]]]:
205     rewards: dict[str, list[Union[Symbol, MacGuffin]]] = {}
206     problems = list(problems)
207     for problem in problems:
208         rewards[problem] = []
209
210     # Distribute MacGuffins across different problems, randomly.
211     positions: Iterator[str] = itertools.cycle(random.sample(problems, len(problems)))
212     for reward in MacGuffins:
213         rewards[next(positions)].append(MacGuffin(reward))
214
215     for name in frame_of_locks.bindings:
216         if name not in exceptions:
217             rewards[next(positions)].insert(0, name)
218
219     return rewards
220
221
222 TheGlobalFrame = create_global_frame()
223 TheLockingFrame = Frame(TheGlobalFrame)
224 ThePlayerFrame = Frame(TheLockingFrame)
225
226 Problems: dict[str, Problem] = {}
227 ProblemRewards: dict[str, Optional[list[Union[Symbol, MacGuffin]]]] = {}
228
229 def setup() -> None:
230     TheGlobalFrame.define('#undef', SpecialForm('#undef', do_undef))
231
232     game_dir = os.path.dirname(os.path.realpath(__file__))
233     prelude_path = os.path.join(game_dir, "game_prelude.scm")
234     scheme_load(prelude_path, True, TheGlobalFrame)
235
236     shadow_all_forms_except(TheLockingFrame, 'lambda', 'quote', 'display', 'print', 'newline', 'error')
237     load_problems(Problems, os.path.join(game_dir, 'locations.yml'))
238     global ProblemRewards
239     ProblemRewards = generate_problem_rewards_except(Problems.keys(), TheLockingFrame, 'eval', 'exit', 'load')
240
241 @main
242 def run_game(*argv: str):
243     if '--list-all-items' in argv:
244         for name in sorted(TheGlobalFrame.bindings):
245             print(name)
246
247     setup()
248
249     if '--dump-items' in argv:
250         for reward in itertools.chain.from_iterable(ProblemRewards.values()):
251             if isinstance(reward, MacGuffin):
252                 item_id = MacGuffins.index(reward.name) + 1
253             else:
254                 # The last six characters of our operations happen to be unique
255                 # and 48 bits is within the allowed ID range for Archipelago.
256                 item_id = int.from_bytes(reward.encode('utf-8')[-6:], byteorder='big')
257             print(f'- name: "{reward}"')
258             print(f'  id: {item_id:#x}')
259
260     if '--spoiler-log' in argv:
261         for name in ProblemRewards:
262             print(f'{name}: {ProblemRewards[name]}')
263
264     show_help()
265     read_eval_print_loop(buffer_input, ThePlayerFrame, startup=True,
266                          interactive=True)