├── .gitignore ├── README.md ├── constants.py ├── docs ├── decrypt.png ├── hardware.png ├── manual.css └── manual.html ├── gameplay.py ├── gamestate.py ├── ggo16.py ├── ggo16.spec ├── media ├── bezel.png ├── bezel_off.png ├── chip.png ├── cpu.png ├── fonts │ ├── Arrows.ttf │ ├── Gobotronic.otf │ ├── LCDMU___.TTF │ ├── METRO-DF.TTF │ ├── PigpenCipher.otf │ ├── Sansation_Regular.ttf │ ├── circlethings.ttf │ └── whitrabt.ttf ├── icon.ico ├── icon.png ├── levels.json ├── login │ ├── archery.png │ ├── baseball.png │ ├── basketball.png │ ├── beer.png │ ├── boats.png │ ├── books.png │ ├── cars.png │ ├── cats.png │ ├── computers.png │ ├── dogs.png │ ├── flowers.png │ ├── food.png │ ├── horses.png │ ├── music.png │ ├── planes.png │ ├── skateboarding.png │ ├── soccer.png │ ├── tennis.png │ └── wine.png ├── motherboard3.png ├── motherboard4.png └── resistor.png ├── menu ├── __init__.py ├── level.py ├── mainmenu.py ├── menu.py ├── pause.py └── splash.py ├── mouse.py ├── programs ├── __init__.py ├── decrypt.py ├── hardware.py ├── hexedit.py ├── imagepassword.py ├── minehunt.py ├── network.py ├── password.py └── program.py ├── resources.py ├── terminal.py ├── timer.py ├── tools ├── decrypt_image.py └── imagepassword_test.py └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | progress.json 2 | build 3 | dist 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Terminal 2 | 3 | An entry into the Github 2016 Game-Off game jam, The Terminal is a cooperative hacking 4 | game in the style of [Keep Talking and Nobody Explodes](http://keeptalkinggame.com). 5 | 6 | The aim of the game is to hack into a computer terminal by following the 7 | instructions in the hacking manual, before the system locks you out. 8 | 9 | Only one player can see and interact with the computer, but is not allowed to 10 | view the hacking manual. The other players can view the manual, but are not 11 | allowed to see the computer. The two groups must communicate with each other 12 | in order to gain access before timer runs out. 13 | 14 | ## Setup 15 | ### From the source (all platforms) 16 | * Install python3 and the pygame module. 17 | * Clone the repository: git clone https://github.com/juzley/theterminal 18 | * Change into the directory containing the repository. 19 | * Launch the game: python3 ggo16.py 20 | * The manual can be found in the docs dir of the repository (docs/manual.html), or at http://juzley.github.io/TheTerminal/manual.html 21 | 22 | ### Pre-built binary (windows only) 23 | * Download a pre-built archive from the [github releases page](https://github.com/Juzley/theterminal/releases). 24 | * Extract the files from the archive and launch theterminal.exe. 25 | * The manual can be found in the docs dir of the archive (docs/manual.html), or at http://juzley.github.io/TheTerminal/manual.html 26 | 27 | 28 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | """Various constants for use throughout the game.""" 2 | 3 | TEXT_COLOUR = (20, 200, 20) 4 | TEXT_COLOUR_RED = (200, 20, 20) 5 | TEXT_COLOUR_WHITE = (255, 255, 255) 6 | 7 | TERMINAL_FONT = 'media/fonts/whitrabt.ttf' 8 | TERMINAL_TEXT_SIZE = 16 9 | 10 | GAMENAME = "The Terminal" 11 | VERSION = 0.1 12 | VERSION_STRING = 'v{}'.format(VERSION) 13 | MANUAL_URL = 'https://juzley.github.io/game-off-2016/manual.html' 14 | -------------------------------------------------------------------------------- /docs/decrypt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/docs/decrypt.png -------------------------------------------------------------------------------- /docs/hardware.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/docs/hardware.png -------------------------------------------------------------------------------- /docs/manual.css: -------------------------------------------------------------------------------- 1 | 2 | * { 3 | font-family: courier; 4 | } 5 | 6 | body { 7 | background: #EEE; 8 | } 9 | 10 | img { 11 | display: block; 12 | margin: auto; 13 | } 14 | 15 | .command { 16 | font-style: italic; 17 | } 18 | 19 | table.alternate tr:nth-child(odd), ol.alternate li:nth-child(odd) { 20 | background: #EEE; 21 | } 22 | 23 | table.alternate th { 24 | background: #CCC; 25 | font-weight: bold; 26 | } 27 | 28 | table.alternate th, table.alternate td { 29 | text-align: left; 30 | border: 1px solid; 31 | padding-left: 5px; 32 | padding-right: 5px; 33 | } 34 | 35 | a:link { 36 | text-decoration: none; 37 | color: #444; 38 | } 39 | 40 | a:hover { 41 | font-weight: bold; 42 | color: #444; 43 | } 44 | 45 | a:visited { 46 | color: #444; 47 | } 48 | 49 | #manual { 50 | max-width: 20cm; 51 | margin: auto; 52 | } 53 | 54 | div.section { 55 | background: white; 56 | margin-top: 20px; 57 | margin-bottom: 20px; 58 | padding-left: 5px; 59 | padding-right: 5px; 60 | padding-bottom: 10px; 61 | } 62 | 63 | .section em { 64 | font-weight: bold; 65 | } 66 | 67 | .puzzle-section a { 68 | display: block; 69 | text-align: center; 70 | margin-top: 10px; 71 | } 72 | 73 | .puzzle-section h3 { 74 | text-align: center; 75 | } 76 | 77 | .puzzle-section p.intro { 78 | font-style: italic; 79 | } 80 | 81 | .puzzle-section table { 82 | border-collapse: collapse; 83 | max-width: 90%; 84 | margin: auto; 85 | } 86 | 87 | div.network-diagrams { 88 | text-align: center; 89 | } 90 | 91 | div.network-diagram { 92 | display: inline-block; 93 | margin-top: 5px; 94 | margin-bottom: 0px; 95 | min-width: 30%; 96 | background-color: #CCC; 97 | } 98 | 99 | .network-diagram span.ip { 100 | display: block; 101 | font-style: italic; 102 | } 103 | 104 | .network-diagram pre { 105 | background-color: #EEE; 106 | text-align: center; 107 | margin-top: 1px; 108 | } 109 | 110 | .network-diagram span.node { 111 | color: #AAA; 112 | } 113 | 114 | .network-diagram span.firewall { 115 | color: #C11; 116 | } 117 | 118 | .network-diagram span.src { 119 | color: #1C1; 120 | } 121 | 122 | .network-diagram span.dst { 123 | color: #11C; 124 | } 125 | 126 | .network-diagram span.gateway { 127 | color: #990; 128 | } 129 | 130 | div.minehunt-diagrams { 131 | text-align: center; 132 | } 133 | 134 | div.minehunt-diagram { 135 | display: inline-block; 136 | margin-top: 10px; 137 | min-width: 40%; 138 | } 139 | 140 | .minehunt-diagram td, td.minehunt-diagram { 141 | background-color: #DDD; 142 | border: 1px solid; 143 | margin: 1px; 144 | width: 20px; 145 | height: 20px; 146 | padding: 0; 147 | text-align: center; 148 | } 149 | 150 | .minehunt-diagram div.bomb { 151 | color: black; 152 | font-weight: bold; 153 | } 154 | 155 | .minehunt-diagram div.finish { 156 | color: red; 157 | font-weight: bold; 158 | } 159 | 160 | .minehunt-diagram span.count { 161 | display: block; 162 | font-style: italic; 163 | } 164 | 165 | .minehunt-diagram span.time { 166 | display: block; 167 | font-style: italic; 168 | } 169 | 170 | .minehunt-diagram span.time em { 171 | font-weight: bold; 172 | } 173 | 174 | #minehunt-key td { 175 | border: 1px solid; 176 | padding: 2px; 177 | } 178 | 179 | div.resistor { 180 | margin: auto; 181 | padding-top: 1px; 182 | padding-bottom: 1px; 183 | } 184 | 185 | .resistor div { 186 | width: 6px; 187 | height: 1em; 188 | margin-left: 1px; 189 | margin-right: 1px; 190 | display: inline-block; 191 | font-weight: bold; 192 | font-size: 2em; 193 | } 194 | 195 | .resistor div.red { 196 | color: red; 197 | } 198 | 199 | .resistor div.yellow { 200 | color: yellow; 201 | } 202 | 203 | .resistor div.green { 204 | color: green; 205 | } 206 | 207 | .resistor div.blue { 208 | color: blue; 209 | } 210 | 211 | .resistor div.black { 212 | color: black; 213 | } 214 | 215 | @media print { 216 | .puzzle-section { 217 | page-break-before: always; 218 | } 219 | 220 | table { 221 | page-break-inside: avoid; 222 | } 223 | 224 | img { 225 | page-break-inside: avoid; 226 | } 227 | } 228 | 229 | 230 | -------------------------------------------------------------------------------- /docs/manual.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | The Terminal - Hacking Manual 6 | 7 | 8 | 9 |
10 |
11 |

The Terminal - Hacking Manual

12 | 13 |

Overview

14 | 15 |

The terminal is a local multiplayer hacking game inspired by 16 | "Keep Talking and Nobody Explodes". 17 | The aim of the game is to hack into a computer terminal by following the 18 | instructions in the hacking manual, before the system locks you out.

19 | 20 |

Only one person can see and interact with the computer, but is not allowed 21 | to view the hacking manual. The other players can view the manual, but are not 22 | allowed to see the computer. The two groups must communicate with each other 23 | in order to gain access before timer runs out.

24 | 25 |

Game version

26 | 27 |

It is important that the versions of your game and this manual match. The 28 | game version is shown in the bottom right of the screen in the main menu. This 29 | manual is for version 0.1 (V0.1) of the game.

30 | 31 |

Getting started

32 | 33 |

When you first start the a game, you will be presented with a terminal, 34 | at which you can type commands. Different commands launch different programs 35 | which you will need to use to gain access to the system - these programs may 36 | be text-based, requiring keyboard input, or graphical, in which case you will 37 | use the mouse.

38 | 39 |

Ultimately, the login command is used to log in 40 | to the terminal, and will prompt you for a password or similar form of 41 | authentication. On some systems, you will find you cannot execute the login 42 | command until other forms of security have been disabled - entering 43 | login will give you an idea of what further steps 44 | are required.

45 | 46 |

The help command lists the available commands on 47 | each particular terminal - if you get stuck it is a good idea to try working 48 | your way through this list. See the next section in this manual for 49 | instructions for the different programs you will encounter.

50 | 51 |

If you wish to exit a program prematurely (i.e. without completing the 52 | objective of the program), you may do so by pressing ctrl and c on your 53 | keyboard - you can always run it again later.

54 | 55 |

Hacking

56 |

The sections that follow describe the various tasks you will have to 57 | master in order to be a successful hacker. The list below gives links to each 58 | task.

59 | 68 |
69 | 70 |
71 |

Guessing Passwords

72 |

We have recovered a shredded list of passwords from a nearby 73 | dumpster. Despite our best efforts to piece it back together, it is still hard 74 | to make out the passwords. Below are our best guesses for the passwords of each 75 | user - perhaps with a bit of extra work you can work which are correct?

76 | 77 |

You will have a number of attempts to log in before the password is reset. 78 | Using our cracked login program, you will be able to see how many characters 79 | were correct - the right letter in the right position - for each attempt. You 80 | should be able to work out the password using this information.

81 | 82 |

Inputting the incorrect password too many times will lead to a password 83 | reset, and you'll have to start again.

84 | 85 | 86 | 87 | 88 | 89 | 91 | 92 | 93 | 94 | 96 | 97 | 98 | 99 | 101 | 102 | 103 | 104 | 106 | 107 | 108 | 109 | 111 | 112 | 113 | 114 | 116 | 117 |
UserPossible Passwords
rootflask, great, force, gleam, brick, flute, blast, feast, flick, 90 | flank
ro0ttusks, blush, askew, train, asset, burns, tries, turns, basks, 95 | busks
rewtmaple, pearl, lapel, myths, cycle, apple, ladle, ample, maize, 100 | capel
00142331trice, racer, tours, glaze, trail, raise, slick, track, grace, 105 | trace
00143231court, truce, fords, flirt, cruel, craft, tours, chart, fours, 110 | count
01043231eagle, ariel, glare, gains, earns, gauge, angle, early, agile, 115 | engle
118 | Back to top 119 |
120 | 121 |
122 |

Guessing Visual Passwords

123 |

Some organisations use a visual password system. Users select 124 | images to identify themselves, and this selection is then used for 125 | authentication. Fortunately for us, people usually pick images based on the 126 | things that they like...

127 | 128 |

The visual login process will present you with four images - three from the 129 | user's selected images, and one which was not selected by the user. Use the 130 | table below to identify the user based on the images you are presented with, 131 | and select the three correct images

132 | 133 |

Selecting an incorrect image will result in a temporary lockout.

134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
UserLikes
User ACars, Cats, Dogs, Planes, Music, Flowers
User BSkateboarding, Horses, Music, Dogs, Food, Wine
User CBooks, Computers, Cats, Archery, Boats, Cars
User DSoccer, Basketball, Baseball, Archery, Skateboarding, Tennis
User EBooks, Wine, Planes, Computers, Music, Horses
User FSoccer, Tennis, Flowers, Cats, Boats, Archery
User GFood, Wine, Beer, Planes, Basketball, Baseball
User HCars, Beer, Soccer, Tennis, Food, Computers
170 | Back to top 171 |
172 | 173 |
174 |

Cracking Programs

175 |

You'll sometimes need to modify the code of programs on the 176 | computer you're attempting to gain access to in order to bypass security 177 | checks. Brush off your programming skills and open your hex editor...

178 | 179 |

A program is made up of a number of lines of code, where each line consists 180 | of 6 numbers. A hex editor will allow you to modify the lines of code 181 | one-by-one, replacing one value in the line with another value. Note 182 | that in some cases, you will need to remember the value that you are replacing 183 | for following lines.

184 | 185 |

Follow the rules below in order to determine the correct actions for each 186 | line. If you don't follow the instructions below carefully, the system will 187 | detect your attempts to modify the program, and revert it to its original 188 | state.

189 | 190 |
    191 |
  1. If this is the first line in the program go to (d), else go to (b)
  2. 192 |
  3. If this is the last line go to (j), else go to (c)
  4. 193 |
  5. If you made a change to the previous line, go to (g), else go to 194 | (d)
  6. 195 |
  7. If the line has one or more 9s, replace the last number in the line by 196 | 7, else go to (e)
  8. 197 |
  9. If the line contains one or more zeros, replace the first occurence 198 | with 9, else go to (f)
  10. 199 |
  11. If the line contains more than 3 odd numbers, reduce the first odd 200 | number by 1, else go to (k)
  12. 201 |
  13. If the line has more than one zeros, replace the first number in the 202 | line with the number that was replaced in the previous line, else go 203 | to (h)
  14. 204 |
  15. If the line contains more than one 9s, replace the first occurence with 205 | the number of the column in the last line which was edited, else go to 206 | (i)
  16. 207 |
  17. If the line contains one or more values which match the value inserted 208 | in the previous line, replace the first occurence with zero, else go 209 | to (k)
  18. 210 |
  19. Replace the final value with the number of lines (excluding this line) 211 | which have been modified.
  20. 212 |
  21. Do not edit the line.
  22. 213 |
214 | Back to top 215 |
216 | 217 |
218 |

Decrypting Filesystems

219 |

Some systems you encounter will have encrypted filesystems, 220 | which will need to be unencrypted before you can progress further with a hack. 221 | A disgruntled employee at one of the big security firms leaked some of the 222 | encryption keys, which should allow you to progress

223 | 224 |

Our decryption program will present you with a section of encrypted data. 225 | Use the table below to enter the correct plain-text data - decryption can then 226 | take place based on this information.

227 | 228 |

Entering the incorrect key will cause data corruption, and recovering from 229 | this will waste valuable time.

230 | 231 | decryption key 232 | Back to top 233 |
234 | 235 |
236 |

Disabling Hardware Security

237 |

Some of our target devices have hardware security support in 238 | the form of on-board expansion chips. These will prevent us from gaining access 239 | unless we can somehow disable them. Luckily we were able to 'obtain' a copy of 240 | the hardware specifications, which should help you bypass this protection.

241 | 242 |

Each system may have one or more security chip on its motherboard, with an 243 | associated pull-down resistor. In order to bypass the security system, you 244 | must remove the chip, its pull-down resistor, or both, based on the information 245 | in the below tables. Removing a chip or resistor is achieved by simply 246 | left-clicking on it.

247 | 248 | Hardware diagram 249 | 250 |

Once you have the correct hardware configuration, click the power button 251 | to the bottom left of the screen to restart the computer. Booting the system 252 | with an incorrect hardware configuration may affect the system clock, which may 253 | reduce the amount of time you have before you are logged out.

254 | 255 |

Table 1: 256 | This table shows the values of the different resistors, based on the coloured 257 | markings on the resistor.

258 | 259 | 260 | 261 | 262 | 270 | 271 | 272 | 273 | 281 | 282 | 283 | 284 | 292 | 293 | 294 | 295 | 303 | 304 | 305 | 306 | 314 | 315 | 316 | 317 | 325 | 326 | 327 |
MarkingsValue
263 |
264 |
-
265 |
-
266 |
-
267 |
-
268 |
269 |
10 kOhms
274 |
275 |
-
276 |
-
277 |
-
278 |
-
279 |
280 |
20 kOhms
285 |
286 |
-
287 |
-
288 |
-
289 |
-
290 |
291 |
30 kOhms
296 |
297 |
-
298 |
-
299 |
-
300 |
-
301 |
302 |
40 kOhms
307 |
308 |
-
309 |
-
310 |
-
311 |
-
312 |
313 |
50 kOhms
318 |
319 |
-
320 |
-
321 |
-
322 |
-
323 |
324 |
60 kOhms
328 | 329 |

Table 2: 330 | Use the last character of the security chip's identifier and the value of the 331 | chip's pull-down resistor to determine the correct action.

332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 347 | 349 |
Resistor ValueOdd numberEvent numberZeroLetter
10 kOhmsNCRB
20 kOhmsCCBR
30 kOhmsRRCC
40 kOhmsBNRC
50 kOhmsT3, row 1T3, row 4NT3, 346 | row 3
60 kOhmsT3, row 5CT3, row 2T3, 348 | row 6
350 | 351 |

Table 3: 352 | Use the row given by table 2, and the first character of the terminal name to 353 | determine the correct action.

354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 |
RowOdd numberEven numberZeroLetter
Row 1CNRC
Row 2RCRC
Row 3RBCR
Row 4CRCR
Row 5NCRB
Row 6CCCR
370 | 371 |

Key:

372 | 379 | Back to top 380 |
381 | 382 |
383 |

Network Management

384 |

Some systems obtain login information from a remote 385 | authentication server. The easiest way around this is to reconfigure the 386 | network so that they are connected to one of our servers instead!

387 | 388 |

The network manager allows you to make network connections between devices 389 | in a network. Each device is represented as a symbol in a grid. Your goal is 390 | to use the arrow keys to draw a path from the computer you are attempting to 391 | hack (the source, S) to our rogue authentication server (the destination, D). 392 | You cannot visit a node in the network grid more than once. 393 |

394 | 395 |

Some networks contain gateway switches (G). When connecting the source to 396 | the destination, you must make sure to go through all such switches on the way. 397 | Most networks also contain firewalls (x) - our server cannot communicate 398 | through these devices and so they must be avoided. 399 |

400 | 401 |

Attempts to configure the network such that it doesn't meet the above 402 | requirements will fail, and the network configuration will roll back to its 403 | previous state.

404 | 405 |

Below are some possible network configurations you will encounter. The 406 | source and destination devices are identified by IP addresses, which are 407 | included with the diagram.

408 | 409 |
410 |
411 | Src IP: 192.168.1.15 412 | Dst IP: 10.0.0.1 413 |
414 | S . . . x x
415 | x . . . x x
416 | x x G x x x
417 | . . . . G .
418 | G . . . . .
419 | x x x x x D
420 | 
421 |
422 | 423 |
424 | Src IP: 192.168.1.15 425 | Dst IP: 11.0.0.1 426 |
427 | S . . G x x
428 | . . . . x x
429 | . x . . x x
430 | . . x . G .
431 | G . . x . .
432 | x x . . . D
433 | 
434 |
435 | 436 |
437 | Src IP: 192.168.1.50 438 | Dst IP: 11.0.0.1 439 |
440 | S . . G x x
441 | . . . . . x
442 | . x . . G x
443 | . . x . x .
444 | G . . . . .
445 | x x . . . D
446 | 
447 |
448 | 449 |
450 | Src IP: 192.168.1.100 451 | Dst IP: 12.0.0.1 452 |
453 | . x D x x .
454 | . x . . x .
455 | x x x . x x
456 | . . . . . .
457 | . x x x x .
458 | . . . S . G
459 | 
460 |
461 | 462 |
463 | Src IP: 192.168.1.100 464 | Dst IP: 12.0.0.2 465 |
466 | . x x D x .
467 | . x . . x .
468 | x x . x x x
469 | . . . . . .
470 | . x x x x .
471 | G . S . . .
472 | 
473 |
474 | 475 |
476 | Src IP: 10.10.10.11 477 | Dst IP: 10.10.10.7 478 |
479 | D . . . x x
480 | . x x . x x
481 | . x x G x x
482 | . . . . . x
483 | x . x x . x
484 | x G . . . S
485 | 
486 |
487 |
488 | Back to top 489 |
490 | 491 |
492 |

Minehunt

493 |

The minehunt game is a poorly written Minesweeper clone, 494 | installed on many of our target systems. The aim is to clear all the squares 495 | not containing mines, without uncovering a mine. Flags can be placed to mark 496 | known mine locations. Winning the game is not your aim here, however: analysis 497 | Minehunt's assembly code has revealed that it is possible to gain elavated 498 | permissions if certain actions are performed in the right order

499 | 500 |

To trigger the exploit in the Minehunt game, follow the below steps:

501 |
    502 |
  1. Use the mine count and size of board to determine which layout you 503 | have been presented with.
  2. 504 |
  3. Place flags (right click) on all but one of the mines, as indicated 505 | below.
  4. 506 |
  5. Detonate (left click) the final mine. With some board layouts, the time 507 | at which the final mine is detonated (whether the number of seconds in 508 | the counter is odd or even) is critical in triggering the exploit.
  6. 509 |
510 | 511 |

512 | Below are all possible board layouts you may encounter. 513 | Use the following key to determine which mines should be flagged, and which 514 | should be detonated: 515 |

516 | 517 | 518 | 519 | 520 | 521 | 522 |
o
The location of a mine that should be flagged
x
The location of the mine that should be detonated
523 | 524 |
525 |
526 | 8x10, 8 mines 527 | End when time is odd 528 | 529 | 530 | 531 | 532 | 533 | 534 | 535 | 536 | 537 |
. . . . . o . . . .
o . . . . . . . . .
. . . . . . . . . o
. . . o . . . . . .
. . . . . . . . . .
o x . . . . . o . .
. . . . . . . . . .
. . . . . . . . o .
538 |
539 | 540 |
541 | 8x10, 9 mines 542 | End when time is even 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 |
. . . . . o . . . .
o o . . . . . . . .
. . . . . . . . . .
. . . o . . . . . .
. . . . . . . . o .
o o . . . . . . x .
. . . . . . . . . .
. . . . . . . . . o
553 |
554 | 555 |
556 | 8x10, 10 mines 557 | No time restrictions 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 |
. o . . . o . . . .
o o . . . . . . . .
. . . . . . . . . .
. . . o . . . . . .
. . . . . . . . . .
o x . . . . . . o .
. . . . . . . . . .
. o . . o . . . . .
568 |
569 | 570 |
571 | 8x9, 7 mines 572 | End when time is even 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 |
o . . . . o . . .
. . . . . . . . .
. . . . . . . . .
. . . x . . . . .
. . . . . . . . .
o o . . . . . o .
. . . . . . . . .
. . . . . . . . o
583 |
584 | 585 |
586 | 8x9, 11 mines 587 | End when time is odd 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 |
o . . . . o . . .
o . . . . . . . .
. . . . . . . x o
. . . o . . . . .
. . . . . . . . .
o o . . . . . o .
. . . . . . . . .
. o . . . . . . o
598 |
599 | 600 |
601 | 8x9, 9 mines 602 | No time restrictions 603 | 604 | 605 | 606 | 607 | 608 | 609 | 610 | 611 | 612 |
o . . . . o . . .
. . . . . . . . .
. . . . . . . . .
. . . o . . . . .
. . . . . . . . .
o x . . . . . o .
o o . . . . . . .
. . . . . . . . o
613 |
614 |
615 | Back to top 616 |
617 |
618 | 619 | 651 | 652 | 653 | -------------------------------------------------------------------------------- /gameplay.py: -------------------------------------------------------------------------------- 1 | """Implementation of the core gameplay.""" 2 | 3 | import random 4 | 5 | import pygame 6 | 7 | import timer 8 | import util 9 | import menu 10 | import constants 11 | from gamestate import GameState 12 | from terminal import Terminal 13 | from resources import load_font 14 | 15 | 16 | class SuccessState(GameState): 17 | 18 | """Gamestate implementation for the success screen.""" 19 | 20 | _FONT = constants.TERMINAL_FONT 21 | _WAIT_TIME = 1000 22 | _MAIN_TEXT_HEIGHT = 50 23 | _CONTINUE_TEXT_HEIGHT = 20 24 | _SPACING = 10 25 | 26 | def __init__(self, mgr, terminal): 27 | """Initialize the class.""" 28 | self._mgr = mgr 29 | self._timer = timer.Timer() 30 | self._terminal = terminal 31 | 32 | font = load_font(SuccessState._FONT, 33 | SuccessState._MAIN_TEXT_HEIGHT) 34 | self._login_text = font.render('Access Granted', True, 35 | constants.TEXT_COLOUR) 36 | 37 | font = load_font(SuccessState._FONT, 38 | SuccessState._CONTINUE_TEXT_HEIGHT) 39 | self._continue_text = font.render('Press any key to continue', True, 40 | constants.TEXT_COLOUR) 41 | 42 | self._login_text_coords = util.center_align( 43 | self._login_text.get_rect().w, 44 | self._login_text.get_rect().h + self._continue_text.get_rect().h) 45 | coords = util.center_align(self._continue_text.get_rect().w, 0) 46 | self._continue_text_coords = (coords[0], 47 | self._login_text_coords[1] + 48 | self._login_text.get_rect().h + 49 | SuccessState._SPACING) 50 | 51 | def draw(self): 52 | """Draw the losing screen.""" 53 | self._terminal.draw_bezel() 54 | pygame.display.get_surface().blit(self._login_text, 55 | self._login_text_coords) 56 | 57 | if self._timer.time >= SuccessState._WAIT_TIME: 58 | pygame.display.get_surface().blit(self._continue_text, 59 | self._continue_text_coords) 60 | 61 | def run(self, events): 62 | """Run the win-game screen.""" 63 | self._timer.update() 64 | if self._timer.time >= SuccessState._WAIT_TIME: 65 | if len([e for e in events if e.type == pygame.KEYDOWN]) > 0: 66 | # Return to the main menu. 67 | self._mgr.pop_until(menu.MainMenu) 68 | 69 | 70 | class LostState(GameState): 71 | 72 | """Gamestate implementation for the defeat screen.""" 73 | 74 | _WAIT_TIME = 2000 75 | _FONT = constants.TERMINAL_FONT 76 | _MAIN_TEXT_HEIGHT = 50 77 | _CONTINUE_TEXT_HEIGHT = 20 78 | _SPACING = 10 79 | 80 | def __init__(self, mgr, terminal): 81 | """Initialize the class.""" 82 | self._mgr = mgr 83 | self._timer = timer.Timer() 84 | self._terminal = terminal 85 | 86 | font = load_font(LostState._FONT, 87 | LostState._MAIN_TEXT_HEIGHT) 88 | self._login_text = font.render('You have been locked out', True, 89 | constants.TEXT_COLOUR_RED) 90 | 91 | font = load_font(LostState._FONT, 92 | LostState._CONTINUE_TEXT_HEIGHT) 93 | self._continue_text = font.render('Press any key to continue', True, 94 | constants.TEXT_COLOUR_RED) 95 | 96 | self._login_text_coords = util.center_align( 97 | self._login_text.get_rect().w, 98 | self._login_text.get_rect().h + self._continue_text.get_rect().h) 99 | coords = util.center_align(self._continue_text.get_rect().w, 0) 100 | self._continue_text_coords = (coords[0], 101 | self._login_text_coords[1] + 102 | self._login_text.get_rect().h + 103 | LostState._SPACING) 104 | 105 | def draw(self): 106 | """Draw the losing screen.""" 107 | self._terminal.draw_bezel() 108 | pygame.display.get_surface().blit(self._login_text, 109 | self._login_text_coords) 110 | 111 | if self._timer.time >= LostState._WAIT_TIME: 112 | pygame.display.get_surface().blit(self._continue_text, 113 | self._continue_text_coords) 114 | 115 | def run(self, events): 116 | """Run the lost-game screen.""" 117 | self._timer.update() 118 | if self._timer.time >= LostState._WAIT_TIME: 119 | if len([e for e in events if e.type == pygame.KEYDOWN]) > 0: 120 | # Return to the main menu. 121 | self._mgr.pop_until(menu.MainMenu) 122 | 123 | 124 | class GameplayState(GameState): 125 | 126 | """Gamestate implementation for the core gameplay.""" 127 | 128 | def __init__(self, mgr, level_info): 129 | """Initialize the class.""" 130 | self._level_info = level_info 131 | 132 | # The level file specifies programs with groups, where each group 133 | # contains a list of possible programs, and the number of programs to 134 | # use from that group. Here we pick the programs that we're going to 135 | # use from each group - we end up with a data structure looking 136 | # something like the following: 137 | # groups = { 138 | # 'group_name_1': { 139 | # 'program_name_1': 'program_class_1', 140 | # 'program_name_2': 'program_class_2', 141 | # }, 142 | # 'group_name_2': { 143 | # 'program_name_3': 'program_class_3' 144 | # } 145 | # } 146 | groups = {} 147 | for group_name, group_info in level_info['program_groups'].items(): 148 | # Pick the programs we're going to use for this game. 149 | groups[group_name] = {name: cls for (name, cls) in 150 | random.sample(group_info['programs'], 151 | group_info['program_count'])} 152 | 153 | # Now that we've picked which programs to use, we can set up the 154 | # dependencies between programs. 155 | depends = {} 156 | for group_name, group_info in level_info['program_groups'].items(): 157 | group_programs = groups[group_name] 158 | 159 | if 'dependent_on' in group_info: 160 | program_list = [] 161 | for d in group_info['dependent_on']: 162 | dependent_group_programs = groups[d] 163 | program_list.extend(list(dependent_group_programs.keys())) 164 | 165 | for program in group_programs.keys(): 166 | depends[program] = program_list 167 | 168 | # Finally get the flatten the groups into a list of programs 169 | programs = {} 170 | for g in groups.values(): 171 | programs.update(g) 172 | 173 | self._terminal = Terminal( 174 | programs=programs, 175 | time=level_info['time'], 176 | depends=depends) 177 | self._mgr = mgr 178 | 179 | def run(self, events): 180 | """Run the game.""" 181 | for e in events: 182 | if e.type == pygame.KEYDOWN: 183 | if e.key == pygame.K_ESCAPE: 184 | self._terminal.paused = True 185 | self._mgr.push(menu.PauseMenu(self._mgr, 186 | self._terminal)) 187 | else: 188 | self._terminal.on_keypress(e.key, e.unicode) 189 | elif e.type == pygame.KEYUP: 190 | self._terminal.on_keyrelease() 191 | elif e.type == pygame.MOUSEBUTTONDOWN: 192 | self._terminal.on_mouseclick(e.button, e.pos) 193 | elif e.type == pygame.MOUSEMOTION: 194 | self._terminal.on_mousemove(e.pos) 195 | elif e.type == pygame.ACTIVEEVENT: 196 | self._terminal.on_active_event(util.ActiveEvent(e.state, 197 | e.gain)) 198 | 199 | if not self._terminal.paused: 200 | self._terminal.run() 201 | 202 | # The player is locked out, switch to the Lost gamestate. 203 | if self._terminal.locked: 204 | # Push so that we can restart the game if required by just popping 205 | # again. 206 | self._mgr.push(LostState(self._mgr, self._terminal)) 207 | 208 | # The player has succeeded, switch to the success gamestate. 209 | if self._terminal.completed(): 210 | # Don't need to return to the game, so replace this gamestate with 211 | # the success screen. 212 | menu.LevelMenu.completed_level(self._level_info['id']) 213 | self._mgr.replace(SuccessState(self._mgr, self._terminal)) 214 | 215 | def draw(self): 216 | """Draw the game.""" 217 | self._terminal.draw() 218 | -------------------------------------------------------------------------------- /gamestate.py: -------------------------------------------------------------------------------- 1 | """Module responsible for switching between different gamestates.""" 2 | 3 | 4 | class GameState: 5 | 6 | """Base class for gamestates.""" 7 | 8 | def run(self, events): 9 | """Run the gamestate.""" 10 | pass 11 | 12 | def draw(self): 13 | """Draw the gamestate.""" 14 | 15 | 16 | class GameStateManager: 17 | 18 | """Class to manage running and switching between gamestates.""" 19 | 20 | def __init__(self): 21 | """Initialize the class.""" 22 | # _states is a stack of gamestates, implemented as a list where the 23 | # end of the list is the top of the stack. The current gamestate, 24 | # therefore, is at the end of the list. 25 | self._states = [] 26 | 27 | def push(self, gamestate): 28 | """Push a new gamestate onto the stack.""" 29 | self._states.append(gamestate) 30 | 31 | def replace(self, gamestate): 32 | """Replace the current gamestate with a new gamestate.""" 33 | self.pop() 34 | self.push(gamestate) 35 | 36 | def pop(self): 37 | """Pop the current gamestate off the stack, and move to the next one.""" 38 | if self._states: 39 | self._states.pop() 40 | 41 | def pop_until(self, cls): 42 | """Pop until the current state is an instance of a given class.""" 43 | while self._states and not isinstance(self._states[-1], cls): 44 | self._states.pop() 45 | 46 | def run(self, events): 47 | """Run the current gamestate.""" 48 | if self._states: 49 | self._states[-1].run(events) 50 | 51 | def draw(self): 52 | """Draw the current gamestate.""" 53 | if self._states: 54 | self._states[-1].draw() 55 | 56 | def empty(self): 57 | """Indicate whether there are any active gamestates.""" 58 | return len(self._states) == 0 59 | -------------------------------------------------------------------------------- /ggo16.py: -------------------------------------------------------------------------------- 1 | """Entry point for the game.""" 2 | 3 | 4 | import pygame 5 | import random 6 | 7 | import constants 8 | import mouse 9 | from gamestate import GameStateManager 10 | from menu import SplashScreen 11 | from resources import load_image 12 | 13 | 14 | def setup(): 15 | """Perform initial setup.""" 16 | pygame.init() 17 | pygame.display.set_mode([800, 600], 18 | pygame.DOUBLEBUF | pygame.HWSURFACE, 19 | 24) 20 | pygame.display.set_icon(load_image("media/icon.png")) 21 | pygame.display.set_caption(constants.GAMENAME) 22 | mouse.current.set_cursor(mouse.Cursor.ARROW) 23 | random.seed() 24 | 25 | 26 | def run(): 27 | """Run the game loop.""" 28 | gamestates = GameStateManager() 29 | gamestates.push(SplashScreen(gamestates)) 30 | 31 | running = True 32 | while running: 33 | events = pygame.event.get() 34 | gamestates.run(events) 35 | 36 | if any(e.type == pygame.QUIT for e in events) or gamestates.empty(): 37 | # An empty GameStateManager indicates that the main menu was popped, 38 | # and we should exit. 39 | running = False 40 | else: 41 | screen = pygame.display.get_surface() 42 | screen.fill((0, 0, 0)) 43 | gamestates.draw() 44 | pygame.display.flip() 45 | 46 | 47 | if __name__ == '__main__': 48 | setup() 49 | run() 50 | -------------------------------------------------------------------------------- /ggo16.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | 3 | block_cipher = None 4 | 5 | 6 | a = Analysis(['ggo16.py'], 7 | pathex=['/home/jupriest/game-off-2016'], 8 | binaries=None, 9 | datas=[('media', 'media')], 10 | hiddenimports=[], 11 | hookspath=[], 12 | runtime_hooks=[], 13 | excludes=[], 14 | win_no_prefer_redirects=False, 15 | win_private_assemblies=False, 16 | cipher=block_cipher) 17 | pyz = PYZ(a.pure, a.zipped_data, 18 | cipher=block_cipher) 19 | exe = EXE(pyz, 20 | a.scripts, 21 | a.binaries, 22 | a.zipfiles, 23 | a.datas, 24 | name='theterminal', 25 | debug=False, 26 | strip=False, 27 | upx=True, 28 | console=False, 29 | icon='media\icon.ico') 30 | -------------------------------------------------------------------------------- /media/bezel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/bezel.png -------------------------------------------------------------------------------- /media/bezel_off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/bezel_off.png -------------------------------------------------------------------------------- /media/chip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/chip.png -------------------------------------------------------------------------------- /media/cpu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/cpu.png -------------------------------------------------------------------------------- /media/fonts/Arrows.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/Arrows.ttf -------------------------------------------------------------------------------- /media/fonts/Gobotronic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/Gobotronic.otf -------------------------------------------------------------------------------- /media/fonts/LCDMU___.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/LCDMU___.TTF -------------------------------------------------------------------------------- /media/fonts/METRO-DF.TTF: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/METRO-DF.TTF -------------------------------------------------------------------------------- /media/fonts/PigpenCipher.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/PigpenCipher.otf -------------------------------------------------------------------------------- /media/fonts/Sansation_Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/Sansation_Regular.ttf -------------------------------------------------------------------------------- /media/fonts/circlethings.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/circlethings.ttf -------------------------------------------------------------------------------- /media/fonts/whitrabt.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/fonts/whitrabt.ttf -------------------------------------------------------------------------------- /media/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/icon.ico -------------------------------------------------------------------------------- /media/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/icon.png -------------------------------------------------------------------------------- /media/levels.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 0, 4 | "name": "Level 1 - Learning To Hack", 5 | "cmd": "connect learning.ssh", 6 | "time": 180, 7 | "program_groups": { 8 | "login": { 9 | "program_count": 1, 10 | "dependent_on": ["others"], 11 | "programs": [ 12 | ["login", "PasswordGuess"] 13 | ] 14 | }, 15 | "others": { 16 | "program_count": 1, 17 | "programs": [ 18 | ["decrypt", "Decrypt"] 19 | ] 20 | } 21 | } 22 | }, 23 | { 24 | "id": 1, 25 | "name": "Level 2 - Cracking", 26 | "cmd": "connect cracking.ssh", 27 | "time": 180, 28 | "requires": [0], 29 | "program_groups": { 30 | "login": { 31 | "program_count": 1, 32 | "dependent_on": ["others"], 33 | "programs": [ 34 | ["login", "PasswordGuess"], 35 | ["login", "ImagePassword"] 36 | ] 37 | }, 38 | "others": { 39 | "program_count": 2, 40 | "programs": [ 41 | ["hexedit", "HexEditor"], 42 | ["decrypt", "Decrypt"] 43 | ] 44 | } 45 | } 46 | }, 47 | { 48 | "id": 2, 49 | "name": "Level 3 - Hardware Hacking", 50 | "cmd": "connect hardware.ssh", 51 | "time": 180, 52 | "requires": [1], 53 | "program_groups": { 54 | "login": { 55 | "program_count": 1, 56 | "dependent_on": ["hardware", "others"], 57 | "programs": [ 58 | ["login", "PasswordGuess"], 59 | ["login", "ImagePassword"] 60 | ] 61 | }, 62 | "hardware": { 63 | "program_count": 1, 64 | "programs": [ 65 | ["suspend", "HardwareInspect"] 66 | ] 67 | }, 68 | "others": { 69 | "program_count": 1, 70 | "programs": [ 71 | ["hexedit", "HexEditor"], 72 | ["decrypt", "Decrypt"] 73 | ] 74 | } 75 | } 76 | }, 77 | { 78 | "id": 3, 79 | "name": "Level 4 - Network Design", 80 | "cmd": "connect network.ssh", 81 | "time": 180, 82 | "requires": [1], 83 | "program_groups": { 84 | "login": { 85 | "program_count": 1, 86 | "dependent_on": ["network", "others"], 87 | "programs": [ 88 | ["login", "PasswordGuess"], 89 | ["login", "ImagePassword"] 90 | ] 91 | }, 92 | "network": { 93 | "program_count": 1, 94 | "programs": [ 95 | ["network", "NetworkManager"] 96 | ] 97 | }, 98 | "others": { 99 | "program_count": 1, 100 | "programs": [ 101 | ["hexedit", "HexEditor"], 102 | ["decrypt", "Decrypt"] 103 | ] 104 | } 105 | } 106 | }, 107 | { 108 | "id": 4, 109 | "name": "Level 5 - MineHunt!", 110 | "cmd": "connect minehunt.ssh", 111 | "time": 180, 112 | "requires": [1], 113 | "program_groups": { 114 | "login": { 115 | "program_count": 1, 116 | "dependent_on": ["minehunt", "others"], 117 | "programs": [ 118 | ["login", "PasswordGuess"], 119 | ["login", "ImagePassword"] 120 | ] 121 | }, 122 | "minehunt": { 123 | "program_count": 1, 124 | "programs": [ 125 | ["minehunt", "MineHunt"] 126 | ] 127 | }, 128 | "others": { 129 | "program_count": 1, 130 | "programs": [ 131 | ["hexedit", "HexEditor"], 132 | ["decrypt", "Decrypt"] 133 | ] 134 | } 135 | } 136 | }, 137 | { 138 | "id": 5, 139 | "name": "Level 6 - Ready For Anything", 140 | "cmd": "connect rdy.ssh", 141 | "time": 180, 142 | "requires": [2, 3, 4], 143 | "program_groups": { 144 | "login": { 145 | "program_count": 1, 146 | "dependent_on": ["others"], 147 | "programs": [ 148 | ["login", "PasswordGuess"], 149 | ["login", "ImagePassword"] 150 | ] 151 | }, 152 | "others": { 153 | "program_count": 2, 154 | "programs": [ 155 | ["minehunt", "MineHunt"], 156 | ["network", "NetworkManager"], 157 | ["suspend", "HardwareInspect"], 158 | ["hexedit", "HexEditor"], 159 | ["decrypt", "Decrypt"] 160 | ] 161 | } 162 | } 163 | }, 164 | { 165 | "id": 6, 166 | "name": "Level 7 - The Hacker's Marathon", 167 | "cmd": "connect mar4thon.ssh", 168 | "time": 300, 169 | "requires": [5], 170 | "program_groups": { 171 | "login": { 172 | "program_count": 1, 173 | "dependent_on": ["others"], 174 | "programs": [ 175 | ["login", "PasswordGuess"], 176 | ["login", "ImagePassword"] 177 | ] 178 | }, 179 | "others": { 180 | "program_count": 5, 181 | "programs": [ 182 | ["minehunt", "MineHunt"], 183 | ["network", "NetworkManager"], 184 | ["suspend", "HardwareInspect"], 185 | ["hexedit", "HexEditor"], 186 | ["decrypt", "Decrypt"] 187 | ] 188 | } 189 | } 190 | }, 191 | { 192 | "id": 7, 193 | "name": "Level 8 - Speedy Software", 194 | "cmd": "connect fastlockout.ssh", 195 | "time": 60, 196 | "requires": [6], 197 | "program_groups": { 198 | "login": { 199 | "program_count": 1, 200 | "dependent_on": ["others"], 201 | "programs": [ 202 | ["login", "PasswordGuess"], 203 | ["login", "ImagePassword"] 204 | ] 205 | }, 206 | "others": { 207 | "program_count": 1, 208 | "programs": [ 209 | ["minehunt", "MineHunt"], 210 | ["network", "NetworkManager"], 211 | ["hexedit", "HexEditor"], 212 | ["decrypt", "Decrypt"] 213 | ] 214 | } 215 | } 216 | } 217 | ] 218 | 219 | -------------------------------------------------------------------------------- /media/login/archery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/archery.png -------------------------------------------------------------------------------- /media/login/baseball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/baseball.png -------------------------------------------------------------------------------- /media/login/basketball.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/basketball.png -------------------------------------------------------------------------------- /media/login/beer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/beer.png -------------------------------------------------------------------------------- /media/login/boats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/boats.png -------------------------------------------------------------------------------- /media/login/books.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/books.png -------------------------------------------------------------------------------- /media/login/cars.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/cars.png -------------------------------------------------------------------------------- /media/login/cats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/cats.png -------------------------------------------------------------------------------- /media/login/computers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/computers.png -------------------------------------------------------------------------------- /media/login/dogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/dogs.png -------------------------------------------------------------------------------- /media/login/flowers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/flowers.png -------------------------------------------------------------------------------- /media/login/food.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/food.png -------------------------------------------------------------------------------- /media/login/horses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/horses.png -------------------------------------------------------------------------------- /media/login/music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/music.png -------------------------------------------------------------------------------- /media/login/planes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/planes.png -------------------------------------------------------------------------------- /media/login/skateboarding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/skateboarding.png -------------------------------------------------------------------------------- /media/login/soccer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/soccer.png -------------------------------------------------------------------------------- /media/login/tennis.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/tennis.png -------------------------------------------------------------------------------- /media/login/wine.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/login/wine.png -------------------------------------------------------------------------------- /media/motherboard3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/motherboard3.png -------------------------------------------------------------------------------- /media/motherboard4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/motherboard4.png -------------------------------------------------------------------------------- /media/resistor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Juzley/TheTerminal/363e3ecd9e50946e96ba172800dc2bf9ab73d4bb/media/resistor.png -------------------------------------------------------------------------------- /menu/__init__.py: -------------------------------------------------------------------------------- 1 | """Initialization for the menu package.""" 2 | 3 | # Bring any menus to be used from outside the menu package into the menu 4 | # namespace 5 | from menu.mainmenu import MainMenu 6 | from menu.pause import PauseMenu 7 | from menu.level import LevelMenu 8 | from menu.splash import SplashScreen 9 | -------------------------------------------------------------------------------- /menu/level.py: -------------------------------------------------------------------------------- 1 | """Level select menu.""" 2 | 3 | 4 | import json 5 | import programs 6 | from . import menu 7 | from enum import Enum, unique 8 | from gameplay import GameplayState 9 | from resources import make_path 10 | 11 | 12 | class LevelMenu(menu.CLIMenu): 13 | 14 | """The level select menu.""" 15 | 16 | @unique 17 | class Items(Enum): 18 | BACK = 1 19 | 20 | _LEVELS_FILE = 'media/levels.json' 21 | _PROGRESS_FILE = 'progress.json' 22 | 23 | def __init__(self, mgr): 24 | """Initialize the class.""" 25 | # Load levels from the level file. 26 | with open(make_path(LevelMenu._LEVELS_FILE)) as f: 27 | self._levels = json.load(f) 28 | 29 | # The program class names are represented in the JSON as strings, 30 | # we need to convert them to the corresponding class objects. 31 | for lvl in self._levels: 32 | for group in lvl['program_groups'].values(): 33 | for program_info in group['programs']: 34 | program_info[1] = getattr(programs, program_info[1]) 35 | 36 | # Load progress information. 37 | progress = LevelMenu._get_progress() 38 | completed = progress.get('completed', []) 39 | 40 | # Build the menu text 41 | buf = [ 42 | '$ cd levels', 43 | '$ ls', 44 | menu.CLIMenuItem(' ..', '$ cd ..', LevelMenu.Items.BACK) 45 | ] 46 | 47 | # Add each level as a menu item 48 | for idx, lvl in enumerate(self._levels): 49 | # Work out whether this level is accessible. 50 | disabled = len([r for r in lvl.get('requires', []) 51 | if r not in completed]) > 0 52 | text = ' [{}] {}'.format( 53 | 'x' if lvl['id'] in completed else ' ', 54 | lvl['name']) 55 | 56 | item = menu.CLIMenuItem(text=text, 57 | cmd='$ {}'.format(lvl['cmd']), 58 | item=idx, 59 | disabled=disabled) 60 | buf.append(item) 61 | 62 | # We want to start with the latest available level selected. 63 | enabled = [i for i in buf if 64 | isinstance(i, menu.CLIMenuItem) and not i.disabled] 65 | if enabled: 66 | enabled[-1].selected = True 67 | 68 | super().__init__(mgr, buf) 69 | 70 | @staticmethod 71 | def _get_progress(): 72 | """Load the current level progress from disk.""" 73 | try: 74 | with open(LevelMenu._PROGRESS_FILE, 'r') as f: 75 | return json.load(f) 76 | except (FileNotFoundError, ValueError): 77 | # The file may not be found if this is the first time the game is 78 | # played or the user hasn't completed any levels. Also ignore any 79 | # JSON parsing errors. 80 | return {} 81 | 82 | @staticmethod 83 | def completed_level(lvl_id): 84 | """Mark a level as completed.""" 85 | progress = LevelMenu._get_progress() 86 | completed = progress.get('completed', []) 87 | if lvl_id not in completed: 88 | completed.append(lvl_id) 89 | progress['completed'] = completed 90 | 91 | with open(LevelMenu._PROGRESS_FILE, 'w') as f: 92 | json.dump(progress, f) 93 | 94 | def _on_choose(self, item): 95 | if item == LevelMenu.Items.BACK: 96 | # Return to the main menu. 97 | self._mgr.pop() 98 | else: 99 | # If this isn't an item from enum of items, assume that the user 100 | # clicked on a level - in this case 'item' contains the index of 101 | # the level in the level list. 102 | self._mgr.replace(GameplayState(self._mgr, self._levels[item])) 103 | -------------------------------------------------------------------------------- /menu/mainmenu.py: -------------------------------------------------------------------------------- 1 | """Main Menu Implementation.""" 2 | 3 | 4 | import constants 5 | from .menu import CLIMenu, CLIMenuItem 6 | from .level import LevelMenu 7 | from enum import Enum, unique 8 | 9 | 10 | class MainMenu(CLIMenu): 11 | 12 | """The main menu.""" 13 | 14 | @unique 15 | class Items(Enum): 16 | START_GAME = 1 17 | QUIT = 2 18 | 19 | def __init__(self, mgr): 20 | """Initialize the class.""" 21 | buf = [ 22 | '-' * 60, 23 | '', 24 | 'Welcome to {}'.format(constants.GAMENAME), 25 | '', 26 | '-' * 60, 27 | '', 28 | '$ ls', 29 | CLIMenuItem(' start', '$ start', MainMenu.Items.START_GAME), 30 | CLIMenuItem(' exit', '$ exit', MainMenu.Items.QUIT) 31 | ] 32 | super().__init__(mgr, buf) 33 | 34 | def _on_choose(self, item): 35 | if item == MainMenu.Items.START_GAME: 36 | self._mgr.push(LevelMenu(self._mgr)) 37 | elif item == MainMenu.Items.QUIT: 38 | # The main menu should be the last gamestate on the stack, so 39 | # popping it should cause the game to exit. 40 | self._mgr.pop() 41 | -------------------------------------------------------------------------------- /menu/menu.py: -------------------------------------------------------------------------------- 1 | """Base classes for the menu system.""" 2 | 3 | 4 | import pygame 5 | import util 6 | import mouse 7 | import constants 8 | from gamestate import GameState 9 | from resources import load_font 10 | 11 | 12 | class MenuItem: 13 | 14 | """A single item in a menu.""" 15 | 16 | def __init__(self, item_id, pos, text, text_size, 17 | colour=(255, 255, 255), selected_colour=(255, 255, 255), 18 | align=util.Align.CENTER): 19 | """Initialize the class.""" 20 | self.item_id = item_id 21 | self._pos = pos 22 | 23 | font = load_font(constants.TERMINAL_FONT, text_size) 24 | self._text = font.render(text, True, colour) 25 | self._selected_text = font.render(text, True, selected_colour) 26 | 27 | # Handle alignment 28 | text_width = self._text.get_rect()[2] 29 | surface_width = pygame.display.get_surface().get_rect()[2] 30 | if align == util.Align.LEFT: 31 | self._pos = (0, self._pos[1]) 32 | elif align == util.Align.CENTER: 33 | self._pos = (int((surface_width / 2) - (text_width / 2)), 34 | self._pos[1]) 35 | else: 36 | self._pos = (surface_width - text_width, self._pos[1]) 37 | 38 | def collidepoint(self, pos): 39 | """Determine whether a given point is within this menu item.""" 40 | return self._text.get_rect().move(self._pos).collidepoint(pos) 41 | 42 | def draw(self, selected): 43 | """Draw the menu item.""" 44 | screen = pygame.display.get_surface() 45 | 46 | if selected: 47 | screen.blit(self._selected_text, self._pos) 48 | else: 49 | screen.blit(self._text, self._pos) 50 | 51 | 52 | class Menu(GameState): 53 | 54 | """ 55 | Base class for a single menu screen. 56 | 57 | Note that this is a subclass of gamestate - the intention is that each 58 | menu screen is a separate gamestate. 59 | """ 60 | 61 | def __init__(self, items): 62 | """Initialize the class.""" 63 | self._items = items 64 | self._selected_index = 0 65 | 66 | def run(self, events): 67 | """Handle events.""" 68 | for event in events: 69 | if event.type == pygame.MOUSEBUTTONDOWN: 70 | self._on_mouseclick(event) 71 | elif event.type == pygame.KEYDOWN: 72 | self._on_keypress(event) 73 | elif event.type == pygame.MOUSEMOTION: 74 | self._on_mousemove(event) 75 | 76 | def draw(self): 77 | """Draw the menu.""" 78 | for idx, item in enumerate(self._items): 79 | item.draw(idx == self._selected_index) 80 | 81 | def _on_keypress(self, event): 82 | """Handle a keypress.""" 83 | if event.key in [pygame.K_UP, pygame.K_LEFT]: 84 | if self._selected_index > 0: 85 | self._selected_index -= 1 86 | elif event.key in [pygame.K_DOWN, pygame.K_RIGHT]: 87 | if self._selected_index < len(self._items) - 1: 88 | self._selected_index += 1 89 | elif event.key in [pygame.K_RETURN, pygame.K_KP_ENTER]: 90 | mouse.current.set_cursor(mouse.Cursor.ARROW) 91 | self._on_choose(self._items[self._selected_index]) 92 | 93 | def _on_mouseclick(self, event): 94 | """Handle a mouse click.""" 95 | if event.button == mouse.Button.LEFT: 96 | for item in [i for i in self._items if i.collidepoint(event.pos)]: 97 | mouse.current.set_cursor(mouse.Cursor.ARROW) 98 | self._on_choose(item) 99 | 100 | def _on_mousemove(self, event): 101 | """Handle mousemove event.""" 102 | over_item = False 103 | for idx, item in enumerate(self._items): 104 | if item.collidepoint(event.pos): 105 | self._selected_index = idx 106 | over_item = True 107 | if over_item: 108 | mouse.current.set_cursor(mouse.Cursor.HAND) 109 | else: 110 | mouse.current.set_cursor(mouse.Cursor.ARROW) 111 | 112 | def _on_choose(self, item): 113 | """Handle activation of a menu item.""" 114 | pass 115 | 116 | 117 | class CLIMenuItem: 118 | 119 | """Class representing an item in a CLI Menu.""" 120 | 121 | def __init__(self, text, cmd="", item=None, disabled=False, selected=False): 122 | """Initialize the class.""" 123 | self.text = text 124 | self.cmd = cmd 125 | self.item = item 126 | self.disabled = disabled 127 | self.selected = selected 128 | 129 | 130 | class CLIMenu(GameState): 131 | 132 | """A menu designed to look like a CLI.""" 133 | 134 | _TEXT_SIZE = constants.TERMINAL_TEXT_SIZE 135 | _TEXT_FONT = constants.TERMINAL_FONT 136 | _TEXT_COLOUR = constants.TEXT_COLOUR 137 | _DISABLED_COLOUR = (100, 100, 100) 138 | _TEXT_START = (45, 50) 139 | _CMD_TEXT_POS = (45, 525) 140 | 141 | def __init__(self, mgr, entries): 142 | """Initialize the class.""" 143 | super().__init__() 144 | self._mgr = mgr 145 | 146 | self._bezel = util.render_bezel(constants.VERSION_STRING) 147 | self._font = load_font(CLIMenu._TEXT_FONT, CLIMenu._TEXT_SIZE) 148 | self._selected_index = 0 149 | self._items = [] 150 | self._cmds = {} 151 | 152 | # Create a '<' image to mark the selected item. 153 | self._select_marker = self._font.render(' <', True, 154 | CLIMenu._TEXT_COLOUR) 155 | self._buf = [] 156 | y_coord = CLIMenu._TEXT_START[1] 157 | for entry in entries: 158 | # Render all the text up front, so that we can use the resulting 159 | # surfaces for hit-detection - we store a tuple containing: 160 | # - The surface, 161 | # - Its coordinates, 162 | # - The menu item it represents, if any 163 | colour = CLIMenu._TEXT_COLOUR 164 | disabled = False 165 | if isinstance(entry, CLIMenuItem): 166 | line = entry.text 167 | item = entry.item 168 | 169 | if entry.disabled: 170 | colour = CLIMenu._DISABLED_COLOUR 171 | disabled = True 172 | else: 173 | self._items.append(item) 174 | 175 | # If this entry should start selected, set the 176 | # selected-index appropriately. 177 | if entry.selected: 178 | self._selected_index = len(self._items) - 1 179 | 180 | # If there's a command string associated with this item, 181 | # render the text and store it in a dictionary mapping the 182 | # item ID to the cmd text 183 | if entry.cmd: 184 | self._cmds[item] = self._font.render( 185 | entry.cmd, True, CLIMenu._TEXT_COLOUR) 186 | else: 187 | line = entry 188 | item = None 189 | 190 | text = self._font.render(line, True, colour) 191 | self._buf.append((text, (CLIMenu._TEXT_START[0], y_coord), item, 192 | disabled)) 193 | y_coord += CLIMenu._TEXT_SIZE 194 | 195 | def run(self, events): 196 | """Handle events.""" 197 | for event in events: 198 | if event.type == pygame.MOUSEBUTTONDOWN: 199 | self._on_mouseclick(event) 200 | elif event.type == pygame.MOUSEMOTION: 201 | self._on_mousemove(event) 202 | elif event.type == pygame.KEYDOWN: 203 | self._on_keypress(event) 204 | 205 | def draw(self): 206 | """Draw the menu.""" 207 | selected_item = self._items[self._selected_index] 208 | 209 | # Draw the text 210 | for line, coords, item, disabled in self._buf: 211 | if line: 212 | pygame.display.get_surface().blit(line, coords) 213 | 214 | if item == selected_item and self._highlight_selection(): 215 | pygame.display.get_surface().blit( 216 | self._select_marker, 217 | (coords[0] + line.get_rect().w, coords[1])) 218 | 219 | # Draw the command string 220 | if selected_item in self._cmds: 221 | pygame.display.get_surface().blit(self._cmds[selected_item], 222 | CLIMenu._CMD_TEXT_POS) 223 | 224 | # Draw the bezel 225 | pygame.display.get_surface().blit(self._bezel, self._bezel.get_rect()) 226 | 227 | @staticmethod 228 | def _highlight_selection(): 229 | """Override to control selection highlighting.""" 230 | return True 231 | 232 | def _hit_item(self, pos): 233 | """Determine whether a given point hits a menu item.""" 234 | # Only consider the lines associated with menu items. 235 | for line, coords, item, disabled in [l for l in self._buf 236 | if l[2] is not None]: 237 | if not disabled and line.get_rect().move(coords).collidepoint(pos): 238 | return item 239 | 240 | return None 241 | 242 | def _on_keypress(self, event): 243 | if event.key in [pygame.K_UP, pygame.K_LEFT]: 244 | if self._selected_index > 0: 245 | self._selected_index -= 1 246 | elif event.key in [pygame.K_DOWN, pygame.K_RIGHT]: 247 | if self._selected_index < len(self._items) - 1: 248 | self._selected_index += 1 249 | elif event.key in [pygame.K_RETURN, pygame.K_KP_ENTER]: 250 | mouse.current.set_cursor(mouse.Cursor.ARROW) 251 | self._on_choose(self._items[self._selected_index]) 252 | 253 | def _on_mouseclick(self, event): 254 | """Determine whether we've clicked on a menu item.""" 255 | item = self._hit_item(event.pos) 256 | if item is not None: 257 | mouse.current.set_cursor(mouse.Cursor.ARROW) 258 | self._on_choose(item) 259 | 260 | def _on_mousemove(self, event): 261 | """Determine if we've moused over a menu item.""" 262 | item = self._hit_item(event.pos) 263 | if item is not None: 264 | self._selected_index = self._items.index(item) 265 | mouse.current.set_cursor(mouse.Cursor.HAND) 266 | else: 267 | mouse.current.set_cursor(mouse.Cursor.ARROW) 268 | 269 | def _on_choose(self, item): 270 | """Handle activation of a menu item.""" 271 | pass 272 | -------------------------------------------------------------------------------- /menu/pause.py: -------------------------------------------------------------------------------- 1 | """Pause Menu implementation.""" 2 | 3 | from .menu import Menu, MenuItem 4 | from .mainmenu import MainMenu 5 | from enum import Enum, unique 6 | 7 | 8 | class PauseMenu(Menu): 9 | 10 | """Class defining the pause menu.""" 11 | 12 | @unique 13 | class Items(Enum): 14 | RESUME = 1 15 | QUIT = 2 16 | 17 | def __init__(self, mgr, terminal): 18 | """Initialize the class.""" 19 | super().__init__(items=[ 20 | MenuItem(item_id=PauseMenu.Items.RESUME, 21 | pos=(0, 200), 22 | text='Resume', 23 | text_size=50, 24 | colour=(50, 50, 50), 25 | selected_colour=(20, 200, 20)), 26 | MenuItem(item_id=PauseMenu.Items.QUIT, 27 | pos=(0, 260), 28 | text='Quit', 29 | text_size=50, 30 | colour=(50, 50, 50), 31 | selected_colour=(20, 200, 20)) 32 | ]) 33 | 34 | self._terminal = terminal 35 | self._mgr = mgr 36 | 37 | def _on_choose(self, item): 38 | """Handle activation of a menu item.""" 39 | if item.item_id == PauseMenu.Items.RESUME: 40 | self._terminal.paused = False 41 | self._mgr.pop() 42 | elif item.item_id == PauseMenu.Items.QUIT: 43 | self._mgr.pop_until(MainMenu) 44 | 45 | def run(self, events): 46 | """Run the pause gamestate.""" 47 | # Run the terminal so that the timer updates. 48 | self._terminal.run() 49 | super().run(events) 50 | 51 | def draw(self): 52 | """Draw the menu.""" 53 | self._terminal.draw_bezel() 54 | super().draw() 55 | -------------------------------------------------------------------------------- /menu/splash.py: -------------------------------------------------------------------------------- 1 | """Splash screen with manual information.""" 2 | 3 | import webbrowser 4 | from enum import Enum, unique 5 | import constants 6 | import timer 7 | from .menu import CLIMenu, CLIMenuItem 8 | from .mainmenu import MainMenu 9 | 10 | 11 | class SplashScreen(CLIMenu): 12 | 13 | """The splash screen.""" 14 | 15 | @unique 16 | class Items(Enum): 17 | LAUNCH_MANUAL = 1 18 | 19 | _WAIT_TIME = 1000 20 | 21 | def __init__(self, mgr): 22 | """Initialize the class.""" 23 | self._timer = timer.Timer() 24 | 25 | buf = [ 26 | '-' * 60, 27 | '', 28 | 'Welcome to {}'.format(constants.GAMENAME), 29 | '', 30 | 'Please read this important notice before you begin', 31 | '', 32 | '-' * 60, 33 | '', 34 | 'This is a cooperative local multiplayer game and must be played', 35 | "with reference to the manual, which can be found in the game's", 36 | 'docs directory, or at: ', 37 | '', 38 | CLIMenuItem('{} (click to view)'.format(constants.MANUAL_URL), 39 | '', 40 | SplashScreen.Items.LAUNCH_MANUAL), 41 | '', 42 | 'Please ensure you have the correct version of the manual to match', 43 | 'your game; this is {} of the game.'.format( 44 | constants.VERSION_STRING), 45 | '', 46 | 'Press any key to continue...' 47 | ] 48 | super().__init__(mgr, buf) 49 | 50 | def run(self, events): 51 | """Handle events.""" 52 | self._timer.update() 53 | super().run(events) 54 | 55 | @staticmethod 56 | def _highlight_selection(): 57 | """Don't highlight the URL button.""" 58 | return False 59 | 60 | def _on_keypress(self, event): 61 | if self._timer.time >= SplashScreen._WAIT_TIME: 62 | self._mgr.replace(MainMenu(self._mgr)) 63 | 64 | def _on_mouseclick(self, event): 65 | item = self._hit_item(event.pos) 66 | if item is None: 67 | if self._timer.time >= SplashScreen._WAIT_TIME: 68 | self._mgr.replace(MainMenu(self._mgr)) 69 | else: 70 | super()._on_mouseclick(event) 71 | 72 | def _on_choose(self, item): 73 | if item == SplashScreen.Items.LAUNCH_MANUAL: 74 | webbrowser.open(constants.MANUAL_URL) 75 | -------------------------------------------------------------------------------- /mouse.py: -------------------------------------------------------------------------------- 1 | """Defines various mouse related classes.""" 2 | 3 | import pygame 4 | 5 | _HAND_STRINGS = ( # sized 24x24 6 | " XX ", 7 | " X..X ", 8 | " X..X ", 9 | " X..X ", 10 | " X..X ", 11 | " X..XXX ", 12 | " X..X..XXX ", 13 | " X..X..X..XX ", 14 | " X..X..X..X.X ", 15 | "XXX X..X..X..X..X ", 16 | "X..XX........X..X ", 17 | "X...X...........X ", 18 | " X..X...........X ", 19 | " X.X...........X ", 20 | " X.............X ", 21 | " X............X ", 22 | " X...........X ", 23 | " X..........X ", 24 | " X..........X ", 25 | " X........X ", 26 | " X........X ", 27 | " XXXXXXXXXX ", 28 | " ", 29 | " ", 30 | ) 31 | _HAND_CURSOR = ((24, 24), (5, 0)) + \ 32 | pygame.cursors.compile(_HAND_STRINGS, ".", "X") 33 | 34 | 35 | class Cursor: 36 | """Supported cursors.""" 37 | ARROW = 1 38 | HAND = 2 39 | 40 | 41 | class Button: 42 | 43 | """Class representing the different mouse buttons.""" 44 | 45 | LEFT = 1 46 | MIDDLE = 2 47 | RIGHT = 3 48 | WHEEL_UP = 4 49 | WHEEL_DOWN = 5 50 | 51 | 52 | class Mouse: 53 | 54 | """Class for tracking and updating cursor.""" 55 | 56 | _CURSORS = { 57 | Cursor.ARROW: pygame.cursors.arrow, 58 | Cursor.HAND: _HAND_CURSOR, 59 | } 60 | 61 | def __init__(self): 62 | self._current_cursor = None 63 | 64 | def set_cursor(self, cursor_num): 65 | if self._current_cursor != cursor_num: 66 | pygame.mouse.set_cursor(*Mouse._CURSORS[cursor_num]) 67 | self._current_cursor = cursor_num 68 | 69 | 70 | """Current mouse state.""" 71 | current = Mouse() 72 | -------------------------------------------------------------------------------- /programs/__init__.py: -------------------------------------------------------------------------------- 1 | from programs.hardware import HardwareInspect 2 | from programs.hexedit import HexEditor 3 | from programs.password import PasswordGuess 4 | from programs.imagepassword import ImagePassword 5 | from programs.network import NetworkManager 6 | from programs.decrypt import Decrypt 7 | from programs.minehunt import MineHunt 8 | -------------------------------------------------------------------------------- /programs/decrypt.py: -------------------------------------------------------------------------------- 1 | """Decryption program classes.""" 2 | 3 | 4 | import random 5 | from resources import load_font 6 | from . import program 7 | 8 | 9 | class Decrypt(program.TerminalProgram): 10 | 11 | """Program class for the decryption puzzle.""" 12 | 13 | # For each font, we have a dictionary mapping from the character in the font 14 | # to what the correct decryption is. 15 | _FONTS = [ 16 | ('media/fonts/Arrows.ttf', 17 | {'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 18 | 'h': 'h', 'i': 'A', 'j': 'B', 'k': 'C', 'l': 'D', 'm': 'E', 'n': 'F', 19 | 'o': 'G', 'p': 'H', 'q': 'y', 'r': 'z', 's': 'Y', 't': 'Z', 'u': 'm', 20 | 'v': 'n', 'w': 'Q', 'x': 'R', 'y': 'S', 'z': 'T'}), 21 | ('media/fonts/Gobotronic.otf', 22 | {'a': 'n', 'b': 'o', 'c': 'p', 'd': 'q', 'e': 'r', 'f': 's', 'g': 't', 23 | 'h': 'u', 'i': 'v', 'j': 'w', 'k': 'x', 'l': 'y', 'm': 'z', 'n': 'N', 24 | 'o': 'O', 'p': 'P', 'q': 'Q', 'r': 'R', 's': 'S', 't': 'T', 'u': 'U', 25 | 'v': 'V', 'w': 'W', 'x': 'X', 'y': 'Y', 'z': 'Z'}), 26 | ('media/fonts/PigpenCipher.otf', 27 | {'a': 'n', 'b': 'z', 'c': 'q', 'd': 'o', 'e': 'e', 'f': 'g', 'g': 'm', 28 | 'h': 'u', 'i': 'v', 'j': 'p', 'k': 'x', 'l': 'k', 'm': 'f', 'n': 'b', 29 | 'o': 'y', 'p': 'i', 'q': 'l', 'r': 'r', 's': 'c', 't': 't', 'u': 'w', 30 | 'v': 'h', 'w': 'd', 'x': 's', 'y': 'a', 'z': 'j'}), 31 | ('media/fonts/circlethings.ttf', 32 | {'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 33 | 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'J', 'm': 'm', 'n': 'A', 34 | 'o': 'B', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 35 | 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z'}), 36 | ] 37 | 38 | _TEXT_SIZE = 40 39 | _MIN_LENGTH = 4 40 | _MAX_LENGTH = 8 41 | _FREEZE_TIME = 2 * 1000 42 | 43 | def __init__(self, terminal): 44 | """Initialize the class.""" 45 | super().__init__(terminal) 46 | self._correct = False 47 | 48 | self._fontname = "" 49 | self._cypher = None 50 | 51 | self._enc_string = "" 52 | self._dec_string = "" 53 | 54 | def start(self): 55 | """Start the program.""" 56 | self._fontname, self._cypher = random.choice(Decrypt._FONTS) 57 | load_font(self._fontname, Decrypt._TEXT_SIZE) 58 | self._dec_string = ''.join( 59 | [random.choice(list(self._cypher.keys())) for _ in 60 | range(random.randrange(Decrypt._MIN_LENGTH, 61 | Decrypt._MAX_LENGTH + 1))]) 62 | self._enc_string = ''.join([self._cypher[e] for e in self._dec_string]) 63 | self._terminal.output(['{}'.format( 64 | Decrypt._TEXT_SIZE, self._fontname, self._enc_string)]) 65 | 66 | @property 67 | def help(self): 68 | """Return the help string.""" 69 | return "Decrypt filesystem." 70 | 71 | @property 72 | def security_type(self): 73 | """Return the security type.""" 74 | return "filesystem encryption." 75 | 76 | @property 77 | def prompt(self): 78 | """Return the prompt.""" 79 | return "Enter decryption key: " 80 | 81 | def exited(self): 82 | """Indicate whether the program has exited.""" 83 | return self._correct 84 | 85 | def completed(self): 86 | """Indicate whether the task was completed.""" 87 | return self._correct 88 | 89 | def text_input(self, line): 90 | """Check whether the correct password was entered.""" 91 | if line == self._dec_string: 92 | self._correct = True 93 | else: 94 | self._terminal.output([self.failure_prefix + 95 | "decryption failed, reversing!"]) 96 | self._terminal.freeze(Decrypt._FREEZE_TIME) 97 | -------------------------------------------------------------------------------- /programs/hardware.py: -------------------------------------------------------------------------------- 1 | """Hardware program classes.""" 2 | 3 | import pygame 4 | import random 5 | 6 | import mouse 7 | from . import program 8 | from resources import load_image, load_font 9 | 10 | 11 | class BoardDefinition: 12 | 13 | boards = [] 14 | 15 | """Defines a board layout. """ 16 | def __init__(self, filename, assets, *pair_positions): 17 | """ 18 | 19 | Initialize the board. 20 | 21 | Arguments: 22 | filename: 23 | the image to use for the board 24 | 25 | assets: 26 | List of image, position pairs for additional static assets to add to 27 | board. 28 | 29 | pair_positions: 30 | A pair_position is a tuple of (chip position, resistor position). 31 | This argument can be repeated multiple times for multiple groups. 32 | 33 | """ 34 | 35 | self.filename = filename 36 | self.positions = pair_positions 37 | self.assets = assets 38 | 39 | BoardDefinition.boards.append(self) 40 | 41 | 42 | """Define boards.""" 43 | BoardDefinition("media/motherboard4.png", 44 | [("media/cpu.png", (420, 240))], 45 | ((10, 10), (10, 100)), 46 | ((10, 210), (10, 300)), 47 | ((210, 10), (210, 100)), 48 | ((210, 210), (210, 300))) 49 | 50 | BoardDefinition("media/motherboard3.png", 51 | [("media/cpu.png", (420, 240))], 52 | ((10, 10), (10, 100)), 53 | ((110, 210), (110, 300)), 54 | ((210, 10), (210, 100))) 55 | 56 | 57 | """Chip codes parameters""" 58 | CHIP_CODE_LENGTHS = (4, 5, 5, 5, 6, 7, 7, 7) 59 | CHIP_CODE_CHARS = ("0", "1", "2", "3", "4", "5", "6", "7", "8", "9") 60 | CHIP_CODE_END_CHARS = ("0", "0", "1", "2", "3", "4", "A", "B") 61 | 62 | 63 | """Resistor codes""" 64 | RESISTOR_CODES = { 65 | "rryg": 10, 66 | "rByg": 20, 67 | "ybbr": 30, 68 | "rbby": 40, 69 | "grbg": 50, 70 | "gBbg": 60, 71 | } 72 | 73 | 74 | # Rows are: ohms 75 | # Columns are: chip code last char - Odd, even, zero, letter 76 | RULES_TABLE1 = { 77 | 10: ["N", "C", "R", "B"], 78 | 20: ["C", "C", "B", "R"], 79 | 30: ["R", "R", "C", "C"], 80 | 40: ["B", "N", "R", "C"], 81 | 50: [1, 4, "N", 3], 82 | 60: [5, "C", 2, 6], 83 | } 84 | 85 | 86 | # Rows are: row numbers referred to from table1 87 | # Columns are: terminal name last char - Odd, even, zero, letter 88 | RULES_TABLE2 = { 89 | 1: ["C", "N", "R", "C"], 90 | 2: ["R", "C", "R", "C"], 91 | 3: ["R", "B", "C", "R"], 92 | 4: ["C", "R", "C", "R"], 93 | 5: ["N", "C", "R", "B"], 94 | 6: ["C", "C", "C", "R"], 95 | } 96 | 97 | 98 | # Get column in rules table based on chip/terminal code 99 | def col_from_code(code): 100 | # Look at last character, return: 101 | # 0 if odd 102 | # 1 if even 103 | # 2 if zero 104 | # 3 if letter 105 | try: 106 | num = int(code[-1], 10) 107 | except ValueError: 108 | num = None 109 | 110 | if num is None: 111 | return 3 112 | elif num == 0: 113 | return 2 114 | elif num % 2 == 0: 115 | return 1 116 | else: 117 | return 0 118 | 119 | 120 | class ComponentPair: 121 | 122 | """Pair of chip and resister components that are validated together.""" 123 | 124 | def __init__(self, terminal_id, 125 | chip_code, resistor_code, chip_pos, resistor_pos): 126 | self._chip = Chip(chip_code, chip_pos) 127 | self._resistor = Resistor(resistor_code, resistor_pos) 128 | self._terminal_id = terminal_id 129 | 130 | def setup_draw(self, surface): 131 | self._chip.setup_draw(surface) 132 | self._resistor.setup_draw(surface) 133 | 134 | def hit_component(self, pos): 135 | if self._chip.collidepoint(pos): 136 | return self._chip 137 | elif self._resistor.collidepoint(pos): 138 | return self._resistor 139 | else: 140 | return None 141 | 142 | @property 143 | def has_disabled(self): 144 | """Does this component group have any disabled elements?""" 145 | return self._chip.disabled or self._resistor.disabled 146 | 147 | @property 148 | def is_correct(self): 149 | """ 150 | Is this component group in a correct state? 151 | 152 | """ 153 | actions = { 154 | "N": (lambda: not self._chip.disabled and 155 | not self._resistor.disabled), 156 | "C": (lambda: self._chip.disabled and 157 | not self._resistor.disabled), 158 | "R": (lambda: not self._chip.disabled and 159 | self._resistor.disabled), 160 | "B": (lambda: self._chip.disabled and 161 | self._resistor.disabled), 162 | } 163 | 164 | # First get the action based on resister value and chip code 165 | action = self._action_from_table(RULES_TABLE1, 166 | RESISTOR_CODES[self._resistor.code], 167 | self._chip.code) 168 | if action in actions: 169 | return actions[action]() 170 | else: 171 | # Action is table2 row, so get action based on this and terminal id 172 | action = self._action_from_table(RULES_TABLE2, 173 | action, 174 | self._terminal_id) 175 | return actions[action]() 176 | 177 | @staticmethod 178 | def _action_from_table(table, key, code): 179 | return table[key][col_from_code(code)] 180 | 181 | 182 | class HardwareInspect(program.TerminalProgram): 183 | 184 | """The hardware inspection program.""" 185 | 186 | _BOARD_Y = 50 187 | 188 | _MESSAGE_TEXT = "<- Click to power back on" 189 | _MESSAGE_COLOUR = (180, 180, 180) 190 | _MESSAGE_FONT = "media/fonts/whitrabt.ttf" 191 | _MESSAGE_SIZE = 20 192 | _MESSAGE_POS = (77, 572) 193 | _POWER_BUTTON_RECT = (49, 566, 26, 26) 194 | 195 | """The properties of this program.""" 196 | PROPERTIES = program.ProgramProperties(is_graphical=True, 197 | suppress_success=True, 198 | skip_bezel=True) 199 | 200 | def __init__(self, terminal): 201 | """Initialize the class.""" 202 | super().__init__(terminal) 203 | 204 | # The draw surface 205 | self._draw_surface = None 206 | 207 | # Grab a board definition at random 208 | board_def = random.choice(BoardDefinition.boards) 209 | 210 | self._component_pairs = self._create_component_pairs(board_def) 211 | 212 | # Create the board 213 | self._board = load_image(board_def.filename) 214 | 215 | # Add the static assets 216 | for filename, pos in board_def.assets: 217 | image = load_image(filename) 218 | self._board.blit(image, pos) 219 | 220 | # Set the board position 221 | screen_rect = pygame.display.get_surface().get_rect() 222 | board_rect = self._board.get_rect() 223 | self._board_pos = (int((screen_rect[2] / 2) - (board_rect[2] / 2)), 224 | self._BOARD_Y) 225 | 226 | # Create the message pointing at the button 227 | font = load_font(self._MESSAGE_FONT, self._MESSAGE_SIZE) 228 | self._message_text = font.render(self._MESSAGE_TEXT, True, 229 | self._MESSAGE_COLOUR) 230 | self._button_rect = pygame.Rect(self._POWER_BUTTON_RECT) 231 | 232 | self._completed = False 233 | self._exited = False 234 | 235 | @property 236 | def help(self): 237 | """Get the help string for the program.""" 238 | return "Suspend system and modify hardware." 239 | 240 | @property 241 | def security_type(self): 242 | """Get the scurity type for the program.""" 243 | return "hardware security" 244 | 245 | def start(self): 246 | # Reset board 247 | self._setup_draw() 248 | self._exited = False 249 | 250 | def completed(self): 251 | """Indicate whether the program was completed.""" 252 | return self._completed 253 | 254 | def exited(self): 255 | """Indicate whether the program has exited.""" 256 | return self.completed() or self._exited 257 | 258 | def on_mousemove(self, pos): 259 | if self._hit_component(pos) is not None or self._is_in_button(pos): 260 | mouse.current.set_cursor(mouse.Cursor.HAND) 261 | else: 262 | mouse.current.set_cursor(mouse.Cursor.ARROW) 263 | 264 | def on_mouseclick(self, button, pos): 265 | """Detect whether the user clicked the program.""" 266 | if button == mouse.Button.LEFT: 267 | # Find the component clicked 268 | component = self._hit_component(pos) 269 | if component is not None: 270 | component.toggle() 271 | self._setup_draw() 272 | elif self._is_in_button(pos): 273 | # If nothing has been disabled, then just reboot. Else check 274 | # that there are no incorrect groups. 275 | if len([p for p in self._component_pairs 276 | if p.has_disabled]) == 0: 277 | self._terminal.reboot() 278 | self._exited = True 279 | elif len([p for p in self._component_pairs 280 | if not p.is_correct]) == 0: 281 | self._completed = True 282 | self._terminal.reboot(self.success_syslog) 283 | else: 284 | self._exited = True 285 | self._terminal.reboot(self.failure_prefix + 286 | "Hardware error: clock skew " 287 | "detected. Recovering") 288 | self._terminal.reduce_time(10) 289 | 290 | def draw(self): 291 | """Draw the program.""" 292 | screen = pygame.display.get_surface() 293 | screen.blit(self._draw_surface, self._board_pos) 294 | 295 | # Draw the power off bezel now, so we can then write on it. 296 | self._terminal.draw_bezel(power_off=True) 297 | 298 | # Draw message text 299 | screen.blit(self._message_text, self._MESSAGE_POS) 300 | 301 | def _create_component_pairs(self, board_def): 302 | component_pairs = [] 303 | 304 | def chip_code(): 305 | code = "" 306 | for _ in range(random.choice(CHIP_CODE_LENGTHS) - 1): 307 | code += random.choice(CHIP_CODE_CHARS) 308 | code += random.choice(CHIP_CODE_END_CHARS) 309 | return code 310 | 311 | resistor_codes = list(RESISTOR_CODES.keys()) 312 | 313 | # Keep generating sets until we get one that has a pair that isn't 314 | # correct yet 315 | while len([c for c in component_pairs if not c.is_correct]) == 0: 316 | component_pairs = [ComponentPair(self._terminal.id_string, 317 | chip_code(), 318 | random.choice(resistor_codes), 319 | c_pos, r_pos) 320 | for c_pos, r_pos in board_def.positions] 321 | 322 | return component_pairs 323 | 324 | def _setup_draw(self): 325 | self._draw_surface = self._board.copy() 326 | 327 | for pair in self._component_pairs: 328 | pair.setup_draw(self._draw_surface) 329 | 330 | def _is_in_button(self, pos): 331 | return self._button_rect.collidepoint(pos) 332 | 333 | def _hit_component(self, pos): 334 | board_pos = self._screen_to_board_pos(pos) 335 | for pair in self._component_pairs: 336 | component = pair.hit_component(board_pos) 337 | if component is not None: 338 | return component 339 | 340 | return None 341 | 342 | def _screen_to_board_pos(self, pos): 343 | return pos[0] - self._board_pos[0], pos[1] - self._board_pos[1] 344 | 345 | 346 | class Component: 347 | """"Base class representing active hardware component on a motherboard.""" 348 | 349 | def __init__(self, code, pos): 350 | self.disabled = False 351 | self.code = code 352 | 353 | self._image = None 354 | self._pos = pos 355 | 356 | def toggle(self): 357 | self.disabled = not self.disabled 358 | 359 | def create_image(self): 360 | pass 361 | 362 | def setup_draw(self, surface): 363 | # If we don't have an image yet, then create it 364 | if self._image is None: 365 | self.create_image() 366 | 367 | # If disabled, grey out 368 | if self.disabled: 369 | image = self._image.copy() 370 | rect = self._image.get_rect() 371 | dark = pygame.Surface((rect[2], rect[3]), 372 | flags=pygame.SRCALPHA) 373 | dark.fill((100, 100, 100, 0)) 374 | image.blit(dark, (0, 0), special_flags=pygame.BLEND_RGBA_SUB) 375 | surface.blit(image, self._pos) 376 | else: 377 | surface.blit(self._image, self._pos) 378 | 379 | def collidepoint(self, pos): 380 | return self._image.get_rect().move(self._pos).collidepoint(pos) 381 | 382 | 383 | class Resistor(Component): 384 | 385 | """Resister component.""" 386 | 387 | _COLOURS = { 388 | "r": (190, 50, 50), 389 | "g": (110, 175, 55), 390 | "b": (70, 70, 230), 391 | "y": (220, 200, 90), 392 | "B": (0, 0, 0), 393 | } 394 | 395 | _LINE_WIDTH = 5 396 | _BACKGROUND_COLOUR = (216, 192, 169) 397 | _AREA_START = 43 398 | _AREA_WIDTH = 94 399 | 400 | def create_image(self): 401 | # Take a copy as we are going to edit it! 402 | self._image = load_image('media/resistor.png').copy() 403 | 404 | # Create a surface to draw the lines on, so we can blend it with the 405 | # resistor and have it ignore the portions of the lines outside the 406 | # resistor 407 | height = self._image.get_rect()[3] 408 | surface = pygame.Surface((self._AREA_WIDTH, height)) 409 | surface.fill((255, 255, 255)) 410 | surface.set_alpha(0) 411 | 412 | # Work out the spacing 413 | gap = (self._AREA_WIDTH - 2 * self._LINE_WIDTH) / (len(self.code) + 1) 414 | 415 | # Add lines 416 | for idx, colour in enumerate(self.code): 417 | line_x = self._LINE_WIDTH + idx * gap 418 | pygame.draw.line(surface, 419 | self._COLOURS[colour], 420 | (line_x, 0), 421 | (line_x, height), 422 | self._LINE_WIDTH) 423 | 424 | # Add our surface 425 | self._image.blit(surface, (self._AREA_START, 0), 426 | special_flags=pygame.BLEND_RGBA_MULT) 427 | 428 | 429 | class Chip(Component): 430 | 431 | """Chip component.""" 432 | 433 | _FONT = "media/fonts/METRO-DF.TTF" 434 | _FONT_SIZE = 14 435 | _FONT_COLOUR = (180, 180, 180) 436 | 437 | def create_image(self): 438 | # Take a copy as we are going to edit it! 439 | self._image = load_image('media/chip.png').copy() 440 | 441 | # Add code to the chip 442 | font = load_font(self._FONT, self._FONT_SIZE) 443 | text = font.render(self.code, True, self._FONT_COLOUR) 444 | 445 | image_rect = self._image.get_rect() 446 | text_rect = text.get_rect() 447 | self._image.blit(text, 448 | (image_rect[2] - text_rect[2] - 5, 449 | image_rect[3] - text_rect[3] - 15)) 450 | -------------------------------------------------------------------------------- /programs/hexedit.py: -------------------------------------------------------------------------------- 1 | """Hexeditor program classes.""" 2 | 3 | import logging 4 | import random 5 | from enum import Enum, unique 6 | from copy import deepcopy 7 | from . import program 8 | 9 | 10 | class HexEditor(program.TerminalProgram): 11 | 12 | """Program class for the hex-editor puzzle.""" 13 | 14 | @unique 15 | class States(Enum): 16 | QUERY_ROW = 0 17 | ENTER_COL = 1 18 | ENTER_VAL = 2 19 | FINISHED = 3 20 | 21 | _MIN_FILE_LENGTH = 4 22 | _MAX_FILE_LENGTH = 5 23 | 24 | _ROW_PROMPT = 'Edit line {}? (y/n) ' 25 | _COL_PROMPT = 'Edit col num: ' 26 | _VAL_PROMPT = 'Change {} to (leave empty to cancel): ' 27 | 28 | # How long is the freeze time (in ms) when a mistake is made 29 | _FREEZE_TIME = 5 * 1000 30 | 31 | """The properties of this program.""" 32 | PROPERTIES = program.ProgramProperties(alternate_buf=True) 33 | 34 | def __init__(self, terminal): 35 | """Initialize the class.""" 36 | super().__init__(terminal) 37 | self._completed = False 38 | self._row = 0 39 | self._col = 0 40 | self._state = HexEditor.States.QUERY_ROW 41 | self._start_data = [] 42 | self._end_data = [] 43 | 44 | @property 45 | def help(self): 46 | """Return the help string.""" 47 | return "Modify raw software data." 48 | 49 | @property 50 | def security_type(self): 51 | """Return the security type.""" 52 | return "software lock" 53 | 54 | @property 55 | def prompt(self): 56 | """Return the prompt based on the current state.""" 57 | if self._state == HexEditor.States.QUERY_ROW: 58 | return HexEditor._ROW_PROMPT.format(self._row) 59 | elif self._state == HexEditor.States.ENTER_COL: 60 | return HexEditor._COL_PROMPT 61 | elif self._state == HexEditor.States.ENTER_VAL: 62 | return HexEditor._VAL_PROMPT.format( 63 | self._start_data[self._row][self._col]) 64 | 65 | def start(self): 66 | """Start the program.""" 67 | self._row = 0 68 | self._col = 0 69 | self._state = HexEditor.States.QUERY_ROW 70 | self._start_data = HexEditor._generate_data() 71 | self._end_data = deepcopy(self._start_data) 72 | 73 | def completed(self): 74 | """Indicate whether the user has guessed the password.""" 75 | return self._completed 76 | 77 | def exited(self): 78 | """Indicate whether the current instance has exited.""" 79 | return self._state == HexEditor.States.FINISHED 80 | 81 | def text_input(self, line): 82 | """Handle editor commands.""" 83 | # Remove any leading whitespace and convert to lowercase. 84 | if self._state == HexEditor.States.QUERY_ROW: 85 | if line.startswith('y'): 86 | self._state = HexEditor.States.ENTER_COL 87 | else: 88 | self._row += 1 89 | elif self._state == HexEditor.States.ENTER_COL: 90 | try: 91 | self._col = int(line) 92 | except ValueError: 93 | raise program.BadInput('Not a number') 94 | 95 | if self._col < 0 or self._col >= len(self._start_data[0]): 96 | raise program.BadInput('Column out of range') 97 | 98 | self._state = HexEditor.States.ENTER_VAL 99 | elif self._state == HexEditor.States.ENTER_VAL: 100 | # If the user has cancelled, go back to the row query. 101 | if not line: 102 | self._state = HexEditor.States.QUERY_ROW 103 | else: 104 | try: 105 | self._end_data[self._row][self._col] = int(line) 106 | logging.debug('Line {}: col {} {}->{}'.format( 107 | self._row, self._col, 108 | self._start_data[self._row][self._col], 109 | self._end_data[self._row][self._col])) 110 | except ValueError: 111 | raise program.BadInput('Not a number') 112 | 113 | self._row += 1 114 | self._state = HexEditor.States.QUERY_ROW 115 | 116 | # Check if we've reached the end of the file, and if so see if the edits 117 | # were correct. 118 | self._check_finished() 119 | 120 | def _check_finished(self): 121 | """Determine if edits are finished, and whether they were successful.""" 122 | if self._row == len(self._start_data): 123 | self._state = HexEditor.States.FINISHED 124 | if self._data_correct(): 125 | self._completed = True 126 | else: 127 | self._terminal.output([self.failure_prefix + 128 | "corruption detected " 129 | "in system file, repairing!"]) 130 | self._terminal.freeze(HexEditor._FREEZE_TIME) 131 | 132 | @staticmethod 133 | def _generate_data(): 134 | """Generate the data for the puzzle.""" 135 | return [[random.randrange(10) for _ in range(6)] for _ in 136 | range(random.randrange(HexEditor._MIN_FILE_LENGTH, 137 | HexEditor._MAX_FILE_LENGTH))] 138 | 139 | @property 140 | def buf(self): 141 | """Return the program's text output.""" 142 | col_count = len(self._start_data[0]) 143 | lines = ["" + " " * 2 + " | " + " ".join( 144 | "{:2}".format(i) for i in range(col_count)), 145 | "-" * (5 + 4 * col_count)] 146 | 147 | lines.extend( 148 | ["{:2} | ".format(idx) + " ".join( 149 | "{:2d}".format(c) for c in row) 150 | for idx, row in enumerate(self._end_data)] + [""]) 151 | 152 | return reversed(lines) 153 | 154 | def _data_correct(self): 155 | """Determine if the edits made to the data were correct.""" 156 | edited_previous = False 157 | edits, edited_idx, edited_old, edited_new = (0, 0, 0, 0) 158 | for idx, (start, end) in enumerate(zip(self._start_data, 159 | self._end_data)): 160 | expect_edit = True 161 | if idx + 1 == len(self._start_data): 162 | # This is the last line - (j) in the manual 163 | # The last value should match the total number of lines, and 164 | # the rest of the line should be unedited. 165 | logging.debug('j - Line {}: col 5 {}->{}'.format( 166 | idx, start[-1], edits)) 167 | if end[-1] != edits or end[:-1] != start[:-1]: 168 | return False 169 | elif idx == 0 or not edited_previous: 170 | # This is the first line, or we didn't edit the previous line. 171 | if start.count(9) > 0: 172 | # (d) in the manual 173 | logging.debug('d - Line {} has {} 9s, col 5 {}->7'.format( 174 | idx, start.count(9), start[5])) 175 | if end[5] != 7: 176 | return False 177 | elif start.count(0) > 0: 178 | # (e) in the manual 179 | logging.debug('e - Line {} has {} 0s, col {} 0->9'.format( 180 | idx, start.count(0), start.index(0))) 181 | if end[start.index(0)] != 9: 182 | return False 183 | else: 184 | # Count odd numbers. 185 | odds = [(i, v) for i, v in enumerate(start) if v % 2 == 1] 186 | if len(odds) > 3: 187 | # (f) in the manual 188 | logging.debug( 189 | 'f - Line {} has {} odds, col {} {}->{}'.format( 190 | idx, len(odds), odds[0][0], start[odds[0][0]], 191 | start[odds[0][0]] - 1)) 192 | if end[odds[0][0]] != start[odds[0][0]] - 1: 193 | return False 194 | else: 195 | # (x) in the manual - don't edit. 196 | logging.debug('x- Line {}: do not edit'.format(idx)) 197 | expect_edit = False 198 | else: 199 | # A middle line, and we edited the previous line. 200 | if start.count(0) > 1: 201 | # (g) in the manual 202 | logging.debug('g - Line {} has {} 0s, col 0 {}->{}'.format( 203 | idx, start.count(0), start[0], edited_old)) 204 | if end[0] != edited_old: 205 | return False 206 | elif start.count(9) > 1: 207 | # (h) in the manual 208 | logging.debug('h - Line {} has {} 9s: col {} {}->{}'.format( 209 | idx, start.count(9), start.index(9), 210 | start[start.index(9)], edited_idx)) 211 | if end[start.index(9)] != edited_idx: 212 | return False 213 | elif start.count(edited_new) > 0: 214 | # (i) in the manual 215 | logging.debug('i - Line {} has {} {}s: col {} {}->0'.format( 216 | idx, start.count(edited_new), edited_new, 217 | start.index(edited_new), 218 | start[start.index(edited_new)])) 219 | if end[start.index(edited_new)] != 0: 220 | return False 221 | else: 222 | # (x) in the manual - don't edit. 223 | logging.debug('x- Line {}: do not edit'.format(idx)) 224 | expect_edit = False 225 | 226 | edited_previous = start != end 227 | logging.debug('edited line: {}'.format(edited_previous)) 228 | if edited_previous: 229 | edits += 1 230 | edited_idx = [i for i, (s, e) in enumerate(zip(start, end)) 231 | if s != e][0] 232 | edited_old = start[edited_idx] 233 | edited_new = end[edited_idx] 234 | 235 | # Check that we didn't edit a line we didn't expect to. 236 | # Note this doens't check that we didn't edit one we expected to - 237 | # the lines might have matched originally. 238 | if edited_previous and not expect_edit: 239 | return False 240 | 241 | return True 242 | -------------------------------------------------------------------------------- /programs/imagepassword.py: -------------------------------------------------------------------------------- 1 | """Image password program classes.""" 2 | 3 | import pygame 4 | import random 5 | import mouse 6 | from enum import Enum, unique 7 | from . import program 8 | from resources import load_font, load_image 9 | 10 | 11 | @unique 12 | class Categories(Enum): 13 | 14 | """Categories of image to display.""" 15 | 16 | CARS = 'media/login/cars.png' 17 | PLANES = 'media/login/planes.png' 18 | FLOWERS = 'media/login/flowers.png' 19 | CATS = 'media/login/cats.png' 20 | DOGS = 'media/login/dogs.png' 21 | MUSIC = 'media/login/music.png' 22 | SOCCER = 'media/login/soccer.png' 23 | TENNIS = 'media/login/tennis.png' 24 | FOOD = 'media/login/food.png' 25 | COMPUTERS = 'media/login/computers.png' 26 | BOATS = 'media/login/boats.png' 27 | ARCHERY = 'media/login/archery.png' 28 | BOOKS = 'media/login/books.png' 29 | WINE = 'media/login/wine.png' 30 | BEER = 'media/login/beer.png' 31 | HORSES = 'media/login/horses.png' 32 | BASKETBALL = 'media/login/basketball.png' 33 | BASEBALL = 'media/login/baseball.png' 34 | SKATEBOARDING = 'media/login/skateboarding.png' 35 | 36 | 37 | class ImagePassword(program.TerminalProgram): 38 | 39 | """The image password program.""" 40 | 41 | _USER_INFO = [ 42 | {Categories.CARS, Categories.PLANES, Categories.FLOWERS, 43 | Categories.CATS, Categories.DOGS, Categories.MUSIC}, 44 | {Categories.CARS, Categories.BEER, Categories.SOCCER, 45 | Categories.TENNIS, Categories.FOOD, Categories.COMPUTERS}, 46 | {Categories.SOCCER, Categories.TENNIS, Categories.FLOWERS, 47 | Categories.CATS, Categories.BOATS, Categories.ARCHERY}, 48 | {Categories.CARS, Categories.BOATS, Categories.ARCHERY, 49 | Categories.CATS, Categories.COMPUTERS, Categories.BOOKS}, 50 | {Categories.BOOKS, Categories.WINE, Categories.PLANES, 51 | Categories.COMPUTERS, Categories.MUSIC, Categories.HORSES}, 52 | {Categories.FOOD, Categories.WINE, Categories.BEER, 53 | Categories.PLANES, Categories.BASKETBALL, Categories.BASEBALL}, 54 | {Categories.SOCCER, Categories.BASKETBALL, Categories.BASEBALL, 55 | Categories.ARCHERY, Categories.SKATEBOARDING, Categories.TENNIS}, 56 | {Categories.SKATEBOARDING, Categories.HORSES, Categories.MUSIC, 57 | Categories.DOGS, Categories.FOOD, Categories.WINE} 58 | ] 59 | 60 | _BACKGROUND_POS = (270, 140) 61 | _BACKGROUND_SIZE = (260, 290) 62 | _BACKGROUND_COLOUR = (180, 180, 180) 63 | _BACKGROUND_FLASH_COLOUR = (255, 0, 0) 64 | _BACKGROUND_FLASH_TIME = 100 65 | 66 | _HEADER_POS = (2, 2) 67 | _HEADER_SIZE = (256, 26) 68 | _HEADER_COLOUR = (160, 160, 160) 69 | _HEADER_TEXT_SIZE = 16 70 | _HEADER_TEXT_FONT = 'media/fonts/Sansation_Regular.ttf' 71 | _HEADER_TEXT_COLOUR = (0, 0, 0) 72 | _HEADER_TEXT_POS = (4, 8) 73 | 74 | _BUTTON_COORDS = [ 75 | (280, 180), 76 | (420, 180), 77 | (280, 320), 78 | (420, 320), 79 | ] 80 | 81 | _BUTTON_SIZE = 100 82 | _BUTTON_BORDER_WIDTH = 2 83 | _BUTTON_BORDER_COLOUR = _HEADER_COLOUR 84 | 85 | _GUESSED_OVERLAY_COLOUR = (150, 150, 150) 86 | _GUESSED_OVERLAY_ALPHA = 180 87 | 88 | _LOCK_TIME = 2000 89 | 90 | """The properties of this program.""" 91 | PROPERTIES = program.ProgramProperties(is_graphical=True) 92 | 93 | def __init__(self, terminal): 94 | """Initialize the class.""" 95 | super().__init__(terminal) 96 | self._completed = False 97 | self._user_info = random.choice(ImagePassword._USER_INFO) 98 | self._buttons = [] 99 | self._lock_time = 0 100 | self._background = pygame.Surface(ImagePassword._BACKGROUND_SIZE) 101 | self._background.fill(ImagePassword._BACKGROUND_COLOUR) 102 | header = pygame.Surface(ImagePassword._HEADER_SIZE) 103 | header.fill(ImagePassword._HEADER_COLOUR) 104 | self._background.blit(header, ImagePassword._HEADER_POS) 105 | 106 | font = load_font(ImagePassword._HEADER_TEXT_FONT, 107 | ImagePassword._HEADER_TEXT_SIZE) 108 | text = font.render("Select three images", True, 109 | ImagePassword._HEADER_TEXT_COLOUR) 110 | self._background.blit(text, ImagePassword._HEADER_TEXT_POS) 111 | 112 | for coords in ImagePassword._BUTTON_COORDS: 113 | border_coords = (coords[0] - ImagePassword._BUTTON_BORDER_WIDTH - 114 | ImagePassword._BACKGROUND_POS[0], 115 | coords[1] - ImagePassword._BUTTON_BORDER_WIDTH - 116 | ImagePassword._BACKGROUND_POS[1]) 117 | border_size = (ImagePassword._BUTTON_SIZE + 118 | ImagePassword._BUTTON_BORDER_WIDTH * 2) 119 | 120 | border = pygame.Surface((border_size, border_size)) 121 | border.fill(ImagePassword._BUTTON_BORDER_COLOUR) 122 | self._background.blit(border, border_coords) 123 | 124 | self._correct_overlay = pygame.Surface((ImagePassword._BUTTON_SIZE, 125 | ImagePassword._BUTTON_SIZE)) 126 | self._correct_overlay.fill(ImagePassword._GUESSED_OVERLAY_COLOUR) 127 | self._correct_overlay.set_alpha(ImagePassword._GUESSED_OVERLAY_ALPHA) 128 | 129 | self._flash = pygame.Surface(ImagePassword._BACKGROUND_SIZE) 130 | self._flash.fill(ImagePassword._BACKGROUND_FLASH_COLOUR) 131 | 132 | @property 133 | def help(self): 134 | """Return the help string for the program.""" 135 | return "Run visual login program." 136 | 137 | @property 138 | def security_type(self): 139 | """Return the security type for the program.""" 140 | return "visual authentication" 141 | 142 | @property 143 | def allow_ctrl_c(self): 144 | """Don't allow ctrl-c if the program is locked.""" 145 | return not self._locked() 146 | 147 | def start(self): 148 | """Start the program.""" 149 | self._pick_images() 150 | self._lock_time = 0 151 | 152 | def _pick_images(self): 153 | """Pick the images to present, and generate buttons from them.""" 154 | # Pick 3 images from the user's images, and a 4th from the remaining 155 | # images, shuffle together to form the final list of images. 156 | user_imgs = random.sample(self._user_info, 3) 157 | other_img = random.sample( 158 | set(Categories).difference(self._user_info), 1) 159 | choices = user_imgs + other_img 160 | random.shuffle(choices) 161 | 162 | # Build the buttons 163 | self._buttons = [] 164 | for i, c in enumerate(choices): 165 | self._buttons.append([load_image(c.value), 166 | ImagePassword._BUTTON_COORDS[i], 167 | c, False]) 168 | 169 | def _locked(self): 170 | """Indicate if the user is temporarily locked out.""" 171 | return (self._lock_time != 0 and 172 | self._terminal.time <= self._lock_time + 173 | ImagePassword._LOCK_TIME) 174 | 175 | def draw(self): 176 | """Draw the program.""" 177 | # Draw the background. 178 | pygame.display.get_surface().blit(self._background, 179 | ImagePassword._BACKGROUND_POS) 180 | 181 | # If the user has made a mistake, flash the background. 182 | if (self._lock_time != 0 and self._terminal.time <= self._lock_time + 183 | ImagePassword._BACKGROUND_FLASH_TIME): 184 | pygame.display.get_surface().blit(self._flash, 185 | ImagePassword._BACKGROUND_POS) 186 | 187 | # Draw the buttons. 188 | if not self._locked(): 189 | for surf, coords, _, correct in self._buttons: 190 | pygame.display.get_surface().blit(surf, coords) 191 | 192 | if correct: 193 | pygame.display.get_surface().blit(self._correct_overlay, 194 | coords) 195 | 196 | def on_mouseclick(self, button, pos): 197 | """Detect whether the user clicked the correct image.""" 198 | # Ignore clicks if the program is locked. 199 | if not self._locked() and button == mouse.Button.LEFT: 200 | hits = [info for info in self._buttons if 201 | info[0].get_rect().move(info[1]).collidepoint(pos)] 202 | if hits: 203 | # Mark the image as having been selected. 204 | hits[0][3] = True 205 | 206 | # Check if we've selected 3 images now. 207 | guessed = [item for _, _, item, clicked in self._buttons if 208 | clicked] 209 | correct = [item for _, _, item, clicked in self._buttons if 210 | clicked and item in self._user_info] 211 | if len(guessed) is 3: 212 | if len(correct) == len(guessed): 213 | self._completed = True 214 | else: 215 | self._lock_time = self._terminal.time 216 | self._pick_images() 217 | 218 | def completed(self): 219 | """Indicate whether the program was successfully completed.""" 220 | return self._completed 221 | 222 | def exited(self): 223 | """Indicate whether the program has exited.""" 224 | return self.completed() 225 | -------------------------------------------------------------------------------- /programs/minehunt.py: -------------------------------------------------------------------------------- 1 | 2 | import itertools 3 | import pygame 4 | import random 5 | from enum import Enum, unique 6 | 7 | import mouse 8 | from . import program 9 | from resources import load_font 10 | 11 | 12 | class MineHunt(program.TerminalProgram): 13 | 14 | """Minehunt program.""" 15 | 16 | """The properties of this program.""" 17 | PROPERTIES = program.ProgramProperties(is_graphical=True) 18 | 19 | _TIMER_Y = 50 20 | _BOARD_Y = 85 21 | _BOARD_MAX_WIDTH = 600 22 | _BOARD_MAX_HEIGHT = 400 23 | _STATUS_FONT_SIZE = 20 24 | _END_FONT_SIZE = 25 25 | _TIMER_FONT_SIZE = 30 26 | _FONT = 'media/fonts/Sansation_Regular.ttf' 27 | 28 | def __init__(self, terminal): 29 | """Initialize the class.""" 30 | super().__init__(terminal) 31 | 32 | self._puzzle = random.choice(Puzzle.puzzles) 33 | 34 | self._completed = False 35 | self._exited = False 36 | self._board = Board(self._puzzle.board_def, 37 | self._BOARD_MAX_WIDTH, self._BOARD_MAX_HEIGHT) 38 | self._start_time = None 39 | self._time_secs = None 40 | 41 | screen_rect = pygame.display.get_surface().get_rect() 42 | self._board_pos = (int((screen_rect[2] / 2) - (self._board.width / 2)), 43 | self._BOARD_Y) 44 | 45 | self._status_font = load_font(self._FONT, self._STATUS_FONT_SIZE) 46 | self._timer_font = load_font(self._FONT, self._TIMER_FONT_SIZE) 47 | end_font = load_font(self._FONT, self._END_FONT_SIZE) 48 | self._game_over_texts = [ 49 | end_font.render("Game over!!", True, (255, 255, 255)), 50 | end_font.render("Press R to retry, or Q to quit", 51 | True, (255, 255, 255)), 52 | ] 53 | self._game_won_texts = [ 54 | end_font.render("Game completed!!", True, (255, 255, 255)), 55 | end_font.render("Press R to retry, or Q to quit", 56 | True, (255, 255, 255)), 57 | ] 58 | 59 | @property 60 | def help(self): 61 | """Get the help string for the program.""" 62 | return "Play minehunt!" 63 | 64 | @property 65 | def security_type(self): 66 | """Get the security type for the program.""" 67 | return "user permissions" 68 | 69 | @property 70 | def success_syslog(self): 71 | return "{} user has been promoted to root".format( 72 | self.SUCCESS_PREFIX) 73 | 74 | def start(self): 75 | # Reset board 76 | self._board.reset() 77 | self._exited = False 78 | self._start_time = self._terminal.time 79 | self._time_secs = 0 80 | 81 | def completed(self): 82 | """Indicate whether the program was completed.""" 83 | return self._completed 84 | 85 | def exited(self): 86 | """Indicate whether the program has exited.""" 87 | return self.completed() or self._exited 88 | 89 | def on_keypress(self, key, key_unicode): 90 | """Handle a user keypress.""" 91 | if self._board.state != Board.State.PLAYING: 92 | if key == pygame.K_r: 93 | self.start() 94 | elif key == pygame.K_q: 95 | self._exited = True 96 | 97 | def on_mousemove(self, pos): 98 | pass 99 | 100 | def on_mouseclick(self, button, pos): 101 | """Detect whether the user clicked the program.""" 102 | board_pos = (pos[0] - self._board_pos[0], pos[1] - self._board_pos[1]) 103 | self._board.on_mouseclick(button, board_pos) 104 | 105 | # Have we reached the program complete condition? 106 | self._check_completed() 107 | 108 | def run(self): 109 | # Get time passed if we are still playing 110 | if self._board.state == Board.State.PLAYING: 111 | time_passed = self._terminal.time - self._start_time 112 | self._time_secs = int(time_passed / 1000) 113 | 114 | def draw(self): 115 | """Draw the program.""" 116 | screen = pygame.display.get_surface() 117 | screen.blit(self._board.draw_surface, 118 | self._board_pos) 119 | screen_rect = screen.get_rect() 120 | 121 | # Draw timer 122 | text = self._timer_font.render("Time: {}" 123 | .format(self._time_secs), 124 | True, (255, 255, 255)) 125 | screen.blit(text, (self._board_pos[0], self._TIMER_Y)) 126 | 127 | # Have we hit a mine? Draw game over text 128 | # TODO: have a game surface and draw this on 129 | if self._board.state != Board.State.PLAYING: 130 | # Dim the board 131 | dim = pygame.Surface((self._board.width, self._board.height), 132 | flags=pygame.SRCALPHA) 133 | if self._board.state == Board.State.CLEARED: 134 | dim.fill((255, 100, 255, 0)) 135 | else: 136 | dim.fill((100, 255, 255, 0)) 137 | screen.blit(dim, self._board_pos, 138 | special_flags=pygame.BLEND_RGBA_SUB) 139 | 140 | # Get the end game texts 141 | texts = (self._game_won_texts 142 | if self._board.state == Board.State.CLEARED else 143 | self._game_over_texts) 144 | text_y = self._board_pos[1] + self._board.height + 5 145 | 146 | # Draw text 147 | for text in texts: 148 | text_rect = text.get_rect() 149 | text_x = int(screen_rect[2] / 2 - text_rect[2] / 2) 150 | screen.blit(text, (text_x, text_y)) 151 | 152 | text_y += text_rect[3] 153 | 154 | else: 155 | # Draw mines found text 156 | text = self._status_font.render("Mines flagged: {} / {}" 157 | .format(self._board.flag_count, 158 | self._board.mine_count), 159 | True, (255, 255, 255)) 160 | text_x = int(screen_rect[2] / 2 - text.get_rect()[2] / 2) 161 | text_y = self._board_pos[1] + self._board.height + 5 162 | screen.blit(text, (text_x, text_y)) 163 | 164 | def _check_completed(self): 165 | # Did the user click with the correct time remaining? 166 | if self._puzzle.time_condition == Puzzle.Time.ANY: 167 | success = True 168 | elif self._puzzle.time_condition == Puzzle.Time.ODD: 169 | success = (self._time_secs % 2) == 1 170 | elif self._puzzle.time_condition == Puzzle.Time.EVEN: 171 | success = (self._time_secs % 2) == 0 172 | else: 173 | assert False, "Unexpected condition {}".format( 174 | self._puzzle.time_condition) 175 | 176 | # Have all mines been flagged, and if there is a mine to be clicked, 177 | # has it been clicked? 178 | if success: 179 | for loc, square in self._board.mines: 180 | # Do we have a victory mine, if so this mine should be 181 | # clicked and not flagged. 182 | # 183 | # If not, then the mine should be flagged. 184 | victory_mine = (self._puzzle.click_mine is not None and 185 | loc == self._puzzle.click_mine) 186 | if victory_mine and square.state != Square.State.REVEALED: 187 | success = False 188 | elif (not victory_mine and 189 | square.state != Square.State.FLAGGED): 190 | success = False 191 | 192 | # Have only mines been flagged? 193 | if success: 194 | for loc, square in self._board.flags: 195 | if square.type != Square.Type.MINE: 196 | success = False 197 | 198 | # Display crash message if success 199 | if success: 200 | # TODO: Better crash dump message 201 | self._terminal.output(["CRASH ALERT: minehunt crashed -- " 202 | "segfault at address 0x445d9ee9"]) 203 | 204 | self._completed = success 205 | 206 | 207 | class Board: 208 | @unique 209 | class State(Enum): 210 | PLAYING = 1 211 | MINE_HIT = 2 212 | CLEARED = 3 213 | 214 | def __init__(self, board_def, max_width, max_height): 215 | # Board is a 2D array of Squares, indexed by row then col 216 | self._board = [] 217 | 218 | # Number of rows and cols 219 | self._rows = 0 220 | self._cols = 0 221 | 222 | # Square size 223 | self._square_size = 0 224 | 225 | # Surface for the next draw 226 | self.draw_surface = None 227 | 228 | # Board state 229 | self.state = Board.State.PLAYING 230 | 231 | # Total mine count 232 | self.mine_count = 0 233 | 234 | # Create the board 235 | self._create_board(board_def, max_width, max_height) 236 | 237 | # Set width and height 238 | self.width = self._cols * self._square_size 239 | self.height = self._rows * self._square_size 240 | 241 | # Create the board 242 | self._surface = pygame.Surface((self.width, self.height)) 243 | self._surface.fill((255, 255, 255)) 244 | 245 | self._setup_draw() 246 | 247 | @property 248 | def flag_count(self): 249 | return len([s for s in itertools.chain.from_iterable(self._board) 250 | if s.state == Square.State.FLAGGED]) 251 | 252 | @property 253 | def mines(self): 254 | for row in range(self._rows): 255 | for col in range(self._cols): 256 | square = self._board[row][col] 257 | if square.type == Square.Type.MINE: 258 | yield ((row, col), square) 259 | 260 | @property 261 | def flags(self): 262 | for row in range(self._rows): 263 | for col in range(self._cols): 264 | square = self._board[row][col] 265 | if square.type == Square.State.FLAGGED: 266 | yield ((row, col), square) 267 | 268 | def reset(self): 269 | self.state = Board.State.PLAYING 270 | for square in itertools.chain.from_iterable(self._board): 271 | square.state = Square.State.HIDDEN 272 | self._setup_draw() 273 | 274 | def on_mouseclick(self, button, pos): 275 | if self.state != Board.State.PLAYING: 276 | return 277 | 278 | square = self._hit_square(pos) 279 | if square is not None: 280 | if (button == mouse.Button.LEFT and 281 | square.state == Square.State.HIDDEN): 282 | self._reveal_square(square) 283 | self._setup_draw() 284 | elif (button == mouse.Button.RIGHT and 285 | square.state == Square.State.HIDDEN): 286 | square.state = Square.State.FLAGGED 287 | self._setup_draw() 288 | elif (button == mouse.Button.RIGHT and 289 | square.state == Square.State.FLAGGED): 290 | square.state = Square.State.HIDDEN 291 | self._setup_draw() 292 | 293 | # Game is over when mines have been flagged and all squares 294 | # revealed 295 | if len([ 296 | s for s in itertools.chain.from_iterable(self._board) 297 | if s.state == Square.State.HIDDEN or 298 | (s.state == Square.State.FLAGGED and 299 | s.type == Square.Type.EMPTY) or 300 | (s.state != Square.State.FLAGGED and 301 | s.type == Square.Type.MINE)]) == 0: 302 | self.state = Board.State.CLEARED 303 | 304 | def _reveal_square(self, square): 305 | # Set for this square, and if it doesn't have any mine neighbours, 306 | # reveal them! 307 | if (square.state == Square.State.HIDDEN and 308 | square.type == Square.Type.EMPTY): 309 | square.state = Square.State.REVEALED 310 | if square.mines_nearby == 0: 311 | for neighbour in square.neighbours: 312 | self._reveal_square(neighbour) 313 | elif (square.state == Square.State.HIDDEN and 314 | square.type == Square.Type.MINE): 315 | # We have revealed a Mine! 316 | square.state = Square.State.REVEALED 317 | self.state = Board.State.MINE_HIT 318 | 319 | def _create_board(self, board_def, max_width, max_height): 320 | self._rows = len(board_def) 321 | self._cols = len(board_def[0]) 322 | 323 | # Work out square size 324 | self._square_size = int(min(max_width / self._cols, 325 | max_height / self._rows)) 326 | 327 | def get_type(c): 328 | return (Square.Type.MINE if c == Puzzle.MINE_CHAR 329 | else Square.Type.EMPTY) 330 | 331 | def get_rect(r, c): 332 | return (c * self._square_size, 333 | r * self._square_size, 334 | self._square_size, self._square_size) 335 | 336 | for row, line in enumerate(board_def): 337 | self._board.append([Square(get_type(c), get_rect(row, col)) 338 | for col, c in enumerate(line)]) 339 | 340 | # Now calculate neighbours and mines 341 | for row in range(self._rows): 342 | for col in range(self._cols): 343 | neighbours = [] 344 | for offset in itertools.product((-1, 0, 1), repeat=2): 345 | neighbour = (row + offset[0], col + offset[1]) 346 | if (neighbour != (row, col) and 347 | 0 <= neighbour[0] < self._rows and 348 | 0 <= neighbour[1] < self._cols): 349 | neighbours.append( 350 | self._board[neighbour[0]][neighbour[1]]) 351 | square = self._board[row][col] 352 | square.set_neighbours(neighbours) 353 | if square.type == Square.Type.MINE: 354 | self.mine_count += 1 355 | 356 | def _hit_square(self, pos): 357 | for square in itertools.chain.from_iterable(self._board): 358 | if square.collidepoint(pos): 359 | return square 360 | 361 | return None 362 | 363 | def _setup_draw(self): 364 | self.draw_surface = self._surface.copy() 365 | 366 | for square in itertools.chain.from_iterable(self._board): 367 | self.draw_surface.blit(square.get_surface(), 368 | square.rect[:2]) 369 | 370 | 371 | class Square: 372 | 373 | """Class representing a square on the board.""" 374 | 375 | @unique 376 | class Type(Enum): 377 | EMPTY = 1 378 | MINE = 2 379 | 380 | @unique 381 | class State(Enum): 382 | HIDDEN = 1 383 | FLAGGED = 2 384 | REVEALED = 3 385 | 386 | _FLAG_HEIGHT_FACTOR = 0.6 387 | _FLAG_POLE_WIDTH = 3 388 | _FLAG_SIZE_FACTOR = 0.6 389 | _MINE_SCALE_FACTOR = 0.8 390 | _FONT = 'media/fonts/whitrabt.ttf' 391 | _FONT_SCALE = 0.6 392 | 393 | def __init__(self, square_type, rect): 394 | # Square type 395 | self.type = square_type 396 | 397 | # Current state 398 | self.state = self.State.HIDDEN 399 | 400 | # Rectangle representing the square's position on the board. 401 | self.rect = rect 402 | 403 | # List of neighbour squares 404 | self.neighbours = None 405 | 406 | # Count of neighbours who are mines 407 | self.mines_nearby = 0 408 | 409 | # Create our surfaces 410 | self._surfaces = { 411 | Square.State.HIDDEN: pygame.Surface(self.rect[2:]), 412 | Square.State.FLAGGED: pygame.Surface(self.rect[2:]), 413 | Square.State.REVEALED: pygame.Surface(self.rect[2:]), 414 | } 415 | 416 | self._surfaces[Square.State.HIDDEN].fill((180, 180, 180)) 417 | self._surfaces[Square.State.FLAGGED].fill((180, 180, 180)) 418 | self._surfaces[Square.State.REVEALED].fill((255, 255, 255)) 419 | 420 | for surface in self._surfaces.values(): 421 | pygame.draw.rect(surface, (0, 0, 0), 422 | (0, 0, self.rect[2], self.rect[3]), 1) 423 | 424 | # If we are a mine, then add mine to revealed square 425 | if self.type == Square.Type.MINE: 426 | center = (int(self.rect[2] / 2), int(self.rect[3] / 2)) 427 | pygame.draw.circle(self._surfaces[Square.State.REVEALED], 428 | (0, 0, 0), 429 | center, 430 | int(self._MINE_SCALE_FACTOR * center[0]), 431 | 0) 432 | 433 | # Add a flag to the flagged square 434 | flag = self._surfaces[Square.State.FLAGGED] 435 | pole_length = int(self.rect[3] * self._FLAG_HEIGHT_FACTOR) 436 | flag_size = int(pole_length * self._FLAG_SIZE_FACTOR) 437 | pole_gap = int((self.rect[3] - pole_length) / 2) 438 | x_coord = int(self.rect[2] / 2 - flag_size / 2 + 439 | self._FLAG_POLE_WIDTH / 2) 440 | pygame.draw.line(flag, (0, 0, 0), 441 | (x_coord, pole_gap), 442 | (x_coord, self.rect[3] - pole_gap), 443 | self._FLAG_POLE_WIDTH) 444 | pygame.draw.rect(flag, (255, 20, 20), 445 | ((x_coord, pole_gap, 446 | flag_size, int(flag_size * 0.9))), 447 | 0) 448 | 449 | def get_surface(self): 450 | return self._surfaces[self.state] 451 | 452 | def collidepoint(self, board_pos): 453 | return pygame.Rect(self.rect).collidepoint(board_pos) 454 | 455 | def set_neighbours(self, neighbours): 456 | self.neighbours = neighbours 457 | 458 | # Count mines! 459 | self.mines_nearby = len([n for n in neighbours 460 | if n.type == Square.Type.MINE]) 461 | 462 | # Draw the number on our revealed surface 463 | if self.mines_nearby > 0: 464 | font = load_font(self._FONT, int(self.rect[2] * self._FONT_SCALE)) 465 | text = font.render(str(self.mines_nearby), True, (0, 0, 0)) 466 | 467 | surface = self._surfaces[Square.State.REVEALED] 468 | surface_rect = surface.get_rect() 469 | text_rect = text.get_rect() 470 | surface.blit(text, 471 | (int(surface_rect[2] / 2 - text_rect[2] / 2), 472 | int(surface_rect[3] / 2 - text_rect[3] / 2))) 473 | 474 | 475 | class Puzzle: 476 | puzzles = [] 477 | 478 | MINE_CHAR = "o" 479 | EMPTY_CHAR = "." 480 | 481 | _CLICK_CHAR = "x" 482 | _IGNORE_CHAR = " " 483 | 484 | @unique 485 | class Time(Enum): 486 | ANY = 1 487 | ODD = 2 488 | EVEN = 3 489 | 490 | def __init__(self, board_str, time_condition, mine_count): 491 | self.time_condition = time_condition 492 | 493 | # Parse the board 494 | lines = [l for l in board_str.split("\n") if len(l) > 0] 495 | self.board_def = [[c for c in line if c != self._IGNORE_CHAR] 496 | for line in lines] 497 | 498 | # See if there is a mine that has to be clicked (represented by o) 499 | self.click_mine = self._find_click_mine() 500 | 501 | # Make sure we have a mine to click (can drop this if we decide to 502 | # vary puzzles later) 503 | assert self.click_mine is not None, \ 504 | "No mine to click in puzzle: {}".format(board_str) 505 | 506 | # Check the number of mines are correct 507 | defined_count = len( 508 | [s for s in itertools.chain.from_iterable(self.board_def) 509 | if s == Puzzle.MINE_CHAR]) 510 | assert defined_count == mine_count, \ 511 | "Incorrect mine count: expected {}, actual {}".format( 512 | mine_count, defined_count) 513 | 514 | # Add to global puzzle list 515 | Puzzle.puzzles.append(self) 516 | 517 | def _find_click_mine(self): 518 | for row in range(len(self.board_def)): 519 | for col in range(len(self.board_def[row])): 520 | if self.board_def[row][col] == self._CLICK_CHAR: 521 | # Change to mine character 522 | self.board_def[row][col] = Puzzle.MINE_CHAR 523 | return row, col 524 | return None 525 | 526 | 527 | # 528 | # 8 rows x 10 cols boards 529 | # 530 | Puzzle( 531 | """ 532 | . . . . . o . . . . 533 | o . . . . . . . . . 534 | . . . . . . . . . o 535 | . . . o . . . . . . 536 | . . . . . . . . . . 537 | o x . . . . . o . . 538 | . . . . . . . . . . 539 | . . . . . . . . o . 540 | """, 541 | Puzzle.Time.ODD, 542 | mine_count=8) 543 | 544 | Puzzle(""" 545 | . . . . . o . . . . 546 | o o . . . . . . . . 547 | . . . . . . . . . . 548 | . . . o . . . . . . 549 | . . . . . . . . o . 550 | o o . . . . . . x . 551 | . . . . . . . . . . 552 | . . . . . . . . . o 553 | """, 554 | Puzzle.Time.EVEN, 555 | mine_count=9) 556 | 557 | 558 | Puzzle(""" 559 | . o . . . o . . . . 560 | o o . . . . . . . . 561 | . . . . . . . . . . 562 | . . . o . . . . . . 563 | . . . . . . . . . . 564 | o x . . . . . . o . 565 | . . . . . . . . . . 566 | . o . . o . . . . . 567 | """, 568 | Puzzle.Time.ANY, 569 | mine_count=10) 570 | 571 | # 572 | # 8 rows x 9 cols boards 573 | # 574 | Puzzle( 575 | """ 576 | o . . . . o . . . 577 | . . . . . . . . . 578 | . . . . . . . . . 579 | . . . x . . . . . 580 | . . . . . . . . . 581 | o o . . . . . o . 582 | . . . . . . . . . 583 | . . . . . . . . o 584 | """, 585 | Puzzle.Time.EVEN, 586 | mine_count=7) 587 | 588 | Puzzle( 589 | """ 590 | o . . . . o . . . 591 | o . . . . . . . . 592 | . . . . . . . x o 593 | . . . o . . . . . 594 | . . . . . . . . . 595 | o o . . . . . o . 596 | . . . . . . . . . 597 | . o . . . . . . o 598 | """, 599 | Puzzle.Time.ODD, 600 | mine_count=11) 601 | 602 | Puzzle( 603 | """ 604 | o . . . . o . . . 605 | . . . . . . . . . 606 | . . . . . . . . . 607 | . . . o . . . . . 608 | . . . . . . . . . 609 | o x . . . . . o . 610 | o o . . . . . . . 611 | . . . . . . . . o 612 | """, 613 | Puzzle.Time.ANY, 614 | mine_count=9) 615 | 616 | -------------------------------------------------------------------------------- /programs/network.py: -------------------------------------------------------------------------------- 1 | """Network manager program classes.""" 2 | 3 | import pygame 4 | import random 5 | 6 | from . import program 7 | 8 | PUZZLE1 = (""" 9 | S . . . x x 10 | x . . . x x 11 | x x G x x x 12 | . . . . G . 13 | G . . . . . 14 | x x x x x D 15 | """, 16 | "192.168.1.15", 17 | "10.0.0.1") 18 | 19 | PUZZLE2 = (""" 20 | S . . G x x 21 | . . . . x x 22 | . x . . x x 23 | . . x . G . 24 | G . . x . . 25 | x x . . . D 26 | """, 27 | "192.168.1.15", 28 | "11.0.0.1") 29 | 30 | PUZZLE3 = (""" 31 | S . . G x x 32 | . . . . . x 33 | . x . . G x 34 | . . x . x . 35 | G . . . . . 36 | x x . . . D 37 | """, 38 | "192.168.1.50", 39 | "11.0.0.1") 40 | 41 | PUZZLE4 = (""" 42 | . x D x x . 43 | . x . . x . 44 | x x x . x x 45 | . . . . . . 46 | . x x x x . 47 | . . . S . G 48 | """, 49 | "192.168.1.100", 50 | "12.0.0.1") 51 | 52 | PUZZLE5 = (""" 53 | . x x D x . 54 | . x . . x . 55 | x x . x x x 56 | . . . . . . 57 | . x x x x . 58 | G . S . . . 59 | """, 60 | "192.168.1.100", 61 | "12.0.0.2") 62 | 63 | PUZZLE6 = (""" 64 | D . . . x x 65 | . x x . x x 66 | . x x G x x 67 | . . . . . x 68 | x . x x . x 69 | x G . . . S 70 | """, 71 | "10.10.10.11", 72 | "10.10.10.7") 73 | 74 | """List of available puzzles, to be chosen at random.""" 75 | PUZZLES = (PUZZLE1, PUZZLE2, PUZZLE3, PUZZLE4, PUZZLE5) 76 | 77 | 78 | class NetworkManager(program.TerminalProgram): 79 | 80 | """The network manager program.""" 81 | 82 | _GRID_WIDTH = 10 83 | _GRID_HEIGHT = 10 84 | 85 | _START_NODE = "S" 86 | _END_NODE = "D" 87 | 88 | _NODE = "o" 89 | _NODE_OFF = "." 90 | 91 | _LINK_H = ".." 92 | _LINK_V = ":" 93 | 94 | _SPACE_H = " " 95 | _SPACE_V = " " 96 | 97 | _ON_MS = 800 98 | _OFF_MS = 600 99 | 100 | _REVERT_LINK_TIME = 200 101 | _ERROR_INITIAL_WAIT = 2000 102 | 103 | """The properties of this program.""" 104 | PROPERTIES = program.ProgramProperties(intercept_keypress=True, 105 | alternate_buf=True, 106 | hide_cursor=True) 107 | 108 | def __init__(self, terminal): 109 | """Initialize the class.""" 110 | super().__init__(terminal) 111 | 112 | self._completed = False 113 | self._exited = False 114 | 115 | # Select the puzzle 116 | puzzle = random.choice(PUZZLES) 117 | 118 | # Track the nodes the user has visited, and which node it was visited 119 | # from. This allows us to build up the set of links the user has 120 | # entered. 121 | self._visited_from = {} 122 | 123 | # IP details 124 | self._source_ip = puzzle[1] 125 | self._dest_ip = puzzle[2] 126 | 127 | # Current location (set to start location in start) 128 | self._curr = (0, 0) 129 | 130 | # Has an error occurred? 131 | self._error_mode = False 132 | 133 | # When was error mode started? 134 | self._error_mode_start = None 135 | 136 | # Time that last link was removed on error 137 | self._last_revert_time = None 138 | 139 | # Reason for being in error mode 140 | self._error_msg = None 141 | 142 | # Parser the puzzle and solution 143 | self._puzzle = PuzzleParser(puzzle[0]) 144 | 145 | @property 146 | def allow_ctrl_c(self): 147 | return not self._error_mode 148 | 149 | @property 150 | def help(self): 151 | """Get the help string for the program.""" 152 | return "Manage network connectivity." 153 | 154 | @property 155 | def security_type(self): 156 | """Get the security type for the program.""" 157 | return "network access" 158 | 159 | @property 160 | def success_syslog(self): 161 | return "{} network connectivity to server established".format( 162 | self.SUCCESS_PREFIX) 163 | 164 | @property 165 | def buf(self): 166 | lines = ["-------------------------------", 167 | "Simple Network Manager", 168 | " Networking made easy!", 169 | "-------------------------------", 170 | "", 171 | "Source IP: {}".format(self._source_ip), 172 | "Destination IP: {}".format(self._dest_ip), 173 | "", 174 | "", 175 | "Network map:", 176 | ""] 177 | 178 | # If error mode, start reversing the path 179 | if self._error_mode: 180 | if self._last_revert_time is None: 181 | last_time, delay = self._error_mode_start, \ 182 | self._ERROR_INITIAL_WAIT 183 | else: 184 | last_time, delay = self._last_revert_time, \ 185 | self._REVERT_LINK_TIME 186 | if last_time + delay < self._terminal.time: 187 | # Find where we came from 188 | from_node = self._visited_from[self._curr] 189 | 190 | # Remove link 191 | del self._visited_from[self._curr] 192 | 193 | # Update position. If we have reached None, then start again 194 | if from_node is None: 195 | self.start() 196 | else: 197 | self._curr = from_node 198 | self._last_revert_time = self._terminal.time 199 | 200 | is_on = (self._error_mode or 201 | self._terminal.time % (self._ON_MS + self._OFF_MS) < 202 | self._ON_MS) 203 | 204 | # Draw the grid 205 | for r in range(self._puzzle.rows): 206 | line = "" 207 | for c in range(self._puzzle.cols): 208 | # See whether we need to draw a link to previous node 209 | if c > 0: 210 | if self._has_connection((r, c), (r, c - 1)): 211 | line += self._LINK_H 212 | else: 213 | line += self._SPACE_H 214 | 215 | # Add character - remembering to make current location flash 216 | if (r, c) == self._curr and not is_on: 217 | line += self._NODE_OFF 218 | elif (r, c) == self._puzzle.start: 219 | line += self._START_NODE 220 | elif (r, c) == self._puzzle.end: 221 | line += self._END_NODE 222 | else: 223 | line += self._NODE 224 | if self._error_mode: 225 | lines.append("" + line) 226 | else: 227 | lines.append("" + line) 228 | 229 | # Now create gap between the row, drawing links to next row 230 | if r < self._puzzle.rows - 1: 231 | line = "" 232 | for c in range(self._puzzle.cols): 233 | if c > 0: 234 | line += self._SPACE_H 235 | 236 | if self._has_connection((r, c), (r + 1, c)): 237 | line += self._LINK_V 238 | else: 239 | line += self._SPACE_V 240 | 241 | if self._error_mode: 242 | lines.append("" + line) 243 | else: 244 | lines.append("" + line) 245 | 246 | lines.append("") 247 | if self._error_mode: 248 | lines.append("Invalid route detected: {}" 249 | .format(self._error_msg)) 250 | else: 251 | lines.append("Use arrow keys to create a static route from source " 252 | "to dest.") 253 | 254 | return reversed(lines) 255 | 256 | def start(self): 257 | # Reset board 258 | self._visited_from = {} 259 | self._exited = False 260 | self._curr = self._puzzle.start 261 | 262 | # Mark the start node as visited 263 | self._visited_from[self._curr] = None 264 | 265 | # Make sure error mode is turned off 266 | self._error_mode = False 267 | self._last_revert_time = None 268 | 269 | def completed(self): 270 | """Indicate whether the program was completed.""" 271 | return self._completed 272 | 273 | def exited(self): 274 | """Indicate whether the program has exited.""" 275 | return self.completed() or self._exited 276 | 277 | def on_keypress(self, key, key_unicode): 278 | """Handle a user keypress (used for INTERACTIVE and GRAPHICAL).""" 279 | # Ignore if in error mode 280 | if self._error_mode: 281 | return 282 | 283 | new_curr = None 284 | if key == pygame.K_UP: 285 | new_curr = (self._curr[0] - 1, self._curr[1]) 286 | elif key == pygame.K_DOWN: 287 | new_curr = (self._curr[0] + 1, self._curr[1]) 288 | elif key == pygame.K_RIGHT: 289 | new_curr = (self._curr[0], self._curr[1] + 1) 290 | elif key == pygame.K_LEFT: 291 | new_curr = (self._curr[0], self._curr[1] - 1) 292 | 293 | # Move to this location if we can 294 | if (new_curr is not None and 295 | 0 <= new_curr[0] < self._puzzle.rows and 296 | 0 <= new_curr[1] < self._puzzle.cols and 297 | new_curr not in self._visited_from): 298 | self._visited_from[new_curr] = self._curr 299 | self._curr = new_curr 300 | 301 | # Was this a valid node? 302 | if new_curr in self._puzzle.bad_nodes: 303 | self._enable_error_mode("attempted to use a down node") 304 | 305 | # If we have reached the destination, check we have used every gateway 306 | if not self._error_mode and self._curr == self._puzzle.end: 307 | missing = [g for g in self._puzzle.gateway_nodes 308 | if g not in self._visited_from] 309 | if len(missing) > 0: 310 | self._enable_error_mode("missing gateway node") 311 | else: 312 | self._completed = True 313 | 314 | def _has_connection(self, node1, node2): 315 | if node1 in self._visited_from and self._visited_from[node1] == node2: 316 | return True 317 | elif node2 in self._visited_from and self._visited_from[node2] == node1: 318 | return True 319 | else: 320 | return False 321 | 322 | def _enable_error_mode(self, msg): 323 | self._error_mode = True 324 | self._error_msg = msg 325 | self._error_mode_start = self._terminal.time 326 | 327 | 328 | class PuzzleParser: 329 | 330 | """Class to parse the puzzle solution from an ascii representation.""" 331 | 332 | _SPACE_CHAR = " " 333 | _NODE_CHAR = "." 334 | _GATEWAY_CHAR = "G" 335 | _AVOID_CHAR = "x" 336 | _START_CHAR = "S" 337 | _END_CHAR = "D" 338 | 339 | def __init__(self, puzzle_data): 340 | self.cols = 0 341 | self.rows = 0 342 | self.start = (0, 0) 343 | self.end = (0, 0) 344 | 345 | # Gateway nodes; these must be included in solution 346 | self.gateway_nodes = [] 347 | 348 | # Bad nodes; these must be avoided 349 | self.bad_nodes = [] 350 | 351 | for line in puzzle_data.split("\n"): 352 | # Ignore empty lines 353 | if not line: 354 | continue 355 | self.rows += 1 356 | self._parse_node_row(line) 357 | 358 | def _parse_node_row(self, line): 359 | cols = 0 360 | for idx, char in enumerate(line): 361 | if char == self._NODE_CHAR: 362 | cols += 1 363 | elif char == self._START_CHAR: 364 | cols += 1 365 | self.start = (self.rows - 1, cols - 1) 366 | elif char == self._END_CHAR: 367 | cols += 1 368 | self.end = (self.rows - 1, cols - 1) 369 | elif char == self._GATEWAY_CHAR: 370 | cols += 1 371 | self.gateway_nodes.append((self.rows - 1, cols - 1)) 372 | elif char == self._AVOID_CHAR: 373 | cols += 1 374 | self.bad_nodes.append((self.rows - 1, cols - 1)) 375 | elif char != self._SPACE_CHAR: 376 | raise Exception("Unknown char {} in row {}" 377 | .format(char, self.rows - 1)) 378 | 379 | # Make sure cols agrees with global 380 | if self.cols == 0: 381 | self.cols = cols 382 | elif self.cols != cols: 383 | raise Exception("Row {} has cols {} != {}" 384 | .format(self.rows - 1, cols, self.cols)) 385 | -------------------------------------------------------------------------------- /programs/password.py: -------------------------------------------------------------------------------- 1 | """Password program classes.""" 2 | 3 | import random 4 | from . import program 5 | 6 | 7 | class PasswordGuess(program.TerminalProgram): 8 | 9 | """Class for a password-guessing program.""" 10 | 11 | _MAX_GUESSES = 5 12 | _PASSWORDS = { 13 | 'root': ['flask', 'great', 'force', 'gleam', 'brick', 'flute', 'blash', 14 | 'feast', 'flick', 'flank'], 15 | 'ro0t': ['tusks', 'blush', 'askew', 'train', 'asset', 'burns', 'tries', 16 | 'turns', 'basks', 'busks'], 17 | 'rewt': ['maple', 'pearl', 'lapel', 'myths', 'cycle', 'apple', 'ladle', 18 | 'ample', 'maize', 'capel'], 19 | '00142331': ['trice', 'racer', 'tours', 'glaze', 'trail', 'raise', 20 | 'slick', 'track', 'grace', 'trace'], 21 | '00143231': ['court', 'truce', 'fords', 'flirt', 'cruel', 'craft', 22 | 'tours', 'chart', 'fours', 'count'], 23 | '01043231': ['eagle', 'ariel', 'glare', 'gains', 'earns', 'gauge', 24 | 'angle', 'early', 'agile', 'engle'] 25 | } 26 | 27 | def __init__(self, terminal): 28 | """Initialize the class.""" 29 | self._guesses = 0 30 | self._guessed = False 31 | self._aborted = False 32 | 33 | # Pick a user 34 | self._user = random.choice(list(PasswordGuess._PASSWORDS.keys())) 35 | self._password = random.choice(PasswordGuess._PASSWORDS[self._user]) 36 | 37 | super().__init__(terminal) 38 | 39 | @property 40 | def help(self): 41 | """Return the help string for the program.""" 42 | return "Run main login program." 43 | 44 | @property 45 | def security_type(self): 46 | """Return the security type for the program.""" 47 | return "password protection" 48 | 49 | def _get_prompt(self): 50 | """Get the prompt string.""" 51 | return "Enter password for user '{}' ({} attempts remaining)".format( 52 | self._user, 53 | PasswordGuess._MAX_GUESSES - self._guesses) 54 | 55 | def start(self): 56 | """Start the program.""" 57 | # Don't reset guesses if we are restarting after an abort 58 | if self._aborted: 59 | self._aborted = False 60 | else: 61 | self._guesses = 0 62 | 63 | # Pick a new password for the current user. 64 | self._password = random.choice(PasswordGuess._PASSWORDS[self._user]) 65 | 66 | self._terminal.output([self._get_prompt()]) 67 | 68 | def text_input(self, line): 69 | """Check a password guess.""" 70 | correct = 0 71 | for c in zip(line, self._password): 72 | if c[0] == c[1]: 73 | correct += 1 74 | 75 | if correct == len(self._password): 76 | self._terminal.output(['Password accepted']) 77 | self._guessed = True 78 | else: 79 | self._guesses += 1 80 | 81 | if self._guesses == PasswordGuess._MAX_GUESSES: 82 | self._terminal.output([ 83 | 'Max retries reached - password reset!']) 84 | else: 85 | self._terminal.output([ 86 | 'Incorrect password. {} of {} characters correct'.format( 87 | correct, len(self._password)), 88 | self._get_prompt()]) 89 | 90 | def on_abort(self): 91 | """Handle a ctrl+c from user.""" 92 | self._aborted = True 93 | 94 | def completed(self): 95 | """Indicate whether the user has guessed the password.""" 96 | return self._guessed 97 | 98 | def exited(self): 99 | """Indicate whether the current instance has exited.""" 100 | return self.completed() or self._guesses == PasswordGuess._MAX_GUESSES 101 | -------------------------------------------------------------------------------- /programs/program.py: -------------------------------------------------------------------------------- 1 | """Base class definitions for use by programs.""" 2 | 3 | 4 | class BadInput(Exception): 5 | 6 | """Exception raised by a program receiving bad input.""" 7 | 8 | pass 9 | 10 | 11 | class ProgramProperties: 12 | 13 | """Class that indicates the program properties""" 14 | 15 | def __init__(self, 16 | intercept_keypress=False, 17 | alternate_buf=False, 18 | hide_cursor=False, 19 | suppress_success=False, 20 | is_graphical=False, 21 | skip_bezel=False): 22 | 23 | # If graphical, then set correct properties 24 | if is_graphical: 25 | intercept_keypress = True 26 | alternate_buf = False 27 | hide_cursor = True 28 | 29 | self.intercept_keypress = intercept_keypress 30 | self.alternate_buf = alternate_buf 31 | self.hide_cursor = hide_cursor 32 | self.suppress_success = suppress_success 33 | self.is_graphical = is_graphical 34 | self.skip_bezel = skip_bezel 35 | 36 | 37 | class TerminalProgram: 38 | 39 | """Base class for terminal programs.""" 40 | 41 | """The properties of this program.""" 42 | PROPERTIES = ProgramProperties() 43 | 44 | SUCCESS_PREFIX = "SYSTEM INFO:" 45 | 46 | def __init__(self, terminal): 47 | """Initialize the class.""" 48 | self._terminal = terminal 49 | 50 | @property 51 | def allow_ctrl_c(self): 52 | """Indicate whether ctrl-c is allowed to cancel the program.""" 53 | return True 54 | 55 | @property 56 | def prompt(self): 57 | """Return the prompt for this program. None if it doesn't have one.""" 58 | return None 59 | 60 | @property 61 | def help(self): 62 | """Return help string for this program.""" 63 | return "" 64 | 65 | @property 66 | def security_type(self): 67 | """Return string indicating what security this program can bypass.""" 68 | return "" 69 | 70 | @property 71 | def success_syslog(self): 72 | return "{} {} disabled.".format(self.SUCCESS_PREFIX, self.security_type) 73 | 74 | @property 75 | def failure_prefix(self): 76 | return "SYSTEM ALERT: " 77 | 78 | @property 79 | def buf(self): 80 | """Terminal buffer contents for this interactive program.""" 81 | return [] 82 | 83 | def draw(self): 84 | """Draw the program, if it is graphical.""" 85 | pass 86 | 87 | def run(self): 88 | """Run any background program logic that isn't user input driven.""" 89 | pass 90 | 91 | def start(self): 92 | """Called when the program is started, or restarted.""" 93 | pass 94 | 95 | def text_input(self, line): 96 | """ 97 | Handle a line of input from the terminal (Used for TERMINAL). 98 | 99 | Raises BadInput error on bad input. 100 | 101 | """ 102 | pass 103 | 104 | def on_keypress(self, key, key_unicode): 105 | """Handle a user keypress (used for INTERACTIVE and GRAPHICAL).""" 106 | pass 107 | 108 | def on_mouseclick(self, button, pos): 109 | """Handle a mouse click from the user.""" 110 | pass 111 | 112 | def on_mousemove(self, pos): 113 | """Handle a mouse move from the user.""" 114 | pass 115 | 116 | def on_abort(self): 117 | """Handle a ctrl+c from user.""" 118 | pass 119 | 120 | def exited(self): 121 | """Whether the current instance of the program has exited.""" 122 | return False 123 | 124 | def completed(self): 125 | """Whether the task associated with this program has been completed.""" 126 | return False 127 | -------------------------------------------------------------------------------- /resources.py: -------------------------------------------------------------------------------- 1 | """Media management - only load each asset once.""" 2 | 3 | import os 4 | import sys 5 | import pygame 6 | 7 | # A dict mapping filenames to the in-memory representation for each asset. 8 | _media = {} 9 | 10 | 11 | def make_path(filename): 12 | """Create the correct path for a given file.""" 13 | # If we're running in a pyinstaller one-file bundle, we need to look in 14 | # the temporary directory. 15 | if hasattr(sys, '_MEIPASS'): 16 | return os.path.join(sys._MEIPASS, filename) 17 | else: 18 | return filename 19 | 20 | 21 | def load_font(filename, size): 22 | """Load a font from disk, return a pygame Font object.""" 23 | if (filename, size) not in _media: 24 | _media[(filename, size)] = pygame.font.Font(make_path(filename), size) 25 | return _media[(filename, size)] 26 | 27 | 28 | def load_image(filename): 29 | """Load an image from disk, return a pygame Surface.""" 30 | if filename not in _media: 31 | _media[filename] = pygame.image.load( 32 | make_path(filename)).convert_alpha() 33 | return _media[filename] 34 | -------------------------------------------------------------------------------- /terminal.py: -------------------------------------------------------------------------------- 1 | """Module containing the terminal class, the main gameplay logic.""" 2 | 3 | import re 4 | import itertools 5 | import random 6 | import string 7 | import os 8 | from collections import deque 9 | 10 | import pygame 11 | 12 | import constants 13 | import timer 14 | import mouse 15 | from resources import load_font 16 | from programs.program import BadInput 17 | from util import render_bezel 18 | 19 | 20 | class Terminal: 21 | 22 | """The main terminal class.""" 23 | 24 | _ACCEPTED_CHARS = (string.ascii_letters + string.digits + 25 | string.punctuation + " ") 26 | _BUF_SIZE = 100 27 | _HISTORY_SIZE = 50 28 | 29 | _TIMER_POS = (0, 0) 30 | _TIMER_WARNING_SECS = 30 31 | 32 | # Constants related to drawing the terminal text. 33 | _VISIBLE_LINES = 29 34 | _TEXT_FONT = constants.TERMINAL_FONT 35 | _TEXT_SIZE = constants.TERMINAL_TEXT_SIZE 36 | _TEXT_COLOUR = constants.TEXT_COLOUR 37 | _TEXT_COLOURS = { 38 | "g": constants.TEXT_COLOUR, 39 | "r": constants.TEXT_COLOUR_RED, 40 | "w": constants.TEXT_COLOUR_WHITE, 41 | } 42 | 43 | # Constants related to cursor 44 | _CURSOR_WIDTH = 6 45 | _CURSOR_ON_MS = 800 46 | _CURSOR_OFF_MS = 600 47 | 48 | # The coordinates to start drawing text. 49 | _TEXT_START = (45, 541) 50 | 51 | # Freeze progress bar size 52 | _PROGRESS_BAR_SIZE = 30 53 | 54 | # Key repeat delay 55 | _KEY_REPEAT_DELAY = 50 56 | _KEY_REPEAT_INITIAL_DELAY = 500 57 | 58 | def __init__(self, programs, prompt='$ ', time=300, depends=None): 59 | """Initialize the class.""" 60 | # Public attributes 61 | self.locked = False 62 | self.id_string = ''.join( 63 | random.choice(string.ascii_uppercase + string.digits) 64 | for _ in range(4)) 65 | 66 | # Current line without prompt. If current line with prompt is required, 67 | # use get_current_line(True) 68 | self._current_line = "" 69 | self._buf = deque(maxlen=Terminal._BUF_SIZE) 70 | self._prompt = prompt 71 | self._cmd_history = CommandHistory(self, maxlen=Terminal._HISTORY_SIZE) 72 | self._font = load_font(Terminal._TEXT_FONT, Terminal._TEXT_SIZE) 73 | self._has_focus = True 74 | 75 | # Timer attributes 76 | self._timer = timer.Timer() 77 | self._countdown_timer = CountdownTimer(time, 78 | Terminal._TIMER_WARNING_SECS) 79 | 80 | # Freeze attributes 81 | self._freeze_start = None 82 | self._freeze_time = None 83 | 84 | # Reboot attributes 85 | self._rebooting = False 86 | self._reboot_update_time = 0 87 | self._reboot_buf = deque() 88 | 89 | # Repeat key presses when certain keys are held. 90 | # Held key is a tuple of (key, key_unicode, start time) 91 | self._held_key = None 92 | self._key_last_repeat = None 93 | 94 | # Create instances of the programs that have been registered. 95 | self._programs = {c: p(self) for c, p in programs.items()} 96 | self._current_program = None 97 | self._depends = {} if depends is None else depends 98 | 99 | # Draw the monitor bezel 100 | self._bezel = render_bezel(self.id_string) 101 | self._bezel_off = render_bezel(self.id_string, power_off=True) 102 | 103 | self.reboot() 104 | 105 | def _process_command(self, cmd): 106 | """Process a completed command.""" 107 | if cmd in self._programs: 108 | # Check dependencies for this command 109 | if self._is_cmd_runnable(cmd): 110 | # Create a new instance of the program 111 | self._current_program = self._programs[cmd] 112 | 113 | # Don't run the program if it is already completed 114 | if not self._current_program.completed(): 115 | self._current_program.start() 116 | else: 117 | self.output(["{} already completed!" 118 | .format(self._current_program.security_type) 119 | .capitalize()]) 120 | self._current_program = None 121 | 122 | elif cmd in ('help', '?'): 123 | sorted_cmds = sorted(self._programs.items(), 124 | key=lambda i: i[0]) 125 | self.output(["Available commands:"] + 126 | [" {:10} {}".format(c, p.help) 127 | for c, p in sorted_cmds]) 128 | 129 | # Easter egg! 130 | elif cmd.startswith("colour "): 131 | args = cmd.split(" ")[1:] 132 | if len(args) == 3: 133 | try: 134 | # Get colour and try a render to make sure code correct 135 | colour = tuple(int(a) for a in args) 136 | self._font.render("test", True, colour) 137 | Terminal._TEXT_COLOUR = colour 138 | except (ValueError, TypeError): 139 | self.output(["I am not familiar with that colour code."]) 140 | else: 141 | self.output(["Enjoy your new colour!"]) 142 | 143 | # Freeze test 144 | elif cmd.startswith("freeze "): 145 | try: 146 | self.freeze(int(cmd.split(" ")[1])) 147 | except ValueError: 148 | self.output(["Invalid time"]) 149 | 150 | elif cmd: 151 | self.output(["Unknown command '{}'.".format(cmd)]) 152 | 153 | def _is_cmd_runnable(self, cmd): 154 | depends_list = self._depends.get(cmd) 155 | if depends_list is None: 156 | blocked_on = [] 157 | else: 158 | # Get blocked-on list 159 | blocked_on = [self._programs[c] for c in depends_list 160 | if not self._programs[c].completed()] 161 | 162 | if len(blocked_on) == 0: 163 | return True 164 | else: 165 | self.output(["{} currently blocked by: {}".format( 166 | cmd, ", ".join(p.security_type for p in blocked_on) 167 | )]) 168 | return False 169 | 170 | def _add_to_buf(self, lines): 171 | """Add lines to the display buffer.""" 172 | for line in lines: 173 | # The buffer is ordered left to right from newest to oldest. 174 | # This will push old lines off the end of the buffer if it is full. 175 | self._buf.appendleft(line) 176 | 177 | def _complete_input(self): 178 | """Process a line of input from the user.""" 179 | # Add the current line to the buffer 180 | self._add_to_buf([self.get_current_line(True)]) 181 | 182 | if self._current_program: 183 | # Handle bad input errors 184 | try: 185 | self._current_program.text_input(self.get_current_line()) 186 | except BadInput as e: 187 | self.output(["Error: {}".format(str(e))]) 188 | else: 189 | # Skip the prompt and any leading/trailing whitespace to get 190 | # the command. 191 | cmd = self.get_current_line().lstrip().rstrip() 192 | 193 | # Add to command history, skipping repeated entries 194 | if cmd: 195 | self._cmd_history.add_command(cmd) 196 | self._process_command(cmd) 197 | 198 | # Reset the prompt 199 | self._reset_prompt() 200 | 201 | def _reset_prompt(self): 202 | # Current line doesn't have prompt, so we don't have to worry about 203 | # adding it. 204 | self._current_line = "" 205 | 206 | def _tab_complete(self): 207 | # Only works outside programs for now 208 | if self._current_program is None: 209 | partial = self.get_current_line() 210 | 211 | # Find the command being typed 212 | matches = [c for c in list(self._programs.keys()) + ["help"] 213 | if c.startswith(partial)] 214 | if len(matches) == 1: 215 | self.set_current_line(matches[0]) 216 | elif len(matches) > 1: 217 | # Get the common prefix. If this is more than what is typed 218 | # then complete up till that, else display options 219 | common_prefix = os.path.commonprefix(matches) 220 | if common_prefix != partial: 221 | self.set_current_line(common_prefix) 222 | else: 223 | self.output([self.get_current_line(True), 224 | " ".join(matches)]) 225 | 226 | def _run_reboot(self): 227 | """Handle scrolling text as part of a reboot.""" 228 | if self._rebooting and self._reboot_update_time <= self._timer.time: 229 | pause, line = self._reboot_buf.popleft() 230 | self.output([line]) 231 | 232 | if not self._reboot_buf: 233 | self._rebooting = False 234 | else: 235 | residual = self._timer.time - self._reboot_update_time 236 | self._reboot_update_time = self._timer.time + pause - residual 237 | 238 | @property 239 | def time(self): 240 | """Return the current time.""" 241 | return self._timer.time 242 | 243 | def get_current_line(self, include_prompt=False): 244 | """Get the current input line.""" 245 | if include_prompt: 246 | # See if the current program has a prompt. Will be None if it 247 | # doesn't. 248 | if self._current_program is not None: 249 | prompt = self._current_program.prompt 250 | else: 251 | prompt = self._prompt 252 | 253 | return (self._current_line if prompt is None 254 | else prompt + self._current_line) 255 | else: 256 | return self._current_line 257 | 258 | def set_current_line(self, line): 259 | """Set the current input line.""" 260 | # Don't need to add prompt - this gets added by get_current_line() 261 | self._current_line = line 262 | 263 | def on_keypress(self, key, key_unicode): 264 | """Handle a user keypress.""" 265 | # Ignore all input if in freeze mode, or we are rebooting. 266 | if self._freeze_time is not None or self._rebooting: 267 | return 268 | 269 | # Any typing other than arrows reset history navigation 270 | if key not in (pygame.K_UP, pygame.K_DOWN): 271 | self._cmd_history.reset_navigation() 272 | 273 | # Abort whatever is running on ctrl+c 274 | if (key == pygame.K_c and 275 | pygame.key.get_mods() & pygame.KMOD_CTRL): 276 | current_line = self.get_current_line(True) 277 | 278 | # If we are in a program, then abort it 279 | if self._current_program: 280 | # If the current program doesn't allow ctrl+c then stop 281 | if not self._current_program.allow_ctrl_c: 282 | return 283 | 284 | self._current_program.on_abort() 285 | self._current_program = None 286 | self.output([current_line + "^C"]) 287 | self._reset_prompt() 288 | return 289 | 290 | # If we're displaying a graphical program, or the program wants to 291 | # handle its own keypresses, then pass key to them 292 | if (self._current_program is not None and 293 | (self._current_program.PROPERTIES.is_graphical or 294 | self._current_program.PROPERTIES.intercept_keypress)): 295 | self._current_program.on_keypress(key, key_unicode) 296 | return 297 | 298 | # Now handle terminal keyboard input 299 | repeat_on_hold = False 300 | if key in [pygame.K_RETURN, pygame.K_KP_ENTER]: 301 | if self.get_current_line(True): 302 | self._complete_input() 303 | elif key == pygame.K_BACKSPACE: 304 | self._current_line = self._current_line[:-1] 305 | repeat_on_hold = True 306 | elif key in (pygame.K_UP, pygame.K_DOWN): 307 | # Currently not supported in a program 308 | if self._current_program is None: 309 | self._cmd_history.navigate(key == pygame.K_UP) 310 | elif key == pygame.K_TAB: 311 | self._tab_complete() 312 | elif key_unicode in Terminal._ACCEPTED_CHARS: 313 | self._current_line += key_unicode 314 | repeat_on_hold = True 315 | 316 | # If this is a key that should be repeated when held, then setup the 317 | # the attributes 318 | if repeat_on_hold: 319 | self._held_key = (key, key_unicode, self._timer.time) 320 | 321 | def on_keyrelease(self): 322 | """Handle the user releasing a key.""" 323 | self._held_key = None 324 | self._key_last_repeat = None 325 | 326 | def on_mouseclick(self, button, pos): 327 | """Handle a user mouse click.""" 328 | if self._current_program: 329 | self._current_program.on_mouseclick(button, pos) 330 | 331 | def on_mousemove(self, pos): 332 | """Handle a user mouse move.""" 333 | if self._current_program: 334 | self._current_program.on_mousemove(pos) 335 | 336 | def on_active_event(self, active_event): 337 | """Handle a window active event.""" 338 | if active_event.input_focus_change: 339 | self._has_focus = active_event.gained 340 | 341 | def output(self, output): 342 | """Add a list of lines to the displayed output.""" 343 | # NB Output is expected to be a list of lines. 344 | self._add_to_buf(output) 345 | 346 | def freeze(self, time): 347 | """Freeze terminal for 'time' ms, displaying progress bar.""" 348 | self._freeze_start = self._timer.time 349 | self._freeze_time = time 350 | 351 | def reduce_time(self, time): 352 | """Reduce the available time by 'time' seconds.""" 353 | self._countdown_timer.update(time * 1000) 354 | 355 | def reboot(self, msg=""): 356 | """Simulate a reboot.""" 357 | # Clear the buffer. 358 | self._buf.clear() 359 | 360 | self._rebooting = True 361 | self._reboot_update_time = self._timer.time 362 | 363 | # Display welcome message. 364 | PAUSE_LEN = 20 365 | self._reboot_buf.extend([ 366 | (PAUSE_LEN, "-" * 60), 367 | (PAUSE_LEN, "Mainframe terminal"), 368 | (PAUSE_LEN, ""), 369 | (PAUSE_LEN, 370 | "You have {}s to login before terminal is locked down.".format( 371 | self._countdown_timer.secs_left)), 372 | (PAUSE_LEN, ""), 373 | (PAUSE_LEN, 374 | "Tip of the day: press ctrl+c to cancel current command."), 375 | (PAUSE_LEN, "-" * 60)]) 376 | 377 | if msg: 378 | self._reboot_buf.extend([ 379 | (PAUSE_LEN * 25, ""), 380 | (PAUSE_LEN * 50, msg)]) 381 | 382 | if 'login' in self._programs: 383 | end_msgs = [(PAUSE_LEN, 384 | "Type 'login' to log in, or 'help' to " 385 | "list available commands")] 386 | else: 387 | end_msgs = [(PAUSE_LEN, 388 | "Type 'help' to list available commands")] 389 | 390 | # Push banner to top, leaving space for end messages, and for 391 | # current line. 392 | blank_lines = (Terminal._VISIBLE_LINES - 393 | len(self._reboot_buf) - len(end_msgs) - 1) 394 | self._reboot_buf.extend([(PAUSE_LEN, "")] * blank_lines + end_msgs) 395 | 396 | def draw(self): 397 | """Draw terminal.""" 398 | # If the current program is a graphical one, draw it now, else draw 399 | # monitor contents. 400 | if (self._current_program and 401 | self._current_program.PROPERTIES.is_graphical): 402 | self._current_program.draw() 403 | if not self._current_program.PROPERTIES.skip_bezel: 404 | self.draw_bezel() 405 | else: 406 | self._draw_contents() 407 | self.draw_bezel() 408 | 409 | # Make sure cursor is an arrow if we are not in a program. This should 410 | # be a no-op if it is already an arrow. 411 | if self._current_program is None: 412 | mouse.current.set_cursor(mouse.Cursor.ARROW) 413 | 414 | def _draw_contents(self): 415 | """Draw the terminal.""" 416 | if self._rebooting: 417 | # If we're rebooting, don't draw the prompt 418 | current_line = "" 419 | elif self._freeze_time is not None: 420 | # If terminal freeze is enabled, then update progress bar to 421 | # indicate how long there is left to wait, using this as the 422 | # current line. 423 | done = ((self._timer.time - 424 | self._freeze_start) * 100) / self._freeze_time 425 | remain = int((100 - done) * self._PROGRESS_BAR_SIZE / 100) 426 | current_line = ("[" + 427 | "!" * (self._PROGRESS_BAR_SIZE - remain) + 428 | " " * remain + "]") 429 | else: 430 | current_line = self.get_current_line(True) 431 | 432 | # If program has its own buf, then use it 433 | if (self._current_program is not None and 434 | self._current_program.PROPERTIES.alternate_buf): 435 | buf = self._current_program.buf 436 | else: 437 | buf = self._buf 438 | 439 | # Draw the buffer. 440 | y_coord = Terminal._TEXT_START[1] 441 | first_line_height = None 442 | for line in list(itertools.chain( 443 | [current_line], buf))[:self._VISIBLE_LINES]: 444 | # Set defaults before checking whether the line overrides. 445 | colour = Terminal._TEXT_COLOUR 446 | size = Terminal._TEXT_SIZE 447 | fontname = "" 448 | 449 | # Look for any font commands at the start of the line. 450 | pattern = re.compile(r'<(. [^>]+?)>') 451 | m = pattern.match(line) 452 | while m: 453 | # Don't display the commands 454 | line = line[len(m.group(0)):] 455 | cmd, arg = m.group(1).split() 456 | if cmd == 'c': 457 | # Change the colour code. 458 | colour = Terminal._TEXT_COLOURS[arg] 459 | elif cmd == 's': 460 | size = int(arg) 461 | elif cmd == 'f': 462 | # Don't load the font yet, as we need to know which 463 | # size to load, and the size cmd might come after the 464 | # font command 465 | fontname = arg 466 | 467 | m = pattern.match(line) 468 | 469 | if fontname: 470 | font = load_font(fontname, size) 471 | else: 472 | font = self._font 473 | 474 | # The height of the rendered text can sometimes be quite different 475 | # to the 'size' value used. So use the rendered height with a 2 476 | # pixel padding each side 477 | line_height = font.size(line)[1] + 4 478 | if first_line_height is None: 479 | first_line_height = line_height 480 | 481 | y_coord -= line_height 482 | 483 | text = font.render(line, True, colour) 484 | pygame.display.get_surface().blit( 485 | text, (Terminal._TEXT_START[0], y_coord)) 486 | 487 | # Determine whether the cursor is on. 488 | if ((self._current_program is None or 489 | not self._current_program.PROPERTIES.hide_cursor) and 490 | not self._rebooting and 491 | (self._timer.time % (Terminal._CURSOR_ON_MS + 492 | Terminal._CURSOR_OFF_MS) < 493 | Terminal._CURSOR_ON_MS)): 494 | first_line_size = self._font.size(current_line) 495 | pygame.draw.rect(pygame.display.get_surface(), 496 | Terminal._TEXT_COLOUR, 497 | (Terminal._TEXT_START[0] + first_line_size[0] + 1, 498 | Terminal._TEXT_START[1] - first_line_height - 1, 499 | Terminal._CURSOR_WIDTH, first_line_size[1]), 500 | 0 if self._has_focus else 1) 501 | 502 | def draw_bezel(self, power_off=False): 503 | """Draw the bezel.""" 504 | bezel = self._bezel if not power_off else self._bezel_off 505 | pygame.display.get_surface().blit(bezel, bezel.get_rect()) 506 | 507 | # Draw the countdown text. 508 | self._countdown_timer.draw(Terminal._TIMER_POS) 509 | 510 | def run(self): 511 | """Run terminal logic.""" 512 | self._timer.update() 513 | 514 | if self.paused: 515 | return 516 | 517 | # Run the reboot if one is in progress. 518 | self._run_reboot() 519 | 520 | # Check whether the current program (if there is one) has exited. 521 | if self._current_program and self._current_program.exited(): 522 | # If it exited because it was successfully completed, then display 523 | # syslog, unless the program is going to do it itself 524 | if (self._current_program.completed() and 525 | not self._current_program.PROPERTIES.suppress_success): 526 | self.output([self._current_program.success_syslog]) 527 | 528 | self._current_program = None 529 | 530 | # Display the prompt again. 531 | self._reset_prompt() 532 | 533 | # Check if the player ran out of time. 534 | self._countdown_timer.update(self._timer.frametime) 535 | if self._countdown_timer.ended: 536 | self.locked = True 537 | 538 | # See whether terminal can be unfrozen 539 | if (self._freeze_time is not None and 540 | self._timer.time > self._freeze_start + self._freeze_time): 541 | self._freeze_time = None 542 | self._freeze_start = None 543 | 544 | # Reset current line to prompt 545 | self._reset_prompt() 546 | 547 | # See whether a key is held, and repeat it 548 | if self._held_key is not None: 549 | key, key_unicode, start = self._held_key 550 | if self._key_last_repeat is None: 551 | last, delay = start, Terminal._KEY_REPEAT_INITIAL_DELAY 552 | else: 553 | last, delay = self._key_last_repeat, Terminal._KEY_REPEAT_DELAY 554 | 555 | if (self._timer.time - last) > delay: 556 | self._key_last_repeat = self._timer.time 557 | self.on_keypress(key, key_unicode) 558 | 559 | # Run the current program logic 560 | if self._current_program is not None: 561 | self._current_program.run() 562 | 563 | def completed(self): 564 | """Indicate whether the player has been successful.""" 565 | return len([p for p in self._programs.values() 566 | if not p.completed()]) == 0 567 | 568 | @property 569 | def paused(self): 570 | """Pause the game.""" 571 | return self._timer.paused 572 | 573 | @paused.setter 574 | def paused(self, value): 575 | """Unpause the game.""" 576 | self._timer.paused = value 577 | 578 | 579 | class CommandHistory: 580 | 581 | """Class for storing and navigating a terminal's command history.""" 582 | 583 | def __init__(self, terminal, maxlen): 584 | """Intialize the class.""" 585 | self._terminal = terminal 586 | self._history = deque(maxlen=maxlen) 587 | self._pos = -1 588 | self._saved_line = None 589 | 590 | def add_command(self, cmd): 591 | """Add a command to the command history.""" 592 | # Skip repeated commands 593 | if len(self._history) == 0 or self._history[0] != cmd: 594 | self._history.appendleft(cmd) 595 | 596 | def reset_navigation(self): 597 | """Reset the position in the command history.""" 598 | self._pos = -1 599 | 600 | def navigate(self, up): 601 | """Navigate through the command history.""" 602 | if up: 603 | if self._pos + 1 < len(self._history): 604 | # If we are starting a history navigation, then save current 605 | # line 606 | if self._pos == -1: 607 | self._saved_line = self._terminal.get_current_line() 608 | self._pos += 1 609 | self._terminal.set_current_line(self._history[self._pos]) 610 | else: 611 | if self._pos > 0: 612 | self._pos -= 1 613 | self._terminal.set_current_line(self._history[self._pos]) 614 | elif self._pos == 0: 615 | # Restore saved line 616 | self._pos = -1 617 | self._terminal.set_current_line(self._saved_line) 618 | 619 | 620 | class CountdownTimer: 621 | 622 | _TIMER_FONT = 'media/fonts/LCDMU___.TTF' 623 | _TIMER_SIZE = 20 624 | _TIMER_LARGE_SIZE = 30 625 | _TIMER_COLOUR = (255, 255, 255) 626 | _TIMER_WARNING_COLOUR = (200, 0, 0) 627 | _FLASH_TIME = 3000 628 | _FLASH_ON = 600 629 | _FLASH_OFF = 400 630 | 631 | """Class for the terminal countdown timer.""" 632 | def __init__(self, time_in_s, warning_secs): 633 | self._timeleft = time_in_s * 1000 634 | self._timer_font = load_font(CountdownTimer._TIMER_FONT, 635 | CountdownTimer._TIMER_SIZE) 636 | self._timer_large_font = load_font(CountdownTimer._TIMER_FONT, 637 | CountdownTimer._TIMER_LARGE_SIZE) 638 | self._warning_secs = warning_secs 639 | 640 | # Are we currently flashing the timer, and if so what time did it start 641 | self._flash_start = None 642 | 643 | # The times at which the timer should be large and flashing! 644 | self._flash_times = [warning_secs, 15, 5, 4, 3, 2, 1] 645 | 646 | @property 647 | def secs_left(self): 648 | return self._timeleft // 1000 649 | 650 | @property 651 | def ended(self): 652 | return self._timeleft <= 0 653 | 654 | def update(self, ms_to_subtract): 655 | self._timeleft -= ms_to_subtract 656 | if self._timeleft <= 0: 657 | self._timeleft = 0 658 | self._flash_start = None 659 | else: 660 | # Should we end current flash time? 661 | if (self._flash_start is not None and 662 | self._flash_start - self._timeleft > self._FLASH_TIME): 663 | self._flash_start = None 664 | 665 | # Should we start a new flash time? 666 | if (self._flash_start is None and 667 | len(self._flash_times) > 0 and 668 | self.secs_left <= self._flash_times[0]): 669 | self._flash_start = self._timeleft 670 | 671 | # Keep stripping until we reach a time larger than current 672 | while (len(self._flash_times) > 0 and 673 | self.secs_left <= self._flash_times[0]): 674 | self._flash_times = self._flash_times[1:] 675 | 676 | def draw(self, pos): 677 | # If we are flashing the text, then skip draw if we are in an 'off' 678 | if (self._flash_start is not None and 679 | self._timeleft % (self._FLASH_ON + self._FLASH_OFF) 680 | < self._FLASH_OFF): 681 | return 682 | 683 | # Are we using normal font or the large flashing font? 684 | font = self._timer_font 685 | if self._flash_start is not None: 686 | font = self._timer_large_font 687 | 688 | # Draw the countdown text on a semi transparent background 689 | colour = CountdownTimer._TIMER_COLOUR 690 | if self.secs_left <= self._warning_secs: 691 | colour = CountdownTimer._TIMER_WARNING_COLOUR 692 | minutes, seconds = divmod(self.secs_left, 60) 693 | text = font.render('{}:{:02}'.format(minutes, seconds), True, colour) 694 | surf = pygame.Surface((text.get_rect().w + 4, text.get_rect().h)) 695 | surf.set_alpha(100) 696 | pygame.display.get_surface().blit(surf, pos) 697 | pygame.display.get_surface().blit(text, (pos[0] + 2, pos[1])) 698 | 699 | def _get_font(self): 700 | if self._flash_start is not None: 701 | return self._timer_large_font 702 | else: 703 | return self._timer_font 704 | -------------------------------------------------------------------------------- /timer.py: -------------------------------------------------------------------------------- 1 | """Timer module.""" 2 | 3 | import pygame 4 | 5 | 6 | class Timer: 7 | 8 | """Timer class.""" 9 | 10 | def __init__(self): 11 | """Initialize the class.""" 12 | self.reset() 13 | 14 | def reset(self): 15 | """Reset the timer.""" 16 | self._lasttime = pygame.time.get_ticks() 17 | self.paused = False 18 | self.time = 0 19 | self.frametime = 0 20 | 21 | def update(self): 22 | """Update the time values based on the current tickcount.""" 23 | time = pygame.time.get_ticks() 24 | 25 | if not self.paused: 26 | self.frametime = time - self._lasttime 27 | self.time += self.frametime 28 | 29 | self._lasttime = time 30 | -------------------------------------------------------------------------------- /tools/decrypt_image.py: -------------------------------------------------------------------------------- 1 | """Produce the manual image for the decryption game.""" 2 | import string 3 | import pygame 4 | from pygame import Surface 5 | from pygame.font import Font 6 | from programs import Decrypt 7 | import constants 8 | 9 | _TEXT_HEIGHT = 34 10 | _VERTICAL_SPACING = 20 11 | _VERTICAL_PADDING = 5 12 | _HORIZONTAL_SPACING = 20 13 | _HORIZONTAL_PADDING = 10 14 | _CENTRAL_SPACING = 50 15 | _BACKGROUND_COLOUR = (50, 50, 50) 16 | 17 | 18 | def render_font(font, charmap): 19 | """Render all chars for a given font in a column.""" 20 | renders = [font.render(c, True, constants.TEXT_COLOUR) for _, c in charmap] 21 | surf = Surface((max([r.get_rect().w for r in renders]), 22 | (_TEXT_HEIGHT + _VERTICAL_SPACING) * len(renders))) 23 | surf.fill(_BACKGROUND_COLOUR) 24 | 25 | y = _VERTICAL_PADDING 26 | for r in renders: 27 | surf.blit(r, (0, y)) 28 | y += _TEXT_HEIGHT + _VERTICAL_SPACING 29 | 30 | return surf 31 | 32 | 33 | def render_half(fonts): 34 | """Render one half of the final image.""" 35 | # Render columns for each font. 36 | surfs = [render_font(font, charmap) for font, charmap in fonts] 37 | 38 | # Work out the size of the image we need 39 | img_height = max([s.get_rect().h for s in surfs]) 40 | img_width = (sum([s.get_rect().w for s in surfs]) + 41 | _HORIZONTAL_SPACING * (len(surfs) - 1) + 42 | _HORIZONTAL_PADDING * 2) 43 | 44 | # Blit each font's column into the final image. 45 | output = Surface((img_width, img_height)) 46 | output.fill(_BACKGROUND_COLOUR) 47 | 48 | x = _HORIZONTAL_PADDING 49 | for surf in surfs: 50 | output.blit(surf, (x, 0)) 51 | x += surf.get_rect().w + _HORIZONTAL_SPACING 52 | 53 | return output 54 | 55 | 56 | if __name__ == '__main__': 57 | pygame.font.init() 58 | 59 | # Load the fonts from disk, and group them with their charmaps 60 | fonts = [(Font(f, _TEXT_HEIGHT), sorted(m.items())) for f, m in 61 | [(None, {c: c for c in string.ascii_lowercase})] + Decrypt._FONTS] 62 | 63 | # Split the fonts in half, so that we can render them in two columns, giving 64 | # a squarer final image. 65 | fonts_left = [(f, m[:len(m) // 2]) for f, m in fonts] 66 | fonts_right = [(f, m[len(m) // 2:]) for f, m in fonts] 67 | 68 | # Render the two halfs of the image. 69 | surf_left = render_half(fonts_left) 70 | surf_right = render_half(fonts_right) 71 | 72 | # Combine the two halfs into the final image. 73 | final_height = max(surf_left.get_rect().h, surf_right.get_rect().h) 74 | final_width = (surf_left.get_rect().w + surf_right.get_rect().w + 75 | _CENTRAL_SPACING) 76 | output = Surface((final_width, final_height)) 77 | output.fill(_BACKGROUND_COLOUR) 78 | output.blit(surf_left, (0, 0)) 79 | output.blit(surf_right, (surf_left.get_rect().w + _CENTRAL_SPACING, 0)) 80 | pygame.image.save(output, 'docs/decrypt.png') 81 | -------------------------------------------------------------------------------- /tools/imagepassword_test.py: -------------------------------------------------------------------------------- 1 | """A test tool to check the visual password program has unique solutions.""" 2 | from itertools import combinations 3 | from programs import ImagePassword 4 | 5 | # Find duplicates 6 | combos = [combinations(c, 3) for c in ImagePassword._USER_INFO] 7 | 8 | dups = [] 9 | for c1 in combos: 10 | for c2 in [c for c in combos if c is not c1]: 11 | dups.extend([c for c in c1 if c in c2]) 12 | 13 | if dups: 14 | print('Found duplicates: {}'.format(dups)) 15 | else: 16 | print('No duplicates') 17 | 18 | # Find the number of times each category appears in the puzzle 19 | distribution = {} 20 | for i in ImagePassword._USER_INFO: 21 | for c in i: 22 | if c.name not in distribution: 23 | distribution[c.name] = 1 24 | else: 25 | distribution[c.name] += 1 26 | 27 | print(distribution) 28 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous utilities for use in the game.""" 2 | 3 | 4 | import pygame 5 | from resources import load_image, load_font 6 | 7 | 8 | class Align: 9 | 10 | """Class containing the values representing text alignment.""" 11 | 12 | LEFT = -1 13 | CENTER = 0 14 | RIGHT = 1 15 | 16 | 17 | class ActiveEvent: 18 | 19 | """ 20 | Class representing an pygame.ACTIVEEVENT. 21 | 22 | This wraps the state mask, providing a cleaner interface. 23 | 24 | """ 25 | 26 | MOUSE_FOCUS = 0x1 27 | APP_INPUT_FOCUS = 0x2 28 | APP_ACTIVE = 0x4 29 | 30 | def __init__(self, state_mask, gain): 31 | """Initialize the class from the event parameters.""" 32 | self._state_mask = state_mask 33 | self._gain = gain 34 | 35 | @property 36 | def gained(self): 37 | """Indicate if this event gained or lost the state.""" 38 | return self._gain == 1 39 | 40 | @property 41 | def mouse_focus_change(self): 42 | """Indicate if the event is a mouse focus change.""" 43 | return self._has_state(ActiveEvent.MOUSE_FOCUS) 44 | 45 | @property 46 | def input_focus_change(self): 47 | """Indicate if this event is an app input focus change.""" 48 | return self._has_state(ActiveEvent.APP_INPUT_FOCUS) 49 | 50 | @property 51 | def app_active_change(self): 52 | """Indicate if this event is an app active change.""" 53 | return self._has_state(ActiveEvent.APP_ACTIVE) 54 | 55 | def _has_state(self, state): 56 | """Determine whether the event has a givenstate.""" 57 | return (self._state_mask & state) == state 58 | 59 | 60 | def center_align(w, h): 61 | """Return coords to align an image in the center of the screen.""" 62 | return ((pygame.display.get_surface().get_rect().w - w) / 2, 63 | (pygame.display.get_surface().get_rect().h - h) / 2) 64 | 65 | 66 | def text_align(text, coords, align): 67 | """Align text around given coords.""" 68 | if align == Align.LEFT: 69 | return coords 70 | elif align == Align.CENTER: 71 | return coords[0] - text.get_rect().w / 2, coords[1] 72 | else: 73 | return coords[0] - text.get_rect().w, coords[1] 74 | 75 | 76 | def render_bezel(label, power_off=False): 77 | """Render the bezel and label text.""" 78 | if power_off: 79 | bezel = load_image('media/bezel_off.png') 80 | else: 81 | bezel = load_image('media/bezel.png') 82 | text = load_font('media/fonts/METRO-DF.TTF', 19).render( 83 | label, True, (60, 60, 60)) 84 | 85 | # Copy the bezel surface so we don't overwrite the stored cached surface in 86 | # the media manager. 87 | surf = bezel.copy() 88 | surf.blit(text, text_align(text, (725, 570), Align.CENTER)) 89 | 90 | return surf 91 | --------------------------------------------------------------------------------