J’accuse
You are the world-famous detective Mathilde Camus. Zophie the cat has gone missing, and you must sift through the clues. Suspects either always tell lies or always tell the truth. Will you find Zophie the cat in time and accuse the guilty party?
In this game, you take a taxi to different locations around the city. At each location is a suspect and an item. You can ask suspects about other suspects and items, compare their answers with your own exploration notes, and determine if they are lying or telling the truth. Some will know who has catnapped Zophie (or where she is, or what item is found at the location of the kidnapper), but you must determine if you can believe them. You have five minutes to find the criminal but will lose if you make three wrong accusations. This game is inspired by Homestar Runner’s “Where’s an Egg?” game.
To fully understand this program, you should pay close attention to the clues dictionary, which is set up on lines 51 to 109. You can uncomment lines 151 to 154 to display it on the screen. This dictionary has strings from the SUSPECTS list for the keys and “clue dictionaries” for the values. Each of these clue dictionaries contains strings from SUSPECTS and ITEMS. The original suspect will answer with these strings when asked about another suspect or item. For example, if clues[‘DUKE HAUTDOG’][‘CANDLESTICK’] is set to ‘DUCK POND’, then when the player asks Duke Hautdog about the Candlestick, they’ll say it is at the Duck Pond. The suspects, items, locations, and culprit get shuffled each time the game is played.
The code for this program revolves around this data structure, so understanding it is necessary to unlocking your understanding of the rest of the program.
jaccuse.py
1"""J'ACCUSE!, by Al Sweigart al@inventwithpython.com
2A mystery game of intrigue and a missing cat.
3This code is available at https://nostarch.com/big-book-small-python-programming
4Tags: extra-large, game, humor, puzzle"""
5
6# Play the original Flash game at:
7# https://homestarrunner.com/videlectrix/wheresanegg.html
8# More info at: http://www.hrwiki.org/wiki/Where's_an_Egg%3F
9
10import time, random, sys
11
12# Set up the constants:
13SUSPECTS = ['DUKE HAUTDOG', 'MAXIMUM POWERS', 'BILL MONOPOLIS', 'SENATOR SCHMEAR', 'MRS. FEATHERTOSS', 'DR. JEAN SPLICER', 'RAFFLES THE CLOWN', 'ESPRESSA TOFFEEPOT', 'CECIL EDGAR VANDERTON']
14ITEMS = ['FLASHLIGHT', 'CANDLESTICK', 'RAINBOW FLAG', 'HAMSTER WHEEL', 'ANIME VHS TAPE', 'JAR OF PICKLES', 'ONE COWBOY BOOT', 'CLEAN UNDERPANTS', '5 DOLLAR GIFT CARD']
15PLACES = ['ZOO', 'OLD BARN', 'DUCK POND', 'CITY HALL', 'HIPSTER CAFE', 'BOWLING ALLEY', 'VIDEO GAME MUSEUM', 'UNIVERSITY LIBRARY', 'ALBINO ALLIGATOR PIT']
16TIME_TO_SOLVE = 300 # 300 seconds (5 minutes) to solve the game.
17
18# First letters and longest length of places are needed for menu display:
19PLACE_FIRST_LETTERS = {}
20LONGEST_PLACE_NAME_LENGTH = 0
21for place in PLACES:
22 PLACE_FIRST_LETTERS[place[0]] = place
23 if len(place) > LONGEST_PLACE_NAME_LENGTH:
24 LONGEST_PLACE_NAME_LENGTH = len(place)
25
26# Basic sanity checks of the constants:
27assert len(SUSPECTS) == 9
28assert len(ITEMS) == 9
29assert len(PLACES) == 9
30# First letters must be unique:
31assert len(PLACE_FIRST_LETTERS.keys()) == len(PLACES)
32
33
34knownSuspectsAndItems = []
35# visitedPlaces: Keys=places, values=strings of the suspect & item there.
36visitedPlaces = {}
37currentLocation = 'TAXI' # Start the game at the taxi.
38accusedSuspects = [] # Accused suspects won't offer clues.
39liars = random.sample(SUSPECTS, random.randint(3, 4))
40accusationsLeft = 3 # You can accuse up to 3 people.
41culprit = random.choice(SUSPECTS)
42
43# Common indexes link these; e.g. SUSPECTS[0] and ITEMS[0] are at PLACES[0].
44random.shuffle(SUSPECTS)
45random.shuffle(ITEMS)
46random.shuffle(PLACES)
47
48# Create data structures for clues the truth-tellers give about each
49# item and suspect.
50# clues: Keys=suspects being asked for a clue, value="clue dictionary".
51clues = {}
52for i, interviewee in enumerate(SUSPECTS):
53 if interviewee in liars:
54 continue # Skip the liars for now.
55
56 # This "clue dictionary" has keys=items & suspects,
57 # value=the clue given.
58 clues[interviewee] = {}
59 clues[interviewee]['debug_liar'] = False # Useful for debugging.
60 for item in ITEMS: # Select clue about each item.
61 if random.randint(0, 1) == 0: # Tells where the item is:
62 clues[interviewee][item] = PLACES[ITEMS.index(item)]
63 else: # Tells who has the item:
64 clues[interviewee][item] = SUSPECTS[ITEMS.index(item)]
65 for suspect in SUSPECTS: # Select clue about each suspect.
66 if random.randint(0, 1) == 0: # Tells where the suspect is:
67 clues[interviewee][suspect] = PLACES[SUSPECTS.index(suspect)]
68 else: # Tells what item the suspect has:
69 clues[interviewee][suspect] = ITEMS[SUSPECTS.index(suspect)]
70
71# Create data structures for clues the liars give about each item
72# and suspect:
73for i, interviewee in enumerate(SUSPECTS):
74 if interviewee not in liars:
75 continue # We've already handled the truth-tellers.
76
77 # This "clue dictionary" has keys=items & suspects,
78 # value=the clue given:
79 clues[interviewee] = {}
80 clues[interviewee]['debug_liar'] = True # Useful for debugging.
81
82 # This interviewee is a liar and gives wrong clues:
83 for item in ITEMS:
84 if random.randint(0, 1) == 0:
85 while True: # Select a random (wrong) place clue.
86 # Lies about where the item is.
87 clues[interviewee][item] = random.choice(PLACES)
88 if clues[interviewee][item] != PLACES[ITEMS.index(item)]:
89 # Break out of the loop when wrong clue is selected.
90 break
91 else:
92 while True: # Select a random (wrong) suspect clue.
93 clues[interviewee][item] = random.choice(SUSPECTS)
94 if clues[interviewee][item] != SUSPECTS[ITEMS.index(item)]:
95 # Break out of the loop when wrong clue is selected.
96 break
97 for suspect in SUSPECTS:
98 if random.randint(0, 1) == 0:
99 while True: # Select a random (wrong) place clue.
100 clues[interviewee][suspect] = random.choice(PLACES)
101 if clues[interviewee][suspect] != PLACES[ITEMS.index(item)]:
102 # Break out of the loop when wrong clue is selected.
103 break
104 else:
105 while True: # Select a random (wrong) item clue.
106 clues[interviewee][suspect] = random.choice(ITEMS)
107 if clues[interviewee][suspect] != ITEMS[SUSPECTS.index(suspect)]:
108 # Break out of the loop when wrong clue is selected.
109 break
110
111# Create the data structures for clues given when asked about Zophie:
112zophieClues = {}
113for interviewee in random.sample(SUSPECTS, random.randint(3, 4)):
114 kindOfClue = random.randint(1, 3)
115 if kindOfClue == 1:
116 if interviewee not in liars:
117 # They tell you who has Zophie.
118 zophieClues[interviewee] = culprit
119 elif interviewee in liars:
120 while True:
121 # Select a (wrong) suspect clue.
122 zophieClues[interviewee] = random.choice(SUSPECTS)
123 if zophieClues[interviewee] != culprit:
124 # Break out of the loop when wrong clue is selected.
125 break
126
127 elif kindOfClue == 2:
128 if interviewee not in liars:
129 # They tell you where Zophie is.
130 zophieClues[interviewee] = PLACES[SUSPECTS.index(culprit)]
131 elif interviewee in liars:
132 while True:
133 # Select a (wrong) place clue.
134 zophieClues[interviewee] = random.choice(PLACES)
135 if zophieClues[interviewee] != PLACES[SUSPECTS.index(culprit)]:
136 # Break out of the loop when wrong clue is selected.
137 break
138 elif kindOfClue == 3:
139 if interviewee not in liars:
140 # They tell you what item Zophie is near.
141 zophieClues[interviewee] = ITEMS[SUSPECTS.index(culprit)]
142 elif interviewee in liars:
143 while True:
144 # Select a (wrong) item clue.
145 zophieClues[interviewee] = random.choice(ITEMS)
146 if zophieClues[interviewee] != ITEMS[SUSPECTS.index(culprit)]:
147 # Break out of the loop when wrong clue is selected.
148 break
149
150# EXPERIMENT: Uncomment this code to view the clue data structures:
151#import pprint
152#pprint.pprint(clues)
153#pprint.pprint(zophieClues)
154#print('culprit =', culprit)
155
156# START OF THE GAME
157print("""J'ACCUSE! (a mystery game)")
158By Al Sweigart al@inventwithpython.com
159Inspired by Homestar Runner\'s "Where\'s an Egg?" game
160
161You are the world-famous detective, Mathilde Camus.
162ZOPHIE THE CAT has gone missing, and you must sift through the clues.
163Suspects either always tell lies, or always tell the truth. Ask them
164about other people, places, and items to see if the details they give are
165truthful and consistent with your observations. Then you will know if
166their clue about ZOPHIE THE CAT is true or not. Will you find ZOPHIE THE
167CAT in time and accuse the guilty party?
168""")
169input('Press Enter to begin...')
170
171
172startTime = time.time()
173endTime = startTime + TIME_TO_SOLVE
174
175while True: # Main game loop.
176 if time.time() > endTime or accusationsLeft == 0:
177 # Handle "game over" condition:
178 if time.time() > endTime:
179 print('You have run out of time!')
180 elif accusationsLeft == 0:
181 print('You have accused too many innocent people!')
182 culpritIndex = SUSPECTS.index(culprit)
183 print('It was {} at the {} with the {} who catnapped her!'.format(culprit, PLACES[culpritIndex], ITEMS[culpritIndex]))
184 print('Better luck next time, Detective.')
185 sys.exit()
186
187 print()
188 minutesLeft = int(endTime - time.time()) // 60
189 secondsLeft = int(endTime - time.time()) % 60
190 print('Time left: {} min, {} sec'.format(minutesLeft, secondsLeft))
191
192 if currentLocation == 'TAXI':
193 print(' You are in your TAXI. Where do you want to go?')
194 for place in sorted(PLACES):
195 placeInfo = ''
196 if place in visitedPlaces:
197 placeInfo = visitedPlaces[place]
198 nameLabel = '(' + place[0] + ')' + place[1:]
199 spacing = " " * (LONGEST_PLACE_NAME_LENGTH - len(place))
200 print('{} {}{}'.format(nameLabel, spacing, placeInfo))
201 print('(Q)UIT GAME')
202 while True: # Keep asking until a valid response is given.
203 response = input('> ').upper()
204 if response == '':
205 continue # Ask again.
206 if response == 'Q':
207 print('Thanks for playing!')
208 sys.exit()
209 if response in PLACE_FIRST_LETTERS.keys():
210 break
211 currentLocation = PLACE_FIRST_LETTERS[response]
212 continue # Go back to the start of the main game loop.
213
214 # At a place; player can ask for clues.
215 print(' You are at the {}.'.format(currentLocation))
216 currentLocationIndex = PLACES.index(currentLocation)
217 thePersonHere = SUSPECTS[currentLocationIndex]
218 theItemHere = ITEMS[currentLocationIndex]
219 print(' {} with the {} is here.'.format(thePersonHere, theItemHere))
220
221 # Add the suspect and item at this place to our list of known
222 # suspects and items:
223 if thePersonHere not in knownSuspectsAndItems:
224 knownSuspectsAndItems.append(thePersonHere)
225 if ITEMS[currentLocationIndex] not in knownSuspectsAndItems:
226 knownSuspectsAndItems.append(ITEMS[currentLocationIndex])
227 if currentLocation not in visitedPlaces.keys():
228 visitedPlaces[currentLocation] = '({}, {})'.format(thePersonHere.lower(), theItemHere.lower())
229
230 # If the player has accused this person wrongly before, they
231 # won't give clues:
232 if thePersonHere in accusedSuspects:
233 print('They are offended that you accused them,')
234 print('and will not help with your investigation.')
235 print('You go back to your TAXI.')
236 print()
237 input('Press Enter to continue...')
238 currentLocation = 'TAXI'
239 continue # Go back to the start of the main game loop.
240
241 # Display menu of known suspects & items to ask about:
242 print()
243 print('(J) "J\'ACCUSE!" ({} accusations left)'.format(accusationsLeft))
244 print('(Z) Ask if they know where ZOPHIE THE CAT is.')
245 print('(T) Go back to the TAXI.')
246 for i, suspectOrItem in enumerate(knownSuspectsAndItems):
247 print('({}) Ask about {}'.format(i + 1, suspectOrItem))
248
249 while True: # Keep asking until a valid response is given.
250 response = input('> ').upper()
251 if response in 'JZT' or (response.isdecimal() and 0 < int(response) <= len(knownSuspectsAndItems)):
252 break
253
254 if response == 'J': # Player accuses this suspect.
255 accusationsLeft -= 1 # Use up an accusation.
256 if thePersonHere == culprit:
257 # You've accused the correct suspect.
258 print('You\'ve cracked the case, Detective!')
259 print('It was {} who had catnapped ZOPHIE THE CAT.'.format(culprit))
260 minutesTaken = int(time.time() - startTime) // 60
261 secondsTaken = int(time.time() - startTime) % 60
262 print('Good job! You solved it in {} min, {} sec.'.format(minutesTaken, secondsTaken))
263 sys.exit()
264 else:
265 # You've accused the wrong suspect.
266 accusedSuspects.append(thePersonHere)
267 print('You have accused the wrong person, Detective!')
268 print('They will not help you with anymore clues.')
269 print('You go back to your TAXI.')
270 currentLocation = 'TAXI'
271
272 elif response == 'Z': # Player asks about Zophie.
273 if thePersonHere not in zophieClues:
274 print('"I don\'t know anything about ZOPHIE THE CAT."')
275 elif thePersonHere in zophieClues:
276 print(' They give you this clue: "{}"'.format(zophieClues[thePersonHere]))
277 # Add non-place clues to the list of known things:
278 if zophieClues[thePersonHere] not in knownSuspectsAndItems and zophieClues[thePersonHere] not in PLACES:
279 knownSuspectsAndItems.append(zophieClues[thePersonHere])
280
281 elif response == 'T': # Player goes back to the taxi.
282 currentLocation = 'TAXI'
283 continue # Go back to the start of the main game loop.
284
285 else: # Player asks about a suspect or item.
286 thingBeingAskedAbout = knownSuspectsAndItems[int(response) - 1]
287 if thingBeingAskedAbout in (thePersonHere, theItemHere):
288 print(' They give you this clue: "No comment."')
289 else:
290 print(' They give you this clue: "{}"'.format(clues[thePersonHere][thingBeingAskedAbout]))
291 # Add non-place clues to the list of known things:
292 if clues[thePersonHere][thingBeingAskedAbout] not in knownSuspectsAndItems and clues[thePersonHere][thingBeingAskedAbout] not in PLACES:
293 knownSuspectsAndItems.append(clues[thePersonHere][thingBeingAskedAbout])
294
295 input('Press Enter to continue...')