Compare commits

..
191 Commits
Author SHA1 Message Date
Jon Eugster 68f84a3426 fix replacement for 2+ variables 2024-02-29 16:53:35 +01:00
Jon Eugster 217f86ce5e fix allowed keywords that are not tactics 2024-02-29 15:12:33 +01:00
Jon Eugster dd60093dfc bump i18n again 2024-02-29 12:03:48 +01:00
Jon Eugster 85347a54d9 bump i18n 2024-02-29 11:40:30 +01:00
Jon Eugster 3b4afd6e0e update i18n dependency 2024-02-29 11:24:40 +01:00
Jon Eugster 2c12872a6e npm audit 2024-02-29 11:02:45 +01:00
Jon Eugster ad819bf7ff npm package 2024-02-29 11:01:59 +01:00
Jon Eugster c0f366abba Merge branch 'dev' 2024-02-29 11:00:07 +01:00
Jon Eugster f72ebdf050 bump to v4.6.0 2024-02-29 10:54:37 +01:00
Jon Eugster af8463ca5d fixes for v4.6.0-rc1 2024-02-29 10:34:24 +01:00
Jon Eugster d0a444205a bump to v4.6.0-rc1 2024-02-29 10:34:24 +01:00
Jon Eugster 1796c76a84 remove debugging css 2024-02-29 01:27:26 +01:00
Jon Eugster a75a4a81ac add i18n dependency (#179) 2024-02-29 01:08:51 +01:00
Jon Eugster 45d84103c1 bump npm packages 2024-02-29 01:03:25 +01:00
Jon Eugster 92e9ed38b2 add manual trigger to github action 2024-02-29 01:02:28 +01:00
Jon Eugster d689c7ec86 update npm deps 2024-02-23 18:58:15 +01:00
Jon Eugster 16c979a6c2 bump npm dependencies 2024-02-23 18:58:06 +01:00
Jon Eugster 2b85386373 move Hint tactic back 2024-02-16 18:20:36 +01:00
Jon Eugster 8008b68fd6 cleanup code surrounding hints 2024-02-16 18:09:01 +01:00
Jon Eugster 2649f985fa plug-in variables in hints client-side 2024-02-16 16:50:10 +01:00
Jon Eugster 698a88c545 Update troubleshoot.md 2024-02-16 10:57:02 +01:00
Jon Eugster 3775ad98c8 level completed message in editor 2024-02-14 18:36:19 +01:00
Jon Eugster 780514e45a fix: allow theorems from inventory #191 2024-02-14 18:22:01 +01:00
Jon Eugster 19f2ceface fix indent 2024-02-14 16:45:32 +01:00
Jon Eugster 800d1f3308 drop importGraph dependency in server 2024-02-14 15:03:12 +01:00
Jon Eugster c0acde14e2 hints and diags in editor 2024-02-14 15:02:14 +01:00
Jon Eugster 976d1c6901 fix: goal in editor didnt show 2024-02-14 14:53:10 +01:00
Jon Eugster 5bb6c559bc update npm deps 2024-02-07 13:03:48 +01:00
Jon Eugster 11ee6c1535 bump npm dependencies 2024-02-07 12:54:10 +01:00
Jon Eugster 3998fb2fc9 Merge branch 'main' into dev 2024-02-07 12:51:39 +01:00
Jon Eugster 538f74004c allow for empty lines in editor 2024-02-07 12:50:39 +01:00
Jon Eugster 6aebb8993f update proof from editor 2024-02-07 12:50:39 +01:00
Jon Eugster 6472ef5b31 First big junk of communication refactor 2024-02-07 12:50:39 +01:00
Jon Eugster 72ffab5b46 cleanup InteractiveGoal 2024-02-07 12:50:39 +01:00
Jon Eugster 3b660c5185 Merge branch 'dev'. Bump to v4.5.0 2024-02-02 12:44:32 +01:00
Jon Eugster ebb8c98145 bump to v4.5.0 2024-02-02 12:43:58 +01:00
Jon Eugster 4abf05b77e Merge branch 'dev' into v4.5.0-bump 2024-02-02 12:11:55 +01:00
Jon Eugster df423aaace Merge pull request #181 from Wzixiao/auto-mobile-layout
Modify logic for preferences
2024-01-26 12:06:29 +01:00
Jon Eugster a67dcb306f fix indent 2024-01-26 11:55:00 +01:00
Jon Eugster 37582e04d4 fix errors for disabled tactics #188 2024-01-26 11:54:36 +01:00
ran 9d4a6df139 Remove MobileContext and use PreferencesContext instead 2024-01-23 22:59:43 +08:00
Jon Eugster 26202e5f36 fix typo 2024-01-22 14:09:54 +01:00
Jon Eugster ed017fa605 documentation & cleanup in FileWorker 2024-01-22 14:06:52 +01:00
Jon Eugster 36bc52c960 add logic game to landing page 2024-01-22 13:31:43 +01:00
Jon Eugster e085f2f106 Merge pull request #186 from Geoc2022/main
Cleaned up Documentation
2024-01-22 13:26:30 +01:00
Jon Eugster e975c455fe Update doc/DOCUMENTATION.md 2024-01-22 12:44:12 +01:00
geo c006be5a9f Fix minor typos 2024-01-20 16:15:15 -05:00
geo 49379a0930 Fix link references 2024-01-20 16:02:46 -05:00
geo 03076b0161 Add example npm start message 2024-01-19 10:52:26 -05:00
geo bfb60ffedd Updated "Doc entries" 2024-01-19 10:41:20 -05:00
Jon Eugster ca576542ba whitelist generalizing and says. #173 2024-01-19 10:53:43 +01:00
Jon Eugster fd5e507541 lint: fix line widths 2024-01-19 10:50:48 +01:00
Jon Eugster c103eeacfa fix: case insensitive url in local store #183 2024-01-18 14:57:50 +01:00
Jon Eugster e277a48749 add changelog file 2024-01-18 14:19:32 +01:00
Jon Eugster 16ff701518 Update troubleshoot.md 2024-01-18 13:51:33 +01:00
Jon Eugster 20ca385e14 Update README.md 2024-01-18 13:48:43 +01:00
Jon Eugster 5a38543e3b Merge pull request #185 from leanprover-community/joneugster-patch-1
Update docs
2024-01-18 13:48:08 +01:00
Jon Eugster a4a6f2e725 Merge pull request #184 from leanprover-community/joneugster-patch-1-1
Create troubleshoot.md
2024-01-18 13:47:48 +01:00
Jon Eugster cc89e78e4c Create troubleshoot.md 2024-01-18 13:47:16 +01:00
Jon Eugster c533a635c2 Update docs 2024-01-18 13:47:08 +01:00
ran ab98eaa3ba Change the selected button to a slider 2024-01-16 11:21:46 +08:00
Jon Eugster 7114a8c4cb fix: remove tmp files after import 2024-01-15 12:15:12 +01:00
Jon Eugster b7eb15184d Merge pull request #182 from lnay/patch-1
Minor: correct error in markdown
2024-01-15 11:24:46 +01:00
Luke Naylor 892c70165a Minor: correct error in markdown 2024-01-12 21:07:19 +00:00
ran 8929813e48 Added a missing space regarding code style in store.ts 2024-01-12 23:48:47 +08:00
ran 5d88cd6739 Remove logically duplicated code 2024-01-12 23:47:04 +08:00
ran d16956da9b Modify the props definition of the preference popup component 2024-01-12 23:43:21 +08:00
ran e15e5af126 Add todo about setMobile 2024-01-12 23:31:22 +08:00
ran 8c93b3c5b3 Modify logic for all preferences 2024-01-12 23:29:03 +08:00
Jon Eugster cd1d212a3c npm audit 2024-01-10 15:40:57 +01:00
Jon Eugster 406c2799b1 bump to v4.5.0-rc1 2023-12-25 12:07:56 +01:00
Jon Eugster e579071a3b Merge branch 'v4.4.0' 2023-12-25 12:01:49 +01:00
Jon Eugster 8cdac88b5a bump to v4.4.0 2023-12-22 13:28:49 +01:00
Jon Eugster 7882958f54 Update update_game.md 2023-12-18 16:20:26 +01:00
Alexander Bentkamp c9f97b3285 remove watchdog 2023-12-15 22:17:09 +01:00
Jon Eugster 614b762b6c rename LemmaDoc into TheoremDoc and so on 2023-12-15 16:05:49 +01:00
Jon Eugster c4f4dbdc6b update doc #171 2023-12-15 00:04:54 +01:00
Jon Eugster 5a78118bb6 improve display of buttons on mobile 2023-12-14 23:04:18 +01:00
Jon Eugster a6775d5495 move 'show more help'-button on mobile #143 2023-12-14 22:13:44 +01:00
Jon Eugster 03a370464b fix altTitle for backwards compatibility 2023-12-14 22:06:53 +01:00
Jon Eugster eaa214ec37 improve world tree on mobile #101 2023-12-14 21:59:47 +01:00
Jon Eugster d7f1f70d41 Merge pull request #168 from Wzixiao/mobile-option
Mobile option (template)
2023-12-14 21:53:08 +01:00
Jon Eugster 2d0f69d337 fix: prevent multiple NewTactic per level #125 2023-12-14 21:44:36 +01:00
Jon Eugster 1ab50710f5 fix: update rules slider on erasing progress #157 2023-12-14 21:24:08 +01:00
Jon Eugster e76e287763 fix: persistent lemma tab #144 2023-12-14 21:14:42 +01:00
Jon Eugster 5fd49abb90 improve hover text for inventory items #144 2023-12-14 21:05:37 +01:00
Jon Eugster 4b7d540a80 update fortawesome packages to matching versions 2023-12-14 20:49:38 +01:00
Jon Eugster aec196deff add button to copy inventory item name to clipboard #144 2023-12-14 20:26:31 +01:00
Jon Eugster ae636a03ed fix: hide hidden inventory items in overview #169 2023-12-14 19:01:11 +01:00
Jon Eugster deec431620 Merge branch 'dev' into mobile-option 2023-12-13 15:20:24 +01:00
ran 9f692ccf61 Add basic preference framework and mobile options and Add missing files 2023-12-13 00:37:50 +08:00
ran 47d7f606c6 Add basic preference framework and mobile options 2023-12-13 00:34:38 +08:00
Jon Eugster 5aa0764844 add comment 2023-12-12 14:42:02 +01:00
Jon Eugster 93c55dc9f7 Merge pull request #167 from Wzixiao/home-button-margin
Adjust the margin of the home button.
2023-12-12 13:43:21 +01:00
Jon Eugster aab7441323 Merge pull request #166 from Wzixiao/app-bar-align-center
Adjust the top navigation bar to center both the Home button and some divs on the right.
2023-12-12 13:42:00 +01:00
ran 2ba36b91d5 Adjust the margin of the home button 2023-12-12 19:51:20 +08:00
ran a2726ae287 Adjust the top navigation bar so that it is centered above and below 2023-12-12 19:29:21 +08:00
ran 7515561883 Add a button to toggle mobile 2023-12-12 19:17:55 +08:00
joneugster 8c39fb6664 cleanup; including using doc comment syntax for documentation like TacticDoc 2023-12-11 19:38:58 +01:00
Jon Eugster 5f52e23f29 Update hints.md 2023-12-11 16:04:37 +01:00
Jon Eugster b067dea6e7 Update README.md 2023-12-09 23:38:18 +01:00
Jon Eugster 4edf67f0d6 Update README.md 2023-12-09 23:37:59 +01:00
Jon Eugster 004f81835f write more docs 2023-12-09 23:34:39 +01:00
Jon Eugster 527f58e3a4 separate lean server from socket server 2023-12-09 22:42:40 +01:00
Jon Eugster b239a5d3dc Merge branch 'main' into dev 2023-12-09 22:09:58 +01:00
Jon Eugster 63cf5e8b72 update docs 2023-12-09 22:09:05 +01:00
Jon Eugster 25f166f57f do not filter hidden hints #142 2023-12-09 22:06:12 +01:00
Jon Eugster c89e2e4020 remove consequtive identical hints #142 2023-12-09 13:27:03 +01:00
Jon Eugster f6738faf46 golf 2023-12-09 11:14:55 +01:00
Jon Eugster cb7224934c fix cwd for gameserver 2023-12-08 21:06:31 +01:00
Jon Eugster 13d54ff0ff fix gameserver path 2023-12-08 20:38:35 +01:00
Jon Eugster 72e4011c62 update vite 2023-12-08 18:35:29 +01:00
Jon Eugster 4f5256fa88 fix bubblewrap script 2023-12-08 18:32:45 +01:00
Jon Eugster c2b9175fe5 use the gameserver of each game individually 2023-12-08 18:14:35 +01:00
Jon Eugster a1a6862b5a add tmp option to test images 2023-12-08 10:18:00 +01:00
Jon Eugster 0a057913be update landing page 2023-12-08 03:16:26 +01:00
Jon Eugster bedb2ad5ec Update README.md 2023-12-08 02:22:10 +01:00
Jon Eugster e02e73c1c0 Update DOCUMENTATION.md 2023-12-08 02:17:30 +01:00
Jon Eugster c82a88867f Update create_game.md 2023-12-08 02:12:09 +01:00
Jon Eugster 8b43aed596 Update publish_game.md 2023-12-08 01:57:47 +01:00
Jon Eugster 8c84d3fae7 Merge branch 'dev' 2023-12-08 01:54:02 +01:00
Jon Eugster d5697d052e images folder might not exist 2023-12-08 00:58:17 +01:00
Jon Eugster 0e652256f8 Revert "update lean4web dependency"
This reverts commit 6bced7575b.
2023-12-08 00:51:28 +01:00
Jon Eugster 9492c1011a Revert "use pre-cleanup commit of lean4web"
This reverts commit 3f8b180b04.
2023-12-08 00:51:17 +01:00
Jon Eugster 54bab2a016 add support for images 2023-12-08 00:39:45 +01:00
Alexander Bentkamp 3f8b180b04 use pre-cleanup commit of lean4web 2023-12-07 21:38:51 +01:00
Jon Eugster 23b1074aa2 cleanup tmp files after import 2023-12-07 18:09:12 +01:00
Jon Eugster 0964a06f2f Merge pull request #159 from Wzixiao/cache-typewriter-mode
Add the function of caching typewriterMode
2023-12-07 16:00:19 +01:00
ran b17c8fc4cb remove \n in progress.ts 211 line 2023-12-07 21:45:47 +08:00
ran 0c4ae92856 Optimize the typewriterMode code logic about the game 2023-12-07 21:43:50 +08:00
ran 088711b5d1 Use 'progress' to construct the processing flow of typewriterMode 2023-12-07 21:38:15 +08:00
Jon Eugster 4c93b3a091 Update running_locally.md 2023-12-07 14:10:03 +01:00
ran aa00e359c4 Add the function of caching typewriterMode 2023-12-07 15:06:58 +08:00
joneugster f6a2632d80 fix error message on importing non-existing game tile 2023-12-07 02:29:56 +01:00
joneugster f2190d648f fix manual dependencies being added twice to svg 2023-12-07 02:28:57 +01:00
joneugster d7fd8709cb fix inventory sorting #158 2023-12-07 02:27:24 +01:00
joneugster 7a03c4fe0d move landing page tiles to the games 2023-12-06 18:21:37 +01:00
Jon Eugster 333c9498f1 Update update_game.md 2023-12-01 14:50:27 +01:00
joneugster 121b36b542 bump to v4.3.0 2023-12-01 12:19:58 +01:00
Alexander Bentkamp 6bced7575b update lean4web dependency 2023-12-01 11:32:17 +01:00
Alexander Bentkamp d18b48db2f make json files directly accessible 2023-11-30 20:55:03 +01:00
Alexander Bentkamp 97dc648452 use goal lctx for hints
Closes  #135
2023-11-30 20:38:25 +01:00
joneugster 472e2c66df remove old-nng tile 2023-11-30 20:29:47 +01:00
Jon Eugster 622e9d3897 Update update_game.md 2023-11-30 19:32:38 +01:00
joneugster 7f91ae7da8 Merge branch 'dev' 2023-11-30 19:29:18 +01:00
joneugster 28a7c65db2 update doc, including #148 2023-11-30 19:26:42 +01:00
joneugster 56525c6234 update doc 2023-11-30 17:39:29 +01:00
joneugster 44f7b6703e Revert "fix variables in hints"
This reverts commit 8851cd8b1f.
2023-11-30 12:16:06 +01:00
joneugster 8851cd8b1f fix variables in hints 2023-11-30 11:18:56 +01:00
Alexander Bentkamp bae360874c json api 2023-11-30 10:51:03 +01:00
joneugster 07b6525c58 fix local setup 2023-11-30 09:20:16 +01:00
joneugster 8e8026aa38 more fixing 2023-11-29 15:17:16 +01:00
joneugster a3a421f504 fix bubblewrap script 2023-11-29 15:14:00 +01:00
joneugster 084e25c0dc wip on imports 2023-11-29 14:43:33 +01:00
Alexander Bentkamp 6580afb622 fix names in hints
Fixes #135
2023-11-28 20:51:44 +01:00
joneugster 0c0a7ab400 bubblewrap test 2023-11-28 16:32:19 +01:00
joneugster dfb2f10219 correct std version 2023-11-28 16:27:20 +01:00
Alexander Bentkamp a57a5af111 also display loading of constants 2023-11-28 14:53:05 +01:00
joneugster 946a7fa673 update gitignore 2023-11-28 14:26:41 +01:00
Alexander Bentkamp 244c373192 model.dispose() 2023-11-28 14:05:15 +01:00
Alexander Bentkamp 374afa318a fix gameserver path 2023-11-28 13:55:24 +01:00
joneugster d0636b1d85 update workflow 2023-11-28 13:45:13 +01:00
joneugster 2e121363b3 update workflow 2023-11-28 13:45:13 +01:00
Alexander Bentkamp 590d68ccfb gitignore .lake 2023-11-28 11:43:55 +01:00
Alexander Bentkamp f6727e5c9f loading progress 2023-11-28 11:43:32 +01:00
joneugster 241ef4b67a bump to v4.3.0-rc2 2023-11-28 11:29:40 +01:00
joneugster ea4250f38d modify message of import script 2023-11-28 11:26:07 +01:00
joneugster 1d2420331d make URL case insensitive 2023-11-28 11:05:50 +01:00
joneugster 2213862998 disable bad try catch 2023-11-27 22:48:46 +01:00
joneugster 81073b24b8 fix bubblewrap script 2023-11-27 22:42:31 +01:00
joneugster 33068f1183 improve error message 2023-11-27 22:10:15 +01:00
joneugster 8ab37d4344 disable queue 2023-11-27 22:06:16 +01:00
joneugster 9d9902cce1 edit pm2 config 2023-11-27 21:29:22 +01:00
joneugster f53316c591 fix paths 2023-11-27 20:57:18 +01:00
joneugster 427ce43e95 fix import mechanism 2023-11-27 17:22:02 +01:00
joneugster 15d79244d4 update workflow 2023-11-27 16:50:45 +01:00
joneugster f13027f75c update workflow 2023-11-27 16:47:35 +01:00
joneugster 5fcdee4f71 import seems to be working 2023-11-27 16:40:38 +01:00
joneugster 7ad23dab24 work on importing 2023-11-22 15:54:45 +01:00
joneugster 89e19cc019 update lean4web and npm packages 2023-11-22 13:14:18 +01:00
joneugster 4db5260ed8 shrink nng image 2023-11-20 12:31:37 +01:00
joneugster d19046aebd fix dist path 2023-11-20 12:27:23 +01:00
joneugster 6e0fcb1d50 fixes for vite 2023-11-20 11:19:25 +01:00
joneugster 04c70ba522 fix for STG on landing page 2023-11-18 12:12:19 +01:00
joneugster 1b3993ad3e fix 2023-11-17 22:45:02 +01:00
joneugster 45a95bbdc4 update landing page 2023-11-17 22:27:29 +01:00
joneugster 7c9f3d7a0a remove token 2023-11-17 16:43:42 +01:00
joneugster 823000d5d4 partial implementation of loading mechanism 2023-11-17 13:10:07 +01:00
joneugster c706b66af1 update doc to use -K option for local setup 2023-11-14 21:52:23 +01:00
Jon Eugster 933394bb6f Update running_locally.md 2023-11-10 11:16:25 +01:00
Jon Eugster e09c016c4c Update update_game.md 2023-11-10 11:15:53 +01:00
87 changed files with 5770 additions and 6570 deletions
-1
View File
@@ -1 +0,0 @@
LEAN4GAME_SINGLE_GAME=false
+14 -2
View File
@@ -1,11 +1,23 @@
name: Build
run-name: Build the project
on: [push]
on:
workflow_dispatch:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: install elan
run: |
set -o pipefail
curl -sSfL https://github.com/leanprover/elan/releases/download/v3.0.0/elan-x86_64-unknown-linux-gnu.tar.gz | tar xz
./elan-init -y
echo "$HOME/.elan/bin" >> $GITHUB_PATH
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- name: print lean and lake versions
run: |
lean --version
lake --version
- run: npm install
- run: npm run build
+2 -5
View File
@@ -1,8 +1,5 @@
node_modules
client/dist
server/build
server/lakefile.olean
server32bit
**/lake-packages/
games/
server/.lake
**/.DS_Store
client/public/server.*
+7
View File
@@ -0,0 +1,7 @@
GameSkeleton
HiddenTactic
subgoals
KaTex
gameserver
lakefile
Zulip
+24 -14
View File
@@ -1,6 +1,6 @@
# Lean 4 Game
This is the source code for a Lean 4 game platform hosted at [adam.math.hhu.de](https://adam.math.hhu.de).
This is the source code for a Lean game platform hosted at [adam.math.hhu.de](https://adam.math.hhu.de).
## Creating a Game
@@ -8,21 +8,30 @@ Please follow the tutorial [Creating a Game](doc/create_game.md). In particular,
* Step 5: [How to Run Games Locally](doc/running_locally.md)
* Step 7: [How to Update an existing Game](doc/update_game.md)
### Publishing a Game
We encourage anybody to have games hosted on our [Lean Game Server](https://adam.math.hhu.de) for anybody to play. For that you simply need to contact us with the link to your game repo. We are also happy to add work-in-progress games and games in any language.
For example, you can [contact Jon on Zulip](https://leanprover.zulipchat.com/#narrow/dm/385895-Jon-Eugster). Or [via Email](https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster).
* Step 8: [How to Publishing a Game](doc/publish_game.md)
* [Troubleshooting](doc/troubleshoot.md)
## Documentation
The documentation for the game engine itself is still missing, but there is [Creating a Game](doc/create_game.md) explaining the API to create a game.
The documentation is very much work in progress but the linked documentation here
should be up-to-date:
Some documentation:
### Game creation API
- [NPM Scripts](doc/npm_scripts.md)
- [Old documentation](doc/DOCUMENTATION.md)
- [Creating a Game](doc/create_game.md): **the main document to consult**.
- [More about Hints](doc/hints.md): describes the `Hint` and `Branch` tactic.
### Frontend API
* [How to Run Games Locally](doc/running_locally.md): play a game on your computer
* [How to Update an existing Game](doc/update_game.md): update to a new lean version
* [How to Publishing a Game](doc/publish_game.md): load your game to adam.math.hhu.de for others to play
### Backend
not fully written yet.
* [Server](doc/DOCUMENTATION.md): describes the server part (i.e. the content of `server/` und `relay/`).
## Contributing
@@ -34,7 +43,8 @@ Providing the use access to a Lean instance running on the server is a severe se
## Credits
The project is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
The project has pimarily been developed by Alexander Bentkamp and Jon Eugster.
It is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
(NNG)](https://www.ma.imperial.ac.uk/~buzzard/xena/natural_number_game/)
of Kevin Buzzard and Mohammad Pedramfar.
The project is based on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4).
by Kevin Buzzard and Mohammad Pedramfar, and on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4).
-94
View File
@@ -1,94 +0,0 @@
var stderrBuffer = ""
var messageBuffer = []
var initialized = false;
var flushing = false;
var headerMode = true;
var header="";
var re = /Content-Length: (\d+)\r\n/i;
var contentLength = 0;
var content = []
var utf8decoder = new TextDecoder();
function flushMessageBuffer(){
if (initialized && !flushing) {
while(messageBuffer.length > 0) {
flushing = true;
var msg = messageBuffer.shift();
console.log(`Send message: ${msg}`);
Module.ccall('send_message', 'void', ['string'], [msg]);
console.log(`Message done: ${msg}`);
}
flushing = false;
}
}
var Module = {
"arguments": ["--worker"],
"preRun": [function() {
function stdin() {
return null;
}
function stdout(asciiCode) {
if (headerMode) {
header += String.fromCharCode(asciiCode)
if (header.endsWith('\r\n\r\n')) {
const found = header.match(re)
if (found == null) { console.error(`Invalid header: ${header}`) }
contentLength = parseInt(found[1])
content = []
headerMode = false
}
} else {
content.push(asciiCode)
if (content.length == contentLength) {
const message = utf8decoder.decode(new Uint8Array(content))
console.log(`Server: ${message}`)
postMessage(message);
headerMode = true
header = ''
}
}
}
function stderr(asciiCode) {
stderrBuffer += String.fromCharCode(asciiCode)
}
FS.init(stdin, stdout, stderr);
}],
"noInitialRun": true,
"onRuntimeInitialized": () => {
Module.ccall('main', 'void', [], []);
initialized = true;
if (stderrBuffer !== "") {
console.log(stderrBuffer);
stderrBuffer = ""
}
flushMessageBuffer();
}
};
importScripts("server.js")
onmessage = (ev) => {
console.log(`Client: ${ev.data}`)
messageBuffer.push(ev.data);
flushMessageBuffer();
}
setInterval(() => {
if (stderrBuffer !== "") {
console.log(stderrBuffer);
stderrBuffer = ""
}
}, 1000)
setTimeout(() =>{
},2000)
+8 -7
View File
@@ -8,23 +8,24 @@ import '@fontsource/roboto/700.css';
import './css/reset.css';
import './css/app.css';
import { MobileContext } from './components/infoview/context';
import { useWindowDimensions } from './window_width';
import { PreferencesContext} from './components/infoview/context';
import UsePreferences from "./state/hooks/use_preferences"
export const GameIdContext = React.createContext<string>(undefined);
function App() {
const params = useParams()
const gameId = "g/" + params.owner + "/" + params.repo
const {width, height} = useWindowDimensions()
const [mobile, setMobile] = React.useState(width < 800)
const {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences} = UsePreferences()
return (
<div className="app">
<GameIdContext.Provider value={gameId}>
<MobileContext.Provider value={{mobile, setMobile}}>
<Outlet />
</MobileContext.Provider>
<PreferencesContext.Provider value={{mobile, layout, isSavePreferences, setLayout, setIsSavePreferences}}>
<Outlet />
</PreferencesContext.Provider>
</GameIdContext.Provider>
</div>
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

+12 -8
View File
@@ -5,9 +5,9 @@ import * as React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons'
faCircleInfo, faTerminal, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from "../app"
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
import { InputModeContext, PreferencesContext, WorldLevelIdContext } from "./infoview/context"
import { GameInfo, useGetGameInfoQuery } from '../state/api'
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
import { useAppDispatch, useAppSelector } from '../hooks'
@@ -150,22 +150,23 @@ function InventoryButton({pageNumber, setPageNumber}) {
}
/** the navigation bar on the welcome page */
export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpressum, toggleEraseMenu, toggleUploadMenu, toggleInfo} : {
export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpressum, toggleEraseMenu, toggleUploadMenu, toggleInfo, togglePreferencesPopup} : {
pageNumber: number,
setPageNumber: any,
gameInfo: GameInfo,
toggleImpressum: any,
toggleEraseMenu: any,
toggleUploadMenu: any,
toggleInfo: any
toggleInfo: any,
togglePreferencesPopup: () => void;
}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useAppSelector(selectProgress(gameId))
const {mobile} = React.useContext(MobileContext)
const {mobile} = React.useContext(PreferencesContext)
const [navOpen, setNavOpen] = React.useState(false)
return <div className="app-bar">
<div>
<div className='app-bar-left'>
<Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
@@ -194,6 +195,9 @@ export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpres
<Button title="Impressum, privacy policy" inverted="true" to="" onClick={() => {toggleImpressum(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum
</Button>
<Button title="Preferences" inverted="true" to="" onClick={() => {togglePreferencesPopup(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faGear} />&nbsp;Preferences
</Button>
</div>
</div>
}
@@ -208,7 +212,7 @@ export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=
}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const {mobile} = React.useContext(PreferencesContext)
const [navOpen, setNavOpen] = React.useState(false)
const gameInfo = useGetGameInfoQuery({game: gameId})
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
@@ -237,7 +241,7 @@ export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=
</> :
<>
{/* DESKTOP VERSION */}
<div>
<div className='app-bar-left'>
<HomeButton isDropdown={false} />
<span className="app-bar-title">{worldTitle && `World: ${worldTitle}`}</span>
</div>
+77 -5
View File
@@ -1,21 +1,42 @@
import { GameHint } from "./infoview/rpc_api";
import { GameHint, InteractiveGoalsWithHints, ProofState } from "./infoview/rpc_api";
import * as React from 'react';
import Markdown from './markdown';
import { DeletedChatContext, ProofContext } from "./infoview/context";
import { lastStepHasErrors } from "./infoview/goals";
import { Button } from "./button";
/** Plug-in the variable names in a hint. We do this client-side to prepare
* for i18n in the future. i.e. one should be able translate the `rawText`
* and have the variables substituted just before displaying.
*/
function getHintText(hint: GameHint): string {
if (hint.rawText) {
// Replace the variable names used in the hint with the ones used by the player
// variable names are marked like `«{g}»` inside the text.
return hint.rawText.replaceAll(/«\{(.*?)\}»/g, ((_, v) =>
// `hint.varNames` contains tuples `[oldName, newName]`
(hint.varNames.find(x => x[0] == v))[1]))
} else {
// hints created in the frontend do not have a `rawText`
// TODO: `hint.text` could be removed in theory.
return hint.text
}
}
export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}>
<Markdown>{hint.text}</Markdown>
<Markdown>{getHintText(hint)}</Markdown>
</div>
}
export function HiddenHint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
return <div className={`message warning step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}>
<Markdown>{hint.text}</Markdown>
<Markdown>{getHintText(hint)}</Markdown>
</div>
}
export function Hints({hints, showHidden, step, selected, toggleSelection, lastLevel} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
if (!hints) {return <></>}
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
@@ -28,7 +49,7 @@ export function Hints({hints, showHidden, step, selected, toggleSelection, lastL
export function DeletedHint({hint} : {hint: GameHint}) {
return <div className="message information deleted-hint">
<Markdown>{hint.text}</Markdown>
<Markdown>{getHintText(hint)}</Markdown>
</div>
}
@@ -43,3 +64,54 @@ export function DeletedHints({hints} : {hints: GameHint[]}) {
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
</>
}
/** Filter hints to not show consequtive identical hints twice.
* Hidden hints are not filtered.
*/
export function filterHints(hints: GameHint[], prevHints: GameHint[]): GameHint[] {
if (!hints) {
return []}
else if (!prevHints) {
return hints }
else {
return hints.filter((hint) => hint.hidden ||
(prevHints.find(x => (x.text == hint.text && x.hidden == hint.hidden)) === undefined)
)
}
}
function hasHiddenHints(step: InteractiveGoalsWithHints): boolean {
return step?.goals[0]?.hints.some((hint) => hint.hidden)
}
export function MoreHelpButton({selected=null} : {selected?: number}) {
const {proof, setProof} = React.useContext(ProofContext)
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = React.useContext(DeletedChatContext)
let k = (selected === null) ? (proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)) : selected
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.steps.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k)) {
tmp.delete(k)
} else {
tmp.add(k)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
if (hasHiddenHints(proof.steps[k]) && !showHelp.has(k)) {
return <Button to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
}
+46 -37
View File
@@ -3,45 +3,48 @@
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
import { InteractiveDiagnostic } from '@leanprover/infoview-api';
import { GameHint, InteractiveGoal, InteractiveTermGoal,InteractiveGoalsWithHints, ProofState } from './rpc_api';
import { PreferencesState } from '../../state/preferences';
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
null as any)
export type InfoStatus = 'updating' | 'error' | 'ready';
/** One step of the proof */
export type ProofStep = {
/** The command in this step */
command : string
/** List of goals *after* this command */
goals: InteractiveGoal[] // TODO: Add correct type
/** Story relevant messages */
hints: GameHint[] // TODO: Add correct type
/** Errors and warnings */
errors: InteractiveDiagnostic[] // TODO: Add correct type
}
// /** One step of the proof */
// export type ProofStep = {
// /** The command in this step */
// command : string
// /** List of goals *after* this command */
// goals: InteractiveGoal[] // TODO: Add correct type
// /** Story relevant messages */
// hints: GameHint[] // TODO: Add correct type
// /** Errors and warnings */
// errors: InteractiveDiagnostic[] // TODO: Add correct type
// }
/** The context storing the proof step-by-step for the command line mode */
export const ProofContext = React.createContext<{
/** The proof consists of multiple steps that are processed one after the other.
* In particular multi-line terms like `match`-statements will not be supported.
*
* Note that the first step will always have `null` as command
* Note that the first step will always have "" as command
*/
proof: ProofStep[],
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
proof: ProofState,
setProof: React.Dispatch<React.SetStateAction<ProofState>>
}>({
proof: [],
setProof: () => {} // TODO: implement me
proof: {steps: [], diagnostics: [], completed: false},
setProof: () => {}
})
// TODO: Do we still need that?
export interface ProofStateProps {
// pos: DocumentPosition;
status: InfoStatus;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
goals?: InteractiveGoalsWithHints;
termGoal?: InteractiveTermGoal;
error?: string;
// userWidgets: UserWidgetInstance[];
@@ -49,25 +52,31 @@ export interface ProofStateProps {
// triggerUpdate: () => Promise<void>;
}
export const ProofStateContext = React.createContext<{
proofState : ProofStateProps,
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
}>({
proofState : {
status: 'updating',
messages: [],
goals: undefined,
termGoal: undefined,
error: undefined},
setProofState: () => {},
})
// export const ProofStateContext = React.createContext<{
// proofState : ProofStateProps,
// setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
// }>({
// proofState : {
// status: 'updating',
// messages: [],
// goals: undefined,
// termGoal: undefined,
// error: undefined},
// setProofState: () => {},
// })
export const MobileContext = React.createContext<{
mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
}>({
mobile : false,
setMobile: () => {},
export interface IPreferencesContext extends PreferencesState{
mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout.
setLayout: React.Dispatch<React.SetStateAction<PreferencesState["layout"]>>;
setIsSavePreferences: React.Dispatch<React.SetStateAction<Boolean>>;
}
export const PreferencesContext = React.createContext<IPreferencesContext>({
mobile: false,
layout: "auto",
isSavePreferences: false,
setLayout: () => {},
setIsSavePreferences: () => {}
})
export const WorldLevelIdContext = React.createContext<{
+123 -7
View File
@@ -10,7 +10,10 @@ import { Locations, LocationsContext, SelectableLocation } from '../../../../nod
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
import { InputModeContext } from './context';
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpc_api';
import { InteractiveGoal, InteractiveGoals, InteractiveGoalsWithHints, InteractiveHypothesisBundle, ProofState } from './rpc_api';
import { RpcSessionAtPos } from '@leanprover/infoview/*';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { DiagnosticSeverity } from 'vscode-languageserver-protocol';
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */
function isInaccessibleName(h: string): boolean {
@@ -39,7 +42,11 @@ function goalToString(g: InteractiveGoal): string {
}
export function goalsToString(goals: InteractiveGoals): string {
return goals.goals.map(goalToString).join('\n\n')
return goals.goals.map(g => goalToString(g)).join('\n\n')
}
export function goalsWithHintsToString(goals: InteractiveGoalsWithHints): string {
return goals.goals.map(g => goalToString(g.goal)).join('\n\n')
}
interface GoalFilterState {
@@ -255,16 +262,16 @@ export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
})
interface GoalsProps {
goals: InteractiveGoals
goals: InteractiveGoalsWithHints
filter: GoalFilterState
}
export function Goals({ goals, filter }: GoalsProps) {
if (goals.goals.length === 0) {
return <>No goals</>
return <></>
} else {
return <>
{goals.goals.map((g, i) => <Goal typewriter={false} key={i} goal={g} filter={filter} />)}
{goals.goals.map((g, i) => <Goal typewriter={false} key={i} goal={g.goal} filter={filter} />)}
</>
}
}
@@ -276,7 +283,7 @@ interface FilteredGoalsProps {
* When this is `undefined`, the component will not appear at all but will remember its state
* by virtue of still being mounted in the React tree. When it does appear again, the filter
* settings and collapsed state will be as before. */
goals?: InteractiveGoals
goals?: InteractiveGoalsWithHints
}
/**
@@ -291,7 +298,7 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
data-id="copy-goal-to-comment"
onClick={e => {
e.preventDefault();
if (goals) void ec.copyToComment(goalsToString(goals))
if (goals) void ec.copyToComment(goalsWithHintsToString(goals))
}}
title="copy state to comment" />
@@ -336,3 +343,112 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
</details>
</div>
})
export function loadGoals(
rpcSess: RpcSessionAtPos,
uri: string,
setProof: React.Dispatch<React.SetStateAction<ProofState>>) {
console.info('sending rpc request to load the proof state')
rpcSess.call('Game.getProofState', DocumentPosition.toTdpp({line: 0, character: 0, uri: uri})).then(
(proof : ProofState) => {
console.info(`received a proof state!`)
console.log(proof)
setProof(proof)
// let tmpProof : ProofStep[] = []
// let goalCount = 0
// steps.map((goals, i) => {
// // The first step has an empty command and therefore also no error messages
// // Usually there is a newline at the end of the editors content, so we need to
// // display diagnostics from potentally two lines in the last step.
// let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// // Filter out the 'unsolved goals' message
// messages = messages.filter((msg) => {
// return !("append" in msg.message &&
// "text" in msg.message.append[0] &&
// msg.message.append[0].text === "unsolved goals")
// })
// if (typeof goals == 'undefined') {
// tmpProof.push({
// command: i ? model.getLineContent(i) : '',
// goals: [],
// hints: [],
// errors: messages
// } as ProofStep)
// console.debug('goals is undefined')
// return
// }
// // If the number of goals reduce, show a message
// if (goals.length && goalCount > goals.length) {
// messages.unshift({
// range: {
// start: {
// line: i-1,
// character: 0,
// },
// end: {
// line: i-1,
// character: 0,
// }},
// severity: DiagnosticSeverity.Information,
// message: {
// text: 'intermediate goal solved 🎉'
// }
// })
// }
// goalCount = goals.length
// // with no goals there will be no hints.
// let hints : GameHint[] = goals.length ? goals[0].hints : []
// console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
// console.debug(`Goals: (${i}): `, goalsToString(goals)) //
// console.debug(`Hints: (${i}): `, hints)
// console.debug(`Errors: (${i}): `, messages)
// tmpProof.push({
// // the command of the line above. Note that `getLineContent` starts counting
// // at `1` instead of `zero`. The first ProofStep will have an empty command.
// command: i ? model.getLineContent(i) : '',
// // TODO: store correct data
// goals: goals.map(g => g.goal),
// // only need the hints of the active goals in chat
// hints: hints,
// // errors and messages from the server
// errors: messages
// } as ProofStep)
// })
// // Save the proof to the context
// setProof(tmpProof)
}
)
}
export function lastStepHasErrors (proof : ProofState): boolean {
if (!proof?.steps.length) {return false}
let diags = [...proof.steps[proof.steps.length - 1].diags, ...proof.diagnostics]
return diags.some(
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
)
}
export function isLastStepWithErrors (proof : ProofState, i: number): boolean {
if (!proof?.steps.length) {return false}
return (i == proof.steps.length - 1) && lastStepHasErrors(proof)
}
+24 -11
View File
@@ -4,7 +4,7 @@ import * as React from 'react'
import { CircularProgress } from '@mui/material'
import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api'
RpcErrorCode, getInteractiveDiagnostics } from '@leanprover/infoview-api'
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util'
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
@@ -14,7 +14,7 @@ import { GoalsLocation, Locations, LocationsContext } from '../../../../node_mod
import { AllMessages, lspDiagToInteractive } from './messages'
import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals'
import { InteractiveGoals } from './rpc_api'
import { InteractiveTermGoal, InteractiveGoalsWithHints, InteractiveGoals, ProofState } from './rpc_api'
import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context'
// TODO: All about pinning could probably be removed
@@ -83,11 +83,11 @@ interface InfoDisplayContentProps extends PausableProps {
error?: string
userWidgets: UserWidgetInstance[]
triggerUpdate: () => Promise<void>
proof? : string
proofString? : string
}
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proofString} = props
const hasWidget = userWidgets.length > 0
const hasError = !!error
@@ -114,7 +114,8 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true }
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
return <>
return <>
{hasError &&
<div className='error' key='errors'>
Error updating:{' '}{error}.
@@ -137,7 +138,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
{userWidgets.map(widget =>
<details key={`widget::${widget.id}::${widget.range?.toString()}`} open>
<summary className='mv2 pointer'>{widget.name}</summary>
<PanelWidgetDisplay pos={pos} goals={goals ? goals.goals.map (goal => goal) : []}
<PanelWidgetDisplay pos={pos} goals={goals ? goals.goals : []}
termGoal={termGoal} selectedLocations={selectedLocs} widget={widget}/>
</details>
)}
@@ -166,6 +167,7 @@ interface InfoDisplayProps {
pos: DocumentPosition,
status: InfoStatus,
messages: InteractiveDiagnostic[],
proof?: ProofState,
goals?: InteractiveGoals,
termGoal?: InteractiveTermGoal,
error?: string,
@@ -175,7 +177,7 @@ interface InfoDisplayProps {
}
/** Displays goal state and messages. Can be paused. */
function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable) {
function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
// Used to update the paused state *just once* if it is paused,
// but a display update is triggered
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
@@ -214,7 +216,7 @@ function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable)
{/* <details open> */}
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
<div className="vscode-light">
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
<InfoDisplayContent {...props} proofString={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
</div>
{/* </details> */}
</RpcContext.Provider>
@@ -252,7 +254,7 @@ function useIsProcessingAt(p: DocumentPosition): boolean {
function InfoAux(props: InfoProps) {
const proofContext = React.useContext(ProofContext)
const { setProof } = React.useContext(ProofContext)
const config = React.useContext(ConfigContext)
@@ -290,6 +292,8 @@ function InfoAux(props: InfoProps) {
// with e.g. a new `pos`.
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
const proofReq = rpcSess.call('Game.getProofState', DocumentPosition.toTdpp(pos))
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos))
const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
@@ -308,6 +312,7 @@ function InfoAux(props: InfoProps) {
pos,
status: 'updating',
messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined,
termGoal: undefined,
error: undefined,
@@ -318,11 +323,12 @@ function InfoAux(props: InfoProps) {
// NB: it is important to await await reqs at once, otherwise
// if both throw then one exception becomes unhandled.
Promise.all([goalsReq, termGoalReq, widgetsReq, messagesReq]).then(
([goals, termGoal, userWidgets, messages]) => resolve({
Promise.all([proofReq, goalsReq, termGoalReq, widgetsReq, messagesReq]).then(
([proof, goals, termGoal, userWidgets, messages]) => resolve({
pos,
status: 'ready',
messages,
proof : proof as any,
goals: goals as any,
termGoal,
error: undefined,
@@ -353,6 +359,7 @@ function InfoAux(props: InfoProps) {
pos,
status: 'error',
messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined,
termGoal: undefined,
error: `Error fetching goals: ${errorString}`,
@@ -389,6 +396,7 @@ function InfoAux(props: InfoProps) {
pos,
status: 'updating',
messages: [],
proof: undefined,
goals: undefined,
termGoal: undefined,
error: undefined,
@@ -412,6 +420,11 @@ function InfoAux(props: InfoProps) {
// hintContext.setHints(state.value.goals.goals[0].hints)
// }
setDisplayProps({ ...state.value, triggerUpdate })
// Update the game's proof state
console.info('updating proof from editor mode.')
setProof(state.value.proof)
} else if (state.state === 'rejected' && state.error !== 'retry') {
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
console.warn('Unreachable code reached with error: ', state.error)
+149 -107
View File
@@ -26,15 +26,16 @@ import Markdown from '../markdown';
import { Infos } from './infos';
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
import { Goal } from './goals';
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context';
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter';
import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from './goals';
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, SelectionContext, WorldLevelIdContext } from './context';
import { Typewriter, getInteractiveDiagsAt, hasErrors, hasInteractiveErrors } from './typewriter';
import { InteractiveDiagnostic } from '@leanprover/infoview/*';
import { Button } from '../button';
import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api';
import { GameHint, InteractiveGoalsWithHints, ProofState } from './rpc_api';
import { store } from '../../state/store';
import { Hints } from '../hints';
import { Hints, MoreHelpButton, filterHints } from '../hints';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start.
@@ -61,36 +62,35 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
const gameId = React.useContext(GameIdContext)
const { typewriterMode } = React.useContext(InputModeContext)
// Mark level as completed when server gives notification
const {proof, setProof} = React.useContext(ProofContext)
const dispatch = useAppDispatch()
useServerNotificationEffect(
'$/game/completed',
(params: any) => {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.uri) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
// On completion, add the names of all new items to the local storage
let newTiles = [
...level?.tactics,
...level?.lemmas,
...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name)
React.useEffect(() => {
if (proof.completed) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
// Add the proven statement to the local storage as well.
if (level?.statementName != null) {
newTiles.push(level?.statementName)
}
// On completion, add the names of all new items to the local storage
let newTiles = [
...level?.tactics,
...level?.lemmas,
...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name)
let inv: string[] = selectInventory(gameId)(store.getState())
// add new items and remove duplicates
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
dispatch(changedInventory({ game: gameId, inventory: newInv }))
// Add the proven statement to the local storage as well.
if (level?.statementName != null) {
newTiles.push(level?.statementName)
}
}, [level]
)
let inv: string[] = selectInventory(gameId)(store.getState())
// add new items and remove duplicates
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
dispatch(changedInventory({ game: gameId, inventory: newInv }))
}
}, [proof, level])
/* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
@@ -154,9 +154,22 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
const { proof, setProof } = React.useContext(ProofContext)
const {selectedStep, setSelectedStep} = React.useContext(SelectionContext)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
console.debug(`template: ${props.data.template}`)
function toggleSelection(line: number) {
return (ev) => {
console.debug('toggled selection')
if (selectedStep == line) {
setSelectedStep(undefined)
} else {
setSelectedStep(line)
}
}
}
console.debug(`template: ${props.data?.template}`)
// React.useEffect (() => {
// if (props.data.template) {
@@ -182,6 +195,19 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
const curPos: DocumentPosition | undefined =
useEventResult(ec.events.changedCursorLocation, loc => loc ? { uri: loc.uri, ...loc.range.start } : undefined)
// Effect when the cursor changes in the editor
React.useEffect(() => {
// TODO: this is a bit of a hack and will yield unexpected behaviour if lines
// are indented.
let newPos = curPos?.line + (curPos?.character == 0 ? 0 : 1)
// scroll the chat along
setSelectedStep(newPos)
}, [curPos])
useClientNotificationEffect(
'textDocument/didClose',
(params: DidCloseTextDocumentParams) => {
@@ -206,8 +232,17 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
} else {
ret = <div className="infoview vscode-light">
{completed && <div className="level-completed">Level completed! 🎉</div>}
{proof.completedWithWarnings &&
<div className="level-completed">
{proof.completed ? "Level completed! 🎉" : "Level completed with warnings 🎭"}
</div>
}
<Infos />
<Hints hints={proof.steps[curPos?.line]?.goals[0]?.hints}
showHidden={showHelp.has(curPos?.line)} step={curPos?.line}
selected={selectedStep} toggleSelection={toggleSelection(curPos?.line)}
lastLevel={curPos?.line == proof.steps.length - 1}/>
<MoreHelpButton selected={curPos?.line}/>
</div>
}
@@ -223,15 +258,24 @@ const goalFilter = {
}
/** The display of a single entered lean command */
function Command({ command, deleteProof }: { command: string, deleteProof: any }) {
function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) {
// The first step will always have an empty command
if (!command) { return <></> }
return <div className="command">
<div className="command-text">{command}</div>
<Button to="" className="undo-button btn btn-inverted" title="Retry proof from here" onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry
</Button>
</div>
if (!proof?.steps[i]?.command) { return <></> }
if (isLastStepWithErrors(proof, i)) {
// If the last step has errors, we display the command in a different style
// indicating that it will be removed on the next try.
return <div className="failed-command">
<i>Failed command</i>: {proof.steps[i].command}
</div>
} else {
return <div className="command">
<div className="command-text">{proof.steps[i].command}</div>
<Button to="" className="undo-button btn btn-inverted" title="Retry proof from here" onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry
</Button>
</div>
}
}
// const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
@@ -286,10 +330,14 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// }, fastIsEqual)
/** The tabs of goals that lean ahs after the command of this step has been processed */
function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: ProofStep, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) {
function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: InteractiveGoalsWithHints, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) {
const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
if (proofStep.goals.length == 0) {
return <></>
}
return <div className="goal-tabs" onClick={onClick}>
<div className={`tab-bar ${last ? 'current' : ''}`}>
{proofStep.goals.map((goal, i) => (
@@ -300,7 +348,7 @@ function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofSte
))}
</div>
<div className="goal-tab vscode-light">
<Goal typewriter={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} />
<Goal typewriter={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]?.goal} />
</div>
</div>
}
@@ -347,14 +395,14 @@ export function TypewriterInterface({props}) {
const uri = model.uri.toString()
const [disableInput, setDisableInput] = React.useState<boolean>(false)
const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
const {mobile} = React.useContext(MobileContext)
const { proof } = React.useContext(ProofContext)
const {mobile} = React.useContext(PreferencesContext)
const { proof, setProof } = React.useContext(ProofContext)
const { setTypewriterInput } = React.useContext(InputModeContext)
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
// const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
// const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
@@ -366,9 +414,11 @@ export function TypewriterInterface({props}) {
function deleteProof(line: number) {
return (ev) => {
let deletedChat: Array<GameHint> = []
proof.slice(line).map((step, i) => {
proof.steps.slice(line).map((step, i) => {
let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
// Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
})
setDeletedChat(deletedChat)
@@ -381,7 +431,9 @@ export function TypewriterInterface({props}) {
forceMoveMarkers: false
}])
setSelectedStep(undefined)
setTypewriterInput(proof[line].command)
setTypewriterInput(proof.steps[line].command)
// Reload proof on deleting
loadGoals(rpcSess, uri, setProof)
ev.stopPropagation()
}
}
@@ -401,7 +453,7 @@ export function TypewriterInterface({props}) {
// Scroll to the end of the proof if it is updated.
React.useEffect(() => {
if (proof?.length > 1) {
if (proof.steps.length > 1) {
proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
} else {
proofPanelRef.current?.scrollTo(0,0)
@@ -422,38 +474,19 @@ export function TypewriterInterface({props}) {
}
}, [selectedStep])
// TODO: This about hidden hints is all copied from `level.tsx`. Can we move that into `hints.tsx`?
// TODO: superfluous, can be replaced with `withErr` from above
let lastStepErrors = proof.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof.steps.length)) : false
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
useServerNotificationEffect("$/game/loading", (params : any) => {
if (params.kind == "loadConstants") {
setLoadingProgress(params.counter/100*50)
} else if (params.kind == "finalizeExtensions") {
setLoadingProgress(50 + params.counter/150*50)
} else {
tmp.add(k - withErr)
console.error(`Unknown loading kind: ${params.kind}`)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
let lastStepErrors = proof.length ? hasInteractiveErrors(proof[proof.length - 1].errors) : false
})
return <div className="typewriter-interface">
<RpcContext.Provider value={rpcSess}>
@@ -462,41 +495,44 @@ export function TypewriterInterface({props}) {
</div>
<div className='proof' ref={proofPanelRef}>
<ExerciseStatement data={props.data} />
{proof.length ?
{proof.steps.length ?
<>
{proof.map((step, i) => {
if (i == proof.length - 1 && lastStepErrors) {
// if the last command contains an error, we only display the errors but not the
// entered command as it is still present in the command line.
// TODO: Should not use index as key.
return <div key={`proof-step-${i}`}>
<Errors errors={step.errors} typewriterMode={true} />
</div>
} else {
{proof.steps.map((step, i) => {
let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
// if (i == proof.steps.length - 1 && hasInteractiveErrors(step.diags)) {
// // if the last command contains an error, we only display the errors but not the
// // entered command as it is still present in the command line.
// // TODO: Should not use index as key.
// return <div key={`proof-step-${i}`} className={`step step-${i}`}>
// <Errors errors={step.diags} typewriterMode={true} />
// </div>
// } else {
return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')}>
<Command command={step.command} deleteProof={deleteProof(i)} />
<Errors errors={step.errors} typewriterMode={true} />
<Command proof={proof} i={i} deleteProof={deleteProof(i)} />
<Errors errors={step.diags} typewriterMode={true} />
{mobile && i == 0 && props.data?.introduction &&
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}>
<Markdown>{props.data?.introduction}</Markdown>
</div>
}
{mobile && <>
{mobile &&
<Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i}
hints={filteredHints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
{i == proof.length - 1 && hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button className="btn btn-help" to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
</>
}
<GoalsTabs proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/>
{/* <GoalsTabs proofStep={step} last={i == proof.steps.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.steps.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/> */}
{!(isLastStepWithErrors(proof, i)) &&
<GoalsTabs proofStep={step} last={i == proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1) ? (n) => setDisableInput(n > 0) : (n) => {}}/>
}
{mobile && i == proof.steps.length - 1 &&
<MoreHelpButton />
}
{/* Show a message that there are no goals left */}
{!step.goals.length && (
{/* {!step.goals.length && (
<div className="message information">
{completed ?
{proof.completed ?
<p>Level completed! 🎉</p> :
<p>
<b>no goals left</b><br />
@@ -504,12 +540,18 @@ export function TypewriterInterface({props}) {
</p>
}
</div>
)}
)} */}
</div>
}
})}
{mobile && completed &&
<div className="button-row">
//}
)}
{proof.diagnostics.length > 0 &&
<div key={`proof-step-remaining`} className="step step-remaining">
<Errors errors={proof.diagnostics} typewriterMode={true} />
</div>
}
{mobile && proof.completed &&
<div className="button-row mobile">
{props.level >= props.worldSize ?
<Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
@@ -521,11 +563,11 @@ export function TypewriterInterface({props}) {
}
</div>
}
</> : <CircularProgress />
</> : <CircularProgress variant="determinate" value={loadingProgress} />
}
</div>
</div>
<Typewriter hidden={!withErr && proof[proof.length - 1]?.goals.length == 0} disabled={disableInput || !proof.length}/>
<Typewriter disabled={disableInput || !proof.steps.length}/>
</RpcContext.Provider>
</div>
}
+11 -3
View File
@@ -194,16 +194,24 @@ export function AllMessages() {
</a>
</span>
</summary> */}
<AllMessagesBody uri={curPos.uri} key={curPos.uri} messages={iDiags0} />
<AllMessagesBody uri={curPos.uri} key={curPos.uri} messages={iDiags0} curPos={curPos} />
{/* </Details> */}
</RpcContext.Provider>
)
}
/** We factor out the body of {@link AllMessages} which lazily fetches its contents only when expanded. */
function AllMessagesBody({uri, messages}: {uri: DocumentUri, messages: () => Promise<InteractiveDiagnostic[]>}) {
function AllMessagesBody({uri, curPos, messages}: {uri: DocumentUri, curPos: DocumentPosition | undefined , messages: () => Promise<InteractiveDiagnostic[]>}) {
const [msgs, setMsgs] = React.useState<InteractiveDiagnostic[] | undefined>(undefined)
React.useEffect(() => { void messages().then(setMsgs) }, [messages])
React.useEffect(() => { void messages().then(
msgs => setMsgs(msgs.filter(
(d)=>{
//console.log(`message start: ${d.range.start.line}. CurPos: ${curPos.line}`)
// Only show the messages from the line where the cursor is.
return d.range.start.line == curPos.line
}))
) }, [messages, curPos])
if (msgs === undefined) return <div>Loading messages...</div>
else return <MessagesList uri={uri} messages={msgs}/>
}
+48 -12
View File
@@ -3,46 +3,82 @@
*
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
*/
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
export interface GameHint {
text: string;
hidden: boolean;
}
import type { Range } from 'vscode-languageserver-protocol';
import type { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
import { InteractiveDiagnostic, TermInfo } from '@leanprover/infoview/*';
import type { Diagnostic } from 'vscode-languageserver-protocol';
export interface InteractiveHypothesisBundle {
/** The pretty names of the variables in the bundle. Anonymous names are rendered
* as `"[anonymous]"` whereas inaccessible ones have a `✝` appended at the end.
* Use `InteractiveHypothesisBundle_nonAnonymousNames` to filter anonymouse ones out. */
names: string[];
/** Present since server version 1.1.2. */
fvarIds?: FVarId[];
type: CodeWithInfos;
val?: CodeWithInfos;
isInstance?: boolean;
isType?: boolean;
isAssumption?: boolean;
isInserted?: boolean;
isRemoved?: boolean;
isAssumption?: boolean;
}
export interface InteractiveGoalCore {
hyps: InteractiveHypothesisBundle[];
type: CodeWithInfos;
/** Present since server version 1.1.2. */
ctx?: ContextInfo;
}
export interface InteractiveGoal extends InteractiveGoalCore {
userName?: string;
goalPrefix?: string;
/** Present since server version 1.1.2. */
mvarId?: MVarId;
isInserted?: boolean;
isRemoved?: boolean;
}
export interface InteractiveGoals extends InteractiveGoalCore {
goals: InteractiveGoals[];
}
export interface InteractiveTermGoal extends InteractiveGoalCore {
range?: Range;
term?: TermInfo;
}
export interface GameHint {
text: string;
hidden: boolean;
rawText: string;
varNames: string[][]; // in Lean: `Array (Name × Name)`
}
export interface InteractiveGoalWithHints {
goal: InteractiveGoal;
hints: GameHint[];
}
export interface InteractiveGoals {
goals: InteractiveGoal[];
export interface InteractiveGoalsWithHints {
goals: InteractiveGoalWithHints[];
command: string;
diags: InteractiveDiagnostic[];
}
/**
* The proof state as it is received from the server.
* Per proof step of the tactic proof, there is one `InteractiveGoalWithHints[]`.
*/
export interface ProofState {
/** The proof steps. step 0 is the state at the beginning of the proof. step one
* contains the goal after the first line has been evaluated.
*
* In particular `step[i]` is the proof step at the beginning of line `i` in vscode.
*/
steps: InteractiveGoalsWithHints[];
/** The remaining diagnostics that are not in the steps. Usually this should only
* be the "unsolved goals" message, I believe.
*/
diagnostics : InteractiveDiagnostic[];
completed : Boolean;
completedWithWarnings : Boolean;
}
+131 -91
View File
@@ -5,7 +5,7 @@ import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { Registry } from 'monaco-textmate' // peer dependency
import { wireTmGrammars } from 'monaco-editor-textmate'
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol';
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
@@ -13,13 +13,21 @@ import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
import languageConfig from 'lean4/language-configuration.json';
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api';
import { InteractiveDiagnostic, RpcSessionAtPos, getInteractiveDiagnostics } from '@leanprover/infoview-api';
import { Diagnostic } from 'vscode-languageserver-types';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { RpcContext } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context'
import { goalsToString } from './goals'
import { GameHint, InteractiveGoals } from './rpc_api'
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext } from './context'
import { goalsToString, lastStepHasErrors, loadGoals } from './goals'
import { GameHint, ProofState } from './rpc_api'
export interface GameDiagnosticsParams {
uri: DocumentUri;
diagnostics: Diagnostic[];
}
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
@@ -64,7 +72,7 @@ config.autoClosingPairs = config.autoClosingPairs.map(
monaco.languages.setLanguageConfiguration('lean4cmd', config);
/** The input field */
export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boolean}) {
export function Typewriter({disabled}: {disabled?: boolean}) {
/** Reference to the hidden multi-line editor */
const editor = React.useContext(MonacoEditorContext)
@@ -89,98 +97,98 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
/** Load all goals an messages of the current proof (line-by-line) and save
* the retrieved information into context (`ProofContext`)
*/
const loadAllGoals = React.useCallback(() => {
// const loadAllGoals = React.useCallback(() => {
let goalCalls = []
let msgCalls = []
// let goalCalls = []
// let msgCalls = []
// For each line of code ask the server for the goals and the messages on this line
for (let i = 0; i < model.getLineCount(); i++) {
goalCalls.push(
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
)
msgCalls.push(
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1}).catch((error) => {console.debug("promise broken")})
)
}
// // For each line of code ask the server for the goals and the messages on this line
// for (let i = 0; i < model.getLineCount(); i++) {
// goalCalls.push(
// rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
// )
// msgCalls.push(
// getInteractiveDiagnostics(rpcSess, {start: i, end: i+1}).catch((error) => {console.debug("promise broken")})
// )
// }
// Wait for all these requests to be processed before saving the results
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => {
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
let tmpProof : ProofStep[] = []
// // Wait for all these requests to be processed before saving the results
// Promise.all(goalCalls).then((steps : InteractiveGoalsWithHints[]) => {
// Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
// let tmpProof : ProofStep[] = []
let goalCount = 0
// let goalCount = 0
steps.map((goals, i) => {
// The first step has an empty command and therefore also no error messages
// Usually there is a newline at the end of the editors content, so we need to
// display diagnostics from potentally two lines in the last step.
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// steps.map((goals, i) => {
// // The first step has an empty command and therefore also no error messages
// // Usually there is a newline at the end of the editors content, so we need to
// // display diagnostics from potentally two lines in the last step.
// let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// Filter out the 'unsolved goals' message
messages = messages.filter((msg) => {
return !("append" in msg.message &&
"text" in msg.message.append[0] &&
msg.message.append[0].text === "unsolved goals")
})
// // Filter out the 'unsolved goals' message
// messages = messages.filter((msg) => {
// return !("append" in msg.message &&
// "text" in msg.message.append[0] &&
// msg.message.append[0].text === "unsolved goals")
// })
if (typeof goals == 'undefined') {
tmpProof.push({
command: i ? model.getLineContent(i) : '',
goals: [],
hints: [],
errors: messages
} as ProofStep)
console.debug('goals is undefined')
return
}
// if (typeof goals == 'undefined') {
// tmpProof.push({
// command: i ? model.getLineContent(i) : '',
// goals: [],
// hints: [],
// errors: messages
// } as ProofStep)
// console.debug('goals is undefined')
// return
// }
// If the number of goals reduce, show a message
if (goals.goals.length && goalCount > goals.goals.length) {
messages.unshift({
range: {
start: {
line: i-1,
character: 0,
},
end: {
line: i-1,
character: 0,
}},
severity: DiagnosticSeverity.Information,
message: {
text: 'intermediate goal solved 🎉'
}
})
}
goalCount = goals.goals.length
// // If the number of goals reduce, show a message
// if (goals.length && goalCount > goals.length) {
// messages.unshift({
// range: {
// start: {
// line: i-1,
// character: 0,
// },
// end: {
// line: i-1,
// character: 0,
// }},
// severity: DiagnosticSeverity.Information,
// message: {
// text: 'intermediate goal solved 🎉'
// }
// })
// }
// goalCount = goals.length
// with no goals there will be no hints.
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
// // with no goals there will be no hints.
// let hints : GameHint[] = goals.length ? goals[0].hints : []
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
console.debug(`Goals: (${i}): `, goalsToString(goals)) //
console.debug(`Hints: (${i}): `, hints)
console.debug(`Errors: (${i}): `, messages)
// console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
// console.debug(`Goals: (${i}): `, goalsToString(goals)) //
// console.debug(`Hints: (${i}): `, hints)
// console.debug(`Errors: (${i}): `, messages)
tmpProof.push({
// the command of the line above. Note that `getLineContent` starts counting
// at `1` instead of `zero`. The first ProofStep will have an empty command.
command: i ? model.getLineContent(i) : '',
// TODO: store correct data
goals: goals.goals,
// only need the hints of the active goals in chat
hints: hints,
// errors and messages from the server
errors: messages
} as ProofStep)
// tmpProof.push({
// // the command of the line above. Note that `getLineContent` starts counting
// // at `1` instead of `zero`. The first ProofStep will have an empty command.
// command: i ? model.getLineContent(i) : '',
// // TODO: store correct data
// goals: goals.map(g => g.goal),
// // only need the hints of the active goals in chat
// hints: hints,
// // errors and messages from the server
// errors: messages
// } as ProofStep)
})
// Save the proof to the context
setProof(tmpProof)
}).catch((error) => {console.debug("promise broken")})
}).catch((error) => {console.debug("promise broken")})
}, [editor, rpcSess, uri, model])
// })
// // Save the proof to the context
// setProof(tmpProof)
// }).catch((error) => {console.debug("promise broken")})
// }).catch((error) => {console.debug("promise broken")})
// }, [editor, rpcSess, uri, model])
// Run the command
const runCommand = React.useCallback(() => {
@@ -201,6 +209,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
forceMoveMarkers: false
}])
setTypewriterInput('')
// Load proof after executing edits
loadGoals(rpcSess, uri, setProof)
}
editor.setPosition(pos)
@@ -212,9 +222,15 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
}
}, [typewriterInput])
/* Load proof on start/switching to typewriter */
useEffect(() => {
if (proof.length && hasInteractiveErrors(proof[proof.length - 1].errors)) {
setTypewriterInput(proof[proof.length - 1].command)
loadGoals(rpcSess, uri, setProof)
}, [])
/** If the last step has an error, add the command to the typewriter. */
useEffect(() => {
if (lastStepHasErrors(proof)) {
setTypewriterInput(proof.steps[proof.steps.length - 1].command)
}
}, [proof])
@@ -222,7 +238,9 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (params.uri == uri) {
setProcessing(false)
loadAllGoals()
//loadGoals(rpcSess, uri, setProof)
// TODO: loadAllGoals()
if (!hasErrors(params.diagnostics)) {
//setTypewriterInput("")
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
@@ -236,6 +254,15 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// loadAllGoals()
}, [uri]);
// React when answer from the server comes back
useServerNotificationEffect('$/game/publishDiagnostics', (params: GameDiagnosticsParams) => {
console.log('Received game diagnostics')
console.log(`diag. uri : ${params.uri}`)
console.log(params.diagnostics)
}, [uri]);
useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, {
value: typewriterInput,
@@ -306,7 +333,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useEffect(() => {
console.debug(`time to update: ${uri} \n ${rpcSess}`)
console.debug(rpcSess)
loadAllGoals()
// console.debug('LOAD ALL GOALS')
// TODO: loadAllGoals()
}, [rpcSess])
/** Process the entered command */
@@ -315,7 +343,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
runCommand()
}
return <div className={`typewriter${hidden ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}>
// do not display if the proof is completed (with potential warnings still present)
return <div className={`typewriter${proof.completedWithWarnings ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}>
<form onSubmit={handleSubmit}>
<div className="typewriter-input-wrapper">
<div ref={inputRef} className="typewriter-input" />
@@ -343,3 +372,14 @@ export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
)
}
export function getInteractiveDiagsAt (proof: ProofState, k : number) {
if (k == 0) {
return []
} else if (k >= proof.steps.length-1) {
// TODO: Do we need that?
return proof.diagnostics.filter(msg => msg.range.start.line >= proof.steps.length-1)
} else {
return proof.diagnostics.filter(msg => msg.range.start.line == k-1)
}
}
+45 -22
View File
@@ -2,7 +2,8 @@ import * as React from 'react';
import { useState, useEffect } from 'react';
import '../css/inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan } from '@fortawesome/free-solid-svg-icons'
import { faLock, faBan, faCheck } from '@fortawesome/free-solid-svg-icons'
import { faClipboard } from '@fortawesome/free-regular-svg-icons'
import { GameIdContext } from '../app';
import Markdown from './markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
@@ -10,10 +11,12 @@ import { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store';
import { useSelector } from 'react-redux';
export function Inventory({levelInfo, openDoc, enableAll=false} :
export function Inventory({levelInfo, openDoc, lemmaTab, setLemmaTab, enableAll=false} :
{
levelInfo: LevelInfo|InventoryOverview,
openDoc: (props: {name: string, type: string}) => void,
lemmaTab: any,
setLemmaTab: any,
enableAll?: boolean,
}) {
@@ -31,19 +34,20 @@ export function Inventory({levelInfo, openDoc, enableAll=false} :
}
<h2>Theorems</h2>
{levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} level={levelInfo} enableAll={enableAll} tab={lemmaTab} setTab={setLemmaTab}/>
}
</div>
)
}
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} :
function InventoryList({items, docType, openDoc, tab=null, setTab=undefined, level=undefined, enableAll=false} :
{
items: InventoryTile[],
docType: string,
openDoc(props: {name: string, type: string}): void,
defaultTab? : string,
level? : LevelInfo|InventoryOverview,
tab?: any,
setTab?: any,
level?: LevelInfo|InventoryOverview,
enableAll?: boolean,
}) {
// TODO: `level` is only used in the `useEffect` below to check if a new level has
@@ -59,8 +63,6 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
}
const categories = Array.from(categorySet).sort()
const [tab, setTab] = useState(defaultTab)
// Add inventory items from local store as unlocked.
// Items are unlocked if they are in the local store, or if the server says they should be
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
@@ -68,13 +70,6 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
let inv: string[] = selectInventory(gameId)(store.getState())
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (defaultTab) {
setTab(defaultTab)
}}, [level])
return <>
{categories.length > 1 &&
<div className="tab-bar">
@@ -86,24 +81,29 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
{[...modifiedItems].sort(
// For lemas, sort entries `available > disabled > locked`
// otherwise alphabetically
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName)
).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => {
return <InventoryItem key={`${item.category}-${item.name}`}
item={item}
showDoc={() => {openDoc({name: item.name, type: docType})}}
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
disabled={item.disabled} newly={item.new} enableAll={enableAll}/>
disabled={item.disabled} newly={item.new} enableAll={enableAll} />
})
}
</div>
</>
}
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
disabled ? <FontAwesomeIcon icon={faBan} /> : item.st
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
// Note: This is somewhat a hack as the statement of lemmas comes currently in the form
// `Namespace.statement_name (x y : Nat) : some type`
const title = locked ? "Not unlocked yet" :
disabled ? "Not available in this level" : ""
disabled ? "Not available in this level" : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
const [copied, setCopied] = useState(false)
const handleClick = () => {
if (enableAll || !locked) {
@@ -111,7 +111,21 @@ function InventoryItem({name, displayName, locked, disabled, newly, showDoc, ena
}
}
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
const copyItemName = (ev) => {
navigator.clipboard.writeText(displayName)
setCopied(true)
setInterval(() => {
setCopied(false)
}, 3000);
ev.stopPropagation()
}
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>
{icon} {displayName}
<div className="copy-button" onClick={copyItemName}>
{copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />}
</div>
</div>
}
export function Documentation({name, type, handleClose}) {
@@ -131,16 +145,25 @@ export function Documentation({name, type, handleClose}) {
export function InventoryPanel({levelInfo, visible = true}) {
const gameId = React.useContext(GameIdContext)
const [lemmaTab, setLemmaTab] = useState(levelInfo?.lemmaTab)
// The inventory is overlayed by the doc entry of a clicked item
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() {setInventoryDoc(null)}
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (levelInfo?.lemmaTab) {
setLemmaTab(levelInfo?.lemmaTab)
}}, [levelInfo])
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
{inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
:
<Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true}/>
<Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true} lemmaTab={lemmaTab} setLemmaTab={setLemmaTab}/>
}
</div>
}
+92 -67
View File
@@ -7,11 +7,12 @@ import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '../css/landing_page.css'
import coverRobo from '../assets/covers/formaloversum.png'
import bgImage from '../assets/bg.jpg'
import Markdown from './markdown';
import {PrivacyPolicyPopup} from './popup/privacy_policy'
import { GameTile, useGetGameInfoQuery } from '../state/api'
import path from 'path';
const flag = {
'Dutch': '🇳🇱',
@@ -19,6 +20,7 @@ const flag = {
'French': '🇫🇷',
'German': '🇩🇪',
'Italian': '🇮🇹',
'Spanish': '🇪🇸',
}
function GithubIcon({url='https://github.com'}) {
@@ -32,47 +34,42 @@ function GithubIcon({url='https://github.com'}) {
</div>
}
function GameTile({
title,
gameId,
intro, // Catchy intro phrase.
image=null,
worlds='?',
levels='?',
prereq='&ndash;', // Optional list of games that this game builds on. Use markdown.
description, // Longer description. Supports Markdown.
language}) {
function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) {
let navigate = useNavigate();
const routeChange = () =>{
navigate(gameId);
}
if (typeof data === 'undefined') {
return <></>
}
return <div className="game" onClick={routeChange}>
<div className="wrapper">
<div className="title">{title}</div>
<div className="short-description">{intro}
<div className="title">{data.title}</div>
<div className="short-description">{data.short}
</div>
{ image ? <img className="image" src={image} alt="" /> : <div className="image"/> }
<div className="long description"><Markdown>{description}</Markdown></div>
{ data.image ? <img className="image" src={path.join("data", gameId, data.image)} alt="" /> : <div className="image"/> }
<div className="long description"><Markdown>{data.long}</Markdown></div>
</div>
<table className="info">
<tbody>
<tr>
<td title="consider playing these games first.">Prerequisites</td>
<td><Markdown>{prereq}</Markdown></td>
<td><Markdown>{data.prerequisites.join(', ')}</Markdown></td>
</tr>
<tr>
<td>Worlds</td>
<td>{worlds}</td>
<td>{data.worlds}</td>
</tr>
<tr>
<td>Levels</td>
<td>{levels}</td>
<td>{data.levels}</td>
</tr>
<tr>
<td>Language</td>
<td title={`in ${language}`}>{flag[language]}</td>
<td title={`in ${data.languages.join(', ')}`}>{data.languages.map((lan) => flag[lan]).join(', ')}</td>
</tr>
</tbody>
</table>
@@ -88,6 +85,59 @@ function LandingPage() {
const openImpressum = () => setImpressum(true);
const closeImpressum = () => setImpressum(false);
// const [allGames, setAllGames] = React.useState([])
// const [allTiles, setAllTiles] = React.useState([])
// const getTiles=()=>{
// fetch('featured_games.json', {
// headers : {
// 'Content-Type': 'application/json',
// 'Accept': 'application/json'
// }
// }
// ).then(function(response){
// return response.json()
// }).then(function(data) {
// setAllGames(data.featured_games)
// })
// }
// React.useEffect(()=>{
// getTiles()
// },[])
// React.useEffect(()=>{
// Promise.allSettled(
// allGames.map((gameId) => (
// fetch(`data/g/${gameId}/game.json`).catch(err => {return undefined})))
// ).then(responses =>
// responses.forEach((result) => console.log(result)))
// // Promise.all(responses.map(res => {
// // if (res.status == "fulfilled") {
// // console.log(res.value.json())
// // return res.value.json()
// // } else {
// // return undefined
// // }
// // }))
// // ).then(allData => {
// // setAllTiles(allData.map(data => data?.tile))
// // })
// },[allGames])
// TODO: I would like to read the supported games list form a JSON,
// Then load all these games in
//
let allGames = [
"leanprover-community/nng4",
"hhu-adam/robo",
"djvelleman/stg4",
"miguelmarco/stg4",
"trequetrum/lean4game-logic",
]
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
return <div className="landing-page">
<header style={{backgroundImage: `url(${bgImage})`}}>
@@ -104,49 +154,26 @@ function LandingPage() {
</div>
</header>
<div className="game-list">
<GameTile
title="Natural Number Game"
gameId="g/hhu-adam/NNG4"
intro="The classical introduction game for Lean."
description="In this game you recreate the natural numbers $\mathbb{N}$ from the Peano axioms,
learning the basics about theorem proving in Lean.
This is a good first introduction to Lean!"
worlds="4"
levels="30"
language="English"
/>
<GameTile
title="Formaloversum"
gameId="g/hhu-adam/Robo"
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
description="
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
(Das Spiel befindet sich noch in der Entstehungsphase.)"
image={coverRobo}
language="German"
/>
<GameTile
title="NNG (OLD)"
gameId="g/hhu-adam/nng4-old"
intro="The old version of the NNG copied from lean3."
description="This version is not maintained and might break at any point. You should play the new version instead"
worlds="9"
levels="72"
language="English"
/>
{allTiles.length == 0 ?
<p>No Games loaded. Use <a>http://localhost:3000/#/g/local/FOLDER</a> to open a
game directly from a local folder.
</p>
: allGames.map((id, i) => (
<Tile
key={id}
gameId={`g/${id}`}
data={allTiles[i]}
/>
))
}
</div>
<section>
<div className="wrapper">
<h2>Development notes</h2>
<p>
As this server runs lean on our university machines, it has a limited capacity.
Our current estimate is about 55 copies of the NNG or 25 copies of games importing
mathlib. We hope to address this limitation in the future.
Our current estimate is about 70 simultaneous games.
We hope to address and test this limitation better in the future.
</p>
<p>
Most aspects of the games and the infrastructure are still in development. Feel free to
@@ -160,18 +187,19 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
<h2>Adding new games</h2>
<p>
If you are considering writing your own game, you should use
the <a target="_blank" href="https://github.com/hhu-adam/NNG4">NNG Github Repo</a> as
a template.
the <a target="_blank" href="https://github.com/hhu-adam/GameSkeleton">GameSkeleton Github Repo</a> as
a template and read <a target="_blank" href="https://github.com/leanprover-community/lean4game/">How to Create a Game</a>.
</p>
<p>
There is an option to load and run your own games directly on the server,
instructions are in the NNG repo. Since this is still in development we'd like to
encourage you to contact us for support creating your own game. The documentation is
not polished yet.
You can directly load your games into the server and play it using
the correct URL. The <a target="_blank" href="https://github.com/leanprover-community/lean4game/">instructions above</a> also
explain the details for how to load your game to the server.
We'd like to encourage you to contact us if you have any questions.
</p>
<p>
To add games to this main page, you should get in contact as
games will need to be added manually.
Featured games on this page are added manually.
Please get in contact and we-ll happily add yours.
</p>
</div>
</section>
@@ -189,9 +217,6 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
<a className="link" onClick={openImpressum}>Impressum</a>
{impressum? <PrivacyPolicyPopup handleClose={closeImpressum} />: null}
</footer>
{/* <PrivacyPolicy/> */}
</div>
}
+155 -145
View File
@@ -18,22 +18,22 @@ import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-info
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
import { GameIdContext } from '../app'
import { ConnectionContext, connection, useLeanClient } from '../connection'
import { useAppDispatch, useAppSelector } from '../hooks'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api'
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
selectHelp, selectDifficulty, selectInventory, selectTypewriterMode, changeTypewriterMode } from '../state/progress'
import { store } from '../state/store'
import { Button } from './button'
import Markdown from './markdown'
import {InventoryPanel} from './inventory'
import { hasInteractiveErrors } from './infoview/typewriter'
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext,
ProofContext, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints } from './hints'
import { GameHint, InteractiveGoalsWithHints, ProofState } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints, MoreHelpButton, filterHints } from './hints'
import { PrivacyPolicyPopup } from './popup/privacy_policy'
import path from 'path';
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
@@ -43,12 +43,21 @@ import 'lean4web/client/src/editor/infoview.css'
import 'lean4web/client/src/editor/vscode.css'
import '../css/level.css'
import { LevelAppBar } from './app_bar'
import { LeanClient } from 'lean4web/client/src/editor/leanclient'
import { DisposingWebSocketMessageReader } from 'lean4web/client/src/reader'
import { WebSocketMessageWriter, toSocket } from 'vscode-ws-jsonrpc'
import { IConnectionProvider } from 'monaco-languageclient'
import { monacoSetup } from 'lean4web/client/src/monacoSetup'
import { onigasmH } from 'onigasm/lib/onigasmH'
import { isLastStepWithErrors, lastStepHasErrors } from './infoview/goals'
monacoSetup()
function Level() {
const params = useParams()
const levelId = parseInt(params.levelId)
const worldId = params.worldId
// useLoadWorldFiles(worldId)
const [impressum, setImpressum] = React.useState(false)
@@ -64,9 +73,9 @@ function Level() {
</WorldLevelIdContext.Provider>
}
function ChatPanel({lastLevel}) {
function ChatPanel({lastLevel, visible = true}) {
const chatRef = useRef<HTMLDivElement>(null)
const {mobile} = useContext(MobileContext)
const {mobile} = useContext(PreferencesContext)
const gameId = useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
@@ -75,9 +84,7 @@ function ChatPanel({lastLevel}) {
const {selectedStep, setSelectedStep} = useContext(SelectionContext)
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
let k = proof.steps.length - (lastStepHasErrors(proof) ? 2 : 1)
function toggleSelection(line: number) {
return (ev) => {
@@ -90,29 +97,6 @@ function ChatPanel({lastLevel}) {
}
}
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
} else {
tmp.add(k - withErr)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
useEffect(() => {
// TODO: For some reason this is always called twice
console.debug('scroll chat')
@@ -138,24 +122,34 @@ function ChatPanel({lastLevel}) {
let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/)
return <div className="chat-panel">
return <div className={`chat-panel ${visible ? '' : 'hidden'}`}>
<div ref={chatRef} className="chat">
{introText?.filter(t => t.trim()).map(((t, i) =>
// Show the level's intro text as hints, too
<Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} />
))}
{proof.map((step, i) => {
// It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them.
if (!(i == proof.length - 1 && withErr)) {
// TODO: Should not use index as key.
{proof.steps.map((step, i) => {
let filteredHints = filterHints(step.goals[0]?.hints, proof?.steps[i-1]?.goals[0]?.hints)
if (step.goals.length > 0 && !isLastStepWithErrors(proof, i)) {
return <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.length - 1}/>
hints={filteredHints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.steps.length - 1}/>
}
})}
{/* {modifiedHints.map((step, i) => {
// It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them.
if (!(i == proof.steps.length - 1 && withErr)) {
// TODO: Should not use index as key.
return <Hints key={`hints-${i}`}
hints={step} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.steps.length - 1}/>
}
})} */}
<DeletedHints hints={deletedChat}/>
{completed &&
{proof.completed &&
<>
<div className={`message information recent step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
Level completed! 🎉
@@ -169,7 +163,7 @@ function ChatPanel({lastLevel}) {
}
</div>
<div className="button-row">
{completed && (lastLevel ?
{proof.completed && (lastLevel ?
<Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button> :
@@ -177,16 +171,13 @@ function ChatPanel({lastLevel}) {
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>)
}
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
<MoreHelpButton />
</div>
</div>
}
function ExercisePanel({codeviewRef, visible=true}) {
function ExercisePanel({codeviewRef, visible=true}: {codeviewRef: React.MutableRefObject<HTMLDivElement>, visible?: boolean}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
@@ -202,18 +193,21 @@ function PlayableLevel({impressum, setImpressum}) {
const codeviewRef = useRef<HTMLDivElement>(null)
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const {mobile} = React.useContext(PreferencesContext)
const dispatch = useAppDispatch()
const difficulty = useSelector(selectDifficulty(gameId))
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
const inventory: Array<String> = useSelector(selectInventory(gameId))
const typewriterMode = useSelector(selectTypewriterMode(gameId))
const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode}))
const gameInfo = useGetGameInfoQuery({game: gameId})
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
// The state variables for the `ProofContext`
const [proof, setProof] = useState<Array<ProofStep>>([])
const [proof, setProof] = useState<ProofState>({steps: [], diagnostics: [], completed: false, completedWithWarnings: false})
// When deleting the proof, we want to keep to old messages around until
// a new proof has been entered. e.g. to consult messages coming from dead ends
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
@@ -221,12 +215,11 @@ function PlayableLevel({impressum, setImpressum}) {
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
// Only for mobile layout
const [pageNumber, setPageNumber] = useState(0)
const [typewriterMode, setTypewriterMode] = useState(true)
// set to true to prevent switching between typewriter and editor
const [lockInputMode, setLockInputMode] = useState(false)
const [typewriterInput, setTypewriterInput] = useState("")
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
const dispatch = useAppDispatch()
// impressum pop-up
function toggleImpressum() {setImpressum(!impressum)}
@@ -284,12 +277,6 @@ function PlayableLevel({impressum, setImpressum}) {
// a hint at the beginning of the proof...
const [selectedStep, setSelectedStep] = useState<number>()
// if the user inventory changes, notify the server
useEffect(() => {
let leanClient = connection.getLeanClient(gameId)
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, difficulty: difficulty})
}, [inventory])
useEffect (() => {
// Lock editor mode
if (level?.data?.template) {
@@ -320,8 +307,6 @@ function PlayableLevel({impressum, setImpressum}) {
console.debug(`not inserting template.`)
}
}
} else {
setTypewriterMode(true)
}
}, [level, levelId, worldId, gameId, editor])
@@ -336,7 +321,7 @@ function PlayableLevel({impressum, setImpressum}) {
}, [gameId, worldId, levelId])
useEffect(() => {
if (!typewriterMode) {
if (!typewriterMode && editor) {
// Delete last input attempt from command line
editor.executeEdits("typewriter", [{
range: editor.getSelection(),
@@ -349,15 +334,15 @@ function PlayableLevel({impressum, setImpressum}) {
useEffect(() => {
// Forget whether hidden hints are displayed for steps that don't exist yet
if (proof.length) {
if (proof.steps.length) {
console.debug(Array.from(showHelp))
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length))))
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.steps.length))))
}
}, [proof])
// save showed help in store
useEffect(() => {
if (proof.length) {
if (proof.steps.length) {
console.debug(`showHelp:\n ${showHelp}`)
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(showHelp)}))
}
@@ -365,7 +350,7 @@ function PlayableLevel({impressum, setImpressum}) {
// Effect when command line mode gets enabled
useEffect(() => {
if (editor && typewriterMode) {
if (onigasmH && editor && typewriterMode) {
let code = editor.getModel().getLinesContent().filter(line => line.trim())
editor.executeEdits("typewriter", [{
range: editor.getModel().getFullModelRange(),
@@ -388,7 +373,7 @@ function PlayableLevel({impressum, setImpressum}) {
// editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
// }
}
}, [editor, typewriterMode])
}, [editor, typewriterMode, onigasmH == null])
return <>
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
@@ -434,6 +419,7 @@ function PlayableLevel({impressum, setImpressum}) {
function IntroductionPanel({gameInfo}) {
const gameId = React.useContext(GameIdContext)
const {worldId} = useContext(WorldLevelIdContext)
const {mobile} = React.useContext(PreferencesContext)
let text: Array<string> = gameInfo.data?.worlds.nodes[worldId].introduction.split(/\n(\s*\n)+/)
@@ -444,7 +430,7 @@ function IntroductionPanel({gameInfo}) {
hint={{text: t, hidden: false}} step={0} selected={null} toggleSelection={undefined} />
))}
</div>
<div className="button-row">
<div className={`button-row${mobile ? ' mobile' : ''}`}>
{gameInfo.data?.worldSize[worldId] == 0 ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}>
@@ -460,12 +446,17 @@ export default Level
/** The site with the introduction text of a world */
function Introduction({impressum, setImpressum}) {
const gameId = React.useContext(GameIdContext)
const {mobile} = useContext(MobileContext)
const {mobile} = useContext(PreferencesContext)
const inventory = useLoadInventoryOverviewQuery({game: gameId})
const gameInfo = useGetGameInfoQuery({game: gameId})
const {worldId} = useContext(WorldLevelIdContext)
let image: string = gameInfo.data?.worlds.nodes[worldId].image
const toggleImpressum = () => {
setImpressum(!impressum)
}
@@ -479,7 +470,13 @@ function Introduction({impressum, setImpressum}) {
:
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}>
<IntroductionPanel gameInfo={gameInfo} />
<div className="world-image-container empty"></div>
<div className="world-image-container empty">
{image &&
// TODO: Temporary for testing
<img className={worldId=="Proposition" ? "cover" : "contain"} src={path.join("data", gameId, image)} alt="" />
}
</div>
<InventoryPanel levelInfo={inventory?.data} />
</Split>
}
@@ -512,21 +509,32 @@ function Introduction({impressum, setImpressum}) {
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
const connection = React.useContext(ConnectionContext)
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
// Create Editor
const uriStr = `file:///${worldId}/${levelId}`
const uri = monaco.Uri.parse(uriStr)
const inventory: Array<String> = useSelector(selectInventory(gameId))
const difficulty: number = useSelector(selectDifficulty(gameId))
useEffect(() => {
const model = monaco.editor.createModel(initialCode ?? '', 'lean4', uri)
if (onDidChangeContent) {
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
}
const editor = monaco.editor.create(codeviewRef.current!, {
model,
glyphMargin: true,
quickSuggestions: false,
lineDecorationsWidth: 5,
folding: false,
lineNumbers: 'on',
lightbulb: {
enabled: true
},
@@ -538,11 +546,63 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
enabled: false
},
lineNumbersMinChars: 3,
tabSize: 2,
'semanticHighlighting.enabled': true,
theme: 'vs-code-theme-converted'
})
if (onDidChangeSelection) {
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
}
if (initialSelections) {
console.debug("Initial Selection: ", initialSelections)
// BUG: Somehow I get an `invalid arguments` bug here
// editor.setSelections(initialSelections)
}
setEditor(editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
const socketUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + '/websocket/' + gameId
const connectionProvider : IConnectionProvider = {
get: async () => {
return await new Promise((resolve, reject) => {
console.log(`connecting ${socketUrl}`)
const websocket = new WebSocket(socketUrl)
websocket.addEventListener('error', (ev) => {
reject(ev)
})
websocket.addEventListener('message', (msg) => {
// console.log(msg.data)
})
websocket.addEventListener('open', () => {
const socket = toSocket(websocket)
const reader = new DisposingWebSocketMessageReader(socket)
const writer = new WebSocketMessageWriter(socket)
resolve({
reader,
writer
})
})
})
}
}
// Following `vscode-lean4/webview/index.ts`
const client = new LeanClient(connectionProvider, showRestartMessage, {inventory, difficulty})
const infoProvider = new InfoProvider(client)
// const div: HTMLElement = infoviewRef.current!
const imports = {
'@leanprover/infoview': `${window.location.origin}/index.production.min.js`,
'react': `${window.location.origin}/react.production.min.js`,
'react/jsx-runtime': `${window.location.origin}/react-jsx-runtime.production.min.js`,
'react-dom': `${window.location.origin}/react-dom.production.min.js`,
'react-popper': `${window.location.origin}/react-popper.production.min.js`
}
// loadRenderInfoview(imports, [infoProvider.getApi(), div], setInfoviewApi)
setInfoProvider(infoProvider)
// TODO: it looks like we get errors "File Changed" here.
client.restart("Lean4Game")
const editorApi = infoProvider.getApi()
@@ -587,77 +647,27 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
setEditor(editor)
setInfoProvider(infoProvider)
setInfoviewApi(infoviewApi)
return () => { infoProvider.dispose(); editor.dispose() }
}, [])
infoProvider.openPreview(editor, infoviewApi)
const taskgutter = new LeanTaskGutter(infoProvider.client, editor)
const {leanClient, leanClientStarted} = useLeanClient(gameId)
const uriStr = `file:///${worldId}/${levelId}`
const uri = monaco.Uri.parse(uriStr)
// TODO:
// setRestart(() => restart)
// Create model when level changes
useEffect(() => {
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
if (!model) {
model = monaco.editor.createModel(initialCode, 'lean4', uri)
}
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
editor.setModel(model)
if (initialSelections) {
console.debug("Initial Selection: ", initialSelections)
// BUG: Somehow I get an `invalid arguments` bug here
// editor.setSelections(initialSelections)
}
return () => {
editorConnection.api.sendClientNotification(uriStr, "textDocument/didClose", {textDocument: {uri: uriStr}})
}
return () => {
editor.dispose();
model.dispose();
abbrevRewriter.dispose();
taskgutter.dispose();
infoProvider.dispose();
client.dispose();
}
}, [editor, levelId, connection, leanClientStarted])
}, [gameId, worldId, levelId])
useEffect(() => {
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
infoviewApi.serverRestarted(leanClient.initializeResult)
infoProvider.openPreview(editor, infoviewApi)
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
}
}, [editor, connection, leanClientStarted])
const showRestartMessage = () => {
// setRestartMessage(true)
console.log("TODO: SHOW RESTART MESSAGE")
}
return {editor, infoProvider, editorConnection}
}
/** Open all files in this world on the server so that they will load faster when accessed */
function useLoadWorldFiles(worldId) {
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const store = useStore()
useEffect(() => {
if (gameInfo.data) {
const models = []
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
let model = monaco.editor.getModel(uri)
if (model) {
models.push(model)
} else {
const code = selectCode(gameId, worldId, levelId)(store.getState())
models.push(monaco.editor.createModel(code, 'lean4', uri))
}
}
return () => { for (let model of models) { model.dispose() } }
}
}, [gameInfo.data, worldId])
}
@@ -0,0 +1,90 @@
import * as React from 'react'
import { Input, Typography } from '@mui/material'
import Markdown from '../markdown'
import { Switch, Button, ButtonGroup } from '@mui/material';
import Box from '@mui/material/Box';
import Slider from '@mui/material/Slider';
import FormControlLabel from '@mui/material/FormControlLabel';
import { IPreferencesContext } from "../infoview/context"
interface PreferencesPopupProps extends Omit<IPreferencesContext, 'mobile'> {
handleClose: () => void
}
export function PreferencesPopup({ layout, setLayout, isSavePreferences, setIsSavePreferences, handleClose }: PreferencesPopupProps) {
const marks = [
{
value: 0,
label: 'Mobile',
key: "mobile"
},
{
value: 1,
label: 'Auto',
key: "auto"
},
{
value: 2,
label: 'Desktop',
key: "desktop"
},
];
const handlerChangeLayout = (_: Event, value: number) => {
setLayout(marks[value].key as IPreferencesContext["layout"])
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<Typography variant="body1" component="div" className="settings">
<div className='preferences-category'>
<div className='category-title'>
<h3>Layout</h3>
</div>
<div className='preferences-item first leave-left-gap'>
<FormControlLabel
control={
<Box sx={{ width: 300 }}>
<Slider
aria-label="Always visible"
value={marks.find(item => item.key === layout).value}
step={1}
marks={marks}
max={2}
sx={{
'& .MuiSlider-track': { display: 'none', },
}}
onChange={handlerChangeLayout}
/>
</Box>
}
label=""
/>
</div>
</div>
<div className='preferences-category tail-category'>
<div className='preferences-item'>
<FormControlLabel
control={
<Switch
checked={isSavePreferences}
onChange={() => setIsSavePreferences(!isSavePreferences)}
name="checked"
color="primary"
/>
}
label="Save my settings (in the browser store)"
labelPlacement="end"
/>
</div>
</div>
</Typography>
</div>
</div>
}
+13 -4
View File
@@ -10,13 +10,14 @@ import { useAppDispatch, useAppSelector } from '../hooks'
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'
import { Button } from './button'
import { MobileContext } from './infoview/context'
import { PreferencesContext } from './infoview/context'
import { InventoryPanel } from './inventory'
import { ErasePopup } from './popup/erase'
import { InfoPopup } from './popup/game_info'
import { PrivacyPolicyPopup } from './popup/privacy_policy'
import { RulesHelpPopup } from './popup/rules_help'
import { UploadPopup } from './popup/upload'
import { PreferencesPopup} from "./popup/preferences"
import { WorldTreePanel } from './world_tree'
import '../css/welcome.css'
@@ -26,7 +27,7 @@ import { Hint } from './hints'
/** the panel showing the game's introduction text */
function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) {
const {mobile} = React.useContext(MobileContext)
const {mobile} = React.useContext(PreferencesContext)
const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
@@ -63,7 +64,9 @@ function IntroductionPanel({introduction, setPageNumber}: {introduction: string,
/** main page of the game showing among others the tree of worlds/levels */
function Welcome() {
const gameId = React.useContext(GameIdContext)
const {mobile} = React.useContext(MobileContext)
const {mobile} = React.useContext(PreferencesContext)
const {layout, isSavePreferences, setLayout, setIsSavePreferences} = React.useContext(PreferencesContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId})
@@ -77,15 +80,20 @@ function Welcome() {
const [info, setInfo] = React.useState(false)
const [rulesHelp, setRulesHelp] = React.useState(false)
const [uploadMenu, setUploadMenu] = React.useState(false)
const [preferencesPopup, setPreferencesPopup] = React.useState(false)
function closeEraseMenu() {setEraseMenu(false)}
function closeImpressum() {setImpressum(false)}
function closeInfo() {setInfo(false)}
function closeRulesHelp() {setRulesHelp(false)}
function closeUploadMenu() {setUploadMenu(false)}
function closePreferencesPopup() {setPreferencesPopup(false)}
function toggleEraseMenu() {setEraseMenu(!eraseMenu)}
function toggleImpressum() {setImpressum(!impressum)}
function toggleInfo() {setInfo(!info)}
function toggleUploadMenu() {setUploadMenu(!uploadMenu)}
function togglePreferencesPopup() {setPreferencesPopup(!preferencesPopup)}
// set the window title
useEffect(() => {
@@ -101,7 +109,7 @@ function Welcome() {
: <>
<WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum}
toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu}
toggleInfo={toggleInfo} />
toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/>
<div className="app-content">
{ mobile ?
<div className="welcome mobile">
@@ -128,6 +136,7 @@ function Welcome() {
{eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null}
{uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null}
{info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null}
{preferencesPopup ? <PreferencesPopup layout={layout} isSavePreferences={isSavePreferences} setLayout={setLayout} setIsSavePreferences={setIsSavePreferences} handleClose={closePreferencesPopup}/> : null}
</>
}
+5 -2
View File
@@ -16,6 +16,7 @@ import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/p
import { store } from '../state/store'
import '../css/world_tree.css'
import { PreferencesContext } from './infoview/context'
// Settings for the world tree
cytoscape.use( klay )
@@ -197,13 +198,15 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const dispatch = useAppDispatch()
const { mobile } = React.useContext(PreferencesContext)
function label(x : number) {
return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular'
}
return <nav className="world-selection-menu">
return <nav className={`world-selection-menu${mobile ? '' : ' desktop'}`}>
<div className="slider-wrap">
<span className="difficulty-label">Rules
<FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/>
@@ -213,7 +216,7 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
title="Game Rules"
min={0} max={2}
aria-label="Game Rules"
defaultValue={difficulty}
value={difficulty}
marks={[
{value: 0, label: label(0)},
{value: 1, label: label(1)},
-68
View File
@@ -1,68 +0,0 @@
/**
* @fileOverview todo
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { LeanClient } from './leanclient';
export class Connection {
private game: string = undefined // We only keep a connection to a single game at a time
private leanClient: LeanClient = null
getLeanClient(game): LeanClient {
if (this.game !== game) {
if (this.leanClient) {
this.leanClient.stop() // Stop previous Lean client
}
this.game = game
// Start a new Lean client for the new `gameId`.
const socketUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + '/websocket/' + game
const uri = monaco.Uri.parse('file:///')
this.leanClient = new LeanClient(socketUrl, undefined, uri, () => {})
}
return this.leanClient
}
/** If not already started, starts the Lean client. resolves the returned promise as soon as a
* Lean client is running.
*/
startLeanClient = (game) => {
return new Promise<LeanClient>((resolve) => {
const leanClient = this.getLeanClient(game)
if (leanClient.isRunning()) {
resolve(leanClient)
} else {
if (!leanClient.isStarted()) {
leanClient.start()
}
leanClient.restarted(() => {
// This keep alive message is not recognized by the server,
// but it makes sure that the websocket connection does not
// time out after 60 seconds.
setInterval(() => {leanClient.sendNotification('$/keepAlive', {}) }, 5000)
resolve(leanClient)
})
}
})
}
}
export const connection = new Connection()
export const ConnectionContext = React.createContext(null);
export const useLeanClient = (gameId) => {
const leanClient = connection.getLeanClient(gameId)
const [leanClientStarted, setLeanClientStarted] = React.useState(leanClient.isStarted())
React.useEffect(() => {
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })
const t2 = leanClient.stopped(() => { console.log("STOP"); setLeanClientStarted(false) })
return () => {t1.dispose(); t2.dispose()}
}, [leanClient, setLeanClientStarted])
return {leanClientStarted, leanClient}
}
+7
View File
@@ -105,11 +105,18 @@ em {
position: relative;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 1.1em;
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5));
z-index: 2;
}
.app-bar > .app-bar-left{
display: flex;
align-items: center;
gap: .5em;
}
.app-bar-title, .app-bar-subtitle {
color: white;
font-weight: 500;
+19
View File
@@ -41,6 +41,13 @@
.level-completed {
font-size: 1.8rem;
font-weight: 500;
padding-left: .5em;
padding-right: .5em;
padding-top: .2em;
padding-bottom: .2em;
border-radius: .5em;
background-color: #eee;
}
.typewriter {
@@ -188,6 +195,18 @@
flex-direction: row;
}
.exercise .failed-command {
background-color: #eee;
padding: .5em;
border-radius: .2em;
/* TODO: It seems my browsers merge the margings of the proof steps,
so that it only shows once 0.5rem instead of twice. Thus have 1.5 here now.
*/
margin-bottom: 1.5rem;
display: flex;
flex-direction: row;
}
.exercise .command-text {
flex: 1;
background-color: #fff;
+23 -1
View File
@@ -26,7 +26,11 @@
.inventory .item {
background: #fff;
border: solid 1px #777;
padding: .1em .5em;
padding-left: .5rem;
padding-right: 1.0rem;
padding-top: .1rem;
padding-bottom: .1rem;
position: relative;
}
.inventory .item.locked {
@@ -72,3 +76,21 @@
color: black;
border-bottom: 0.3em solid #999;
}
.inventory .item .copy-button {
min-width: 3px;
min-height: 3px;
display: inline-block;
color: #ccc;
font-size: 0.6em;
padding-right: .2rem;
vertical-align: top;
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 1rem;
align-items: end;
text-align: end;
}
+1
View File
@@ -48,6 +48,7 @@ a {
border: 1px solid rgb(140, 140, 140);
border-radius: 20px;
box-shadow: 5px 5px 8px rgb(140, 140, 140);
width: 100%;
max-width: 500px;
display: flex;
flex-direction: column;
+34 -5
View File
@@ -232,6 +232,20 @@ td code {
height: 100%;
} */
.button-row.mobile {
margin: .5rem;
padding-top: .2rem;
}
.button-row.mobile .btn {
padding: .5em;
border-radius: .2em;
width: 100%;
margin: 0;
text-align: center;
}
.typewriter-interface {
display: flex;
flex-flow: column;
@@ -317,11 +331,6 @@ td code {
margin-right: 0;
}
#home-btn {
margin-right: .5em;
margin-left: 0;
}
.menu.dropdown .svg-inline--fa {
width: 1.8rem;
}
@@ -336,6 +345,21 @@ td code {
background-color: #eee;
}
.world-image-container {
display: flex;
flex-direction: column;
justify-content: center;
}
.world-image-container img.contain {
object-fit: contain;
}
.world-image-container img.cover {
height: 100%;
object-fit: cover;
}
.typewriter-interface .proof {
background-color: #fff;
}
@@ -344,3 +368,8 @@ td code {
min-width: 40px;
text-align: center;
}
/* DEBUG */
/* .proof .step {
border: 2px solid rgb(0, 123, 255);
} */
+12
View File
@@ -187,3 +187,15 @@ h5, h6 {
margin-left: 0.3rem;
margin-right: 0.3rem;
}
.preferences-category.tail-category{
margin-top: 2em;
}
.preferences-item.first{
margin-top: 1em;
}
.preferences-item.leave-left-gap{
margin-left: 3em;
}
+4 -1
View File
@@ -49,7 +49,6 @@ svg .disabled {
}
.world-selection-menu {
position: absolute;
right: 1em;
top: 1em;
/* margin: 1em; */
@@ -60,6 +59,10 @@ svg .disabled {
filter: drop-shadow(4px 4px 5px rgba(0,0,0,0.5));
}
.world-selection-menu.desktop {
position: absolute;
}
.world-selection-menu .btn, .welcome .btn {
min-width: 5em;
text-align: center;
+6 -16
View File
@@ -1,7 +1,6 @@
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import App from './app'
import { ConnectionContext, connection } from './connection'
import { store } from './state/store'
import { Provider } from 'react-redux'
import type { RouteObject } from "react-router"
@@ -10,20 +9,8 @@ import ErrorPage from './components/error_page'
import Welcome from './components/welcome'
import LandingPage from './components/landing_page'
import Level from './components/level'
import { monacoSetup } from 'lean4web/client/src/monacoSetup'
monacoSetup()
// // Do not show the landing page in the dev-container context
// let root_path: RouteObject = (process.env.LEAN4GAME_SINGLE_GAME == "true") ? {
// path: "/",
// loader: () => redirect("/g/local/game")
// } : {
// path: "/",
// element: <LandingPage />,
// }
// If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to
// `/g/local/game`. This is used for the devcontainer setup
@@ -43,6 +30,11 @@ const router = createHashRouter([
path: "/game/nng",
loader: () => redirect("/g/hhu-adam/NNG4")
},
{
// For backwards compatibility
path: "/g/hhu-adam/NNG4",
loader: () => redirect("/g/leanprover-community/NNG4")
},
{
path: "/g/:owner/:repo",
element: <App />,
@@ -65,9 +57,7 @@ const root = createRoot(container!);
root.render(
<React.StrictMode>
<Provider store={store}>
<ConnectionContext.Provider value={connection}>
<RouterProvider router={router} />
</ConnectionContext.Provider>
<RouterProvider router={router} />
</Provider>
</React.StrictMode>
);
-612
View File
@@ -1,612 +0,0 @@
/* This file is based on `vscode-lean4/src/leanclient.ts` */
import {
TextDocument, EventEmitter, Diagnostic,
DocumentHighlight, Range, DocumentHighlightKind, workspace,
Disposable, Uri, ConfigurationChangeEvent, OutputChannel, DiagnosticCollection,
WorkspaceFolder, window
} from 'vscode'
import {
DidChangeTextDocumentParams,
DidCloseTextDocumentParams,
DidOpenTextDocumentNotification,
DocumentFilter,
InitializeResult,
MonacoLanguageClient as LanguageClient,
LanguageClientOptions,
PublishDiagnosticsParams,
CloseAction, ErrorAction,
RevealOutputChannelOn,
} from 'monaco-languageclient'
import { State } from 'vscode-languageclient'
import * as ls from 'vscode-languageserver-protocol'
import { toSocket } from 'vscode-ws-jsonrpc'
import {
// toolchainPath, lakePath, addServerEnvPaths, serverArgs, serverLoggingEnabled, serverLoggingPath, shouldAutofocusOutput,
getElaborationDelay
// lakeEnabled
} from 'lean4web/client/src/editor/config'
// import { assert } from './utils/assert'
import { LeanFileProgressParams, LeanFileProgressProcessingInfo } from '@leanprover/infoview-api'
// import { LocalStorageService } from './utils/localStorage'
// import { batchExecute } from './utils/batch'
// import { readLeanVersion } from './utils/projectInfo'
import * as fs from 'fs'
import { URL } from 'url'
import { join } from 'path'
// import { logger } from './utils/logger'
import { SemVer } from 'semver'
// import { fileExists, isFileInFolder } from './utils/fsHelper'
import { c2pConverter, p2cConverter, patchConverters } from 'lean4web/client/src/editor/utils/converters'
import { WasmReader, WasmWriter, WebSocketMessageWriter, WebSocketMessageReader } from './wasm'
const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
export type ServerProgress = Map<Uri, LeanFileProgressProcessingInfo[]>
export function getFullRange (diag: Diagnostic): Range {
return (diag as any)?.fullRange || diag.range
}
export class LeanClient implements Disposable {
running: boolean = false
private client: LanguageClient | undefined
// private toolchainPath: string
// private readonly outputChannel: OutputChannel
// private readonly storageManager: LocalStorageService
private readonly workspaceFolder: WorkspaceFolder | undefined
private readonly folderUri: Uri
private readonly subscriptions: Disposable[] = []
private noPrompt: boolean = false
private showingRestartMessage: boolean = false
// private readonly elanDefaultToolchain: string
private readonly didChangeEmitter = new EventEmitter<DidChangeTextDocumentParams>()
didChange = this.didChangeEmitter.event
private readonly diagnosticsEmitter = new EventEmitter<PublishDiagnosticsParams>()
diagnostics = this.diagnosticsEmitter.event
private readonly didSetLanguageEmitter = new EventEmitter<string>()
didSetLanguage = this.didSetLanguageEmitter.event
private readonly didCloseEmitter = new EventEmitter<DidCloseTextDocumentParams>()
didClose = this.didCloseEmitter.event
private readonly customNotificationEmitter = new EventEmitter<{ method: string, params: any }>()
/** Fires whenever a custom notification (i.e. one not defined in LSP) is received. */
customNotification = this.customNotificationEmitter.event
/** saved progress info in case infoview is opened, it needs to get all of it. */
progress: ServerProgress = new Map()
private readonly progressChangedEmitter = new EventEmitter<[string, LeanFileProgressProcessingInfo[]]>()
progressChanged = this.progressChangedEmitter.event
private readonly stoppedEmitter = new EventEmitter()
stopped = this.stoppedEmitter.event
private readonly restartedEmitter = new EventEmitter()
restarted = this.restartedEmitter.event
private readonly restartingEmitter = new EventEmitter()
restarting = this.restartingEmitter.event
private readonly restartedWorkerEmitter = new EventEmitter<string>()
restartedWorker = this.restartedWorkerEmitter.event
private readonly serverFailedEmitter = new EventEmitter<string>()
serverFailed = this.serverFailedEmitter.event
/** Files which are open. */
private readonly isOpen: Map<string, TextDocument> = new Map()
constructor (private readonly socketUrl: string, workspaceFolder: WorkspaceFolder | undefined, folderUri: Uri,
public readonly showRestartMessage: () => void) {
// this.storageManager = storageManager
// this.outputChannel WebSocketMessageWriter= outputChannel
this.workspaceFolder = workspaceFolder // can be null when opening adhoc files.
this.folderUri = folderUri
// this.elanDefaultToolchain = elanDefaultToolchain
// this.subscriptions.push(workspace.onDidChangeConfiguration((e) => this.configChanged(e)))
}
dispose (): void {
this.subscriptions.forEach((s) => s.dispose())
if (this.isStarted()) void this.stop()
}
// async showRestartMessage (restartFile: boolean = false): Promise<void> {
// // if (!this.showingRestartMessage) {
// // this.showingRestartMessage = true
// // let restartItem: string
// // let messageTitle: string
// // if (!restartFile) {
// // restartItem = 'Restart Lean Server'
// // messageTitle = 'Lean Server has stopped unexpectedly.'
// // } else {
// // restartItem = 'Restart Lean Server on this file'
// // messageTitle = 'The Lean Server has stopped processing this file.'
// // }
// // const item = await this.showErrorMessage(messageTitle, restartItem)
// // this.showingRestartMessage = false
// // if (item === restartItem) {
// // void this.start()
// // // if (restartFile && (window.activeTextEditor != null)) {
// // // await this.restartFile(window.activeTextEditor.document)
// // // } else {
// // // void this.start()
// // // }
// // }
// // }
// }
async restart (): Promise<void> {
const startTime = Date.now()
console.log('[LeanClient] Restarting Lean Server')
if (this.isStarted()) {
await this.stop()
}
this.restartingEmitter.fire(undefined)
// this.toolchainPath = this.storageManager.getLeanPath()
// if (!this.toolchainPath) this.toolchainPath = toolchainPath()
// let version = this.storageManager.getLeanVersion()
// const env = addServerEnvPaths(process.env)
// if (serverLoggingEnabled()) {
// env.LEAN_SERVER_LOG_DIR = serverLoggingPath()
// }
// let executable = lakePath() ||
// (this.toolchainPath ? join(this.toolchainPath, 'bin', 'lake') : 'lake')
// check if the lake process will start (skip it on scheme: 'untitled' files)
// let useLake = lakeEnabled() && this.folderUri && this.folderUri.scheme === 'file'
// if (useLake) {
// let knownDate = false
// const lakefile = Uri.joinPath(this.folderUri, 'lakefile.lean')
// if (!await fileExists(new URL(lakefile.toString()))) {
// useLake = false
// } else {
// // see if we can avoid the more expensive checkLakeVersion call.
// const date = await this.checkToolchainVersion(this.folderUri)
// if (date != null) {
// // Feb 16 2022 is when the 3.1.0.pre was released.
// useLake = date >= new Date(2022, 1, 16)
// knownDate = true
// }
// if (useLake && !knownDate) {
// useLake = await this.checkLakeVersion(executable, version)
// }
// }
// }
// if (!useLake) {
// executable = (this.toolchainPath) ? join(this.toolchainPath, 'bin', 'lean') : 'lean'
// }
// const cwd = this.folderUri?.fsPath
// if (!cwd && !version) {
// // Fixes issue #227, for adhoc files it would pick up the cwd from the open folder
// // which is not what we want. For adhoc files we want the (default) toolchain instead.
// version = this.elanDefaultToolchain
// }
// let options = version ? ['+' + version] : []
// if (useLake) {
// options = options.concat(['serve', '--'])
// } else {
// options = options.concat(['--server'])
// }
// Add folder name to command-line so that it shows up in `ps aux`.
// if (cwd) {
// options.push('' + cwd)
// } else {
// options.push('untitled')
// }
// const serverOptions: ServerOptions = {
// command: executable,
// args: options.concat(serverArgs()),
// options: {
// cwd,
// env
// }
// }
const clientOptions: LanguageClientOptions = {
// use a language id as a document selector
documentSelector: ['lean4'],
initializationOptions: {
editDelay: getElaborationDelay(), hasWidgets: true
},
connectionOptions: {
maxRestartCount: 0,
cancellationStrategy: undefined as any
},
// disable the default error handler
errorHandler: {
error: () => ({ action: ErrorAction.Continue }),
closed: () => ({ action: CloseAction.DoNotRestart })
},
middleware: {
handleDiagnostics: (uri, diagnostics, next) => {
next(uri, diagnostics)
if (this.client == null) return
const uri_ = c2pConverter.asUri(uri)
const diagnostics_ = []
for (const d of diagnostics) {
const d_: ls.Diagnostic = {
...c2pConverter.asDiagnostic(d)
}
diagnostics_.push(d_)
}
this.diagnosticsEmitter.fire({ uri: uri_, diagnostics: diagnostics_ })
},
// didOpen: async () => {
// // Note: as per the LSP spec: An open notification must not be sent more than once
// // without a corresponding close notification send before. This means open and close
// // notification must be balanced and the max open count for a particular textDocument
// // is one. So this even does nothing the notification is handled by the
// // openLean4Document method below after the 'lean4' languageId is established and
// // it has weeded out documents opened to invisible editors (like 'git:' schemes and
// // invisible editors created for Ctrl+Hover events. A side effect of unbalanced
// // open/close notification is leaking 'lean --worker' processes.
// // See https://github.com/microsoft/vscode/issues/78453).
// },
didChange: async (data, next) => {
await next(data)
if (!this.running || (this.client == null)) return // there was a problem starting lean server.
const params = c2pConverter.asChangeTextDocumentParams(data)
this.didChangeEmitter.fire(params)
},
didClose: async (doc, next) => {
if (!this.isOpen.delete(doc.uri.toString())) {
return
}
await next(doc)
if (!this.running || (this.client == null)) return // there was a problem starting lean server.
const params = c2pConverter.asCloseTextDocumentParams(doc)
this.didCloseEmitter.fire(params)
},
provideDocumentHighlights: async (doc, pos, ctok, next) => {
const leanHighlights = await next(doc, pos, ctok)
if (leanHighlights?.length) return leanHighlights
// vscode doesn't fall back to textual highlights,
// so we need to do that manually
await new Promise((res) => setTimeout(res, 250))
if (ctok.isCancellationRequested) return
const wordRange = doc.getWordRangeAtPosition(pos)
if (wordRange == null) return
const word = doc.getText(wordRange)
const highlights: DocumentHighlight[] = []
const text = doc.getText()
const nonWordPattern = '[`~@$%^&*()-=+\\[{\\]}⟨⟩⦃⦄⟦⟧⟮⟯‹›\\\\|;:\",./\\s]|^|$'
const regexp = new RegExp(`(?<=${nonWordPattern})${escapeRegExp(word)}(?=${nonWordPattern})`, 'g')
for (const match of text.matchAll(regexp)) {
const start = doc.positionAt(match.index ?? 0)
highlights.push({
range: new Range(start, start.translate(0, match[0].length)),
kind: DocumentHighlightKind.Text
})
}
return highlights
}
}
}
if (!this.client) {
this.client = new LanguageClient({
id: 'lean4',
name: 'Lean 4',
clientOptions,
connectionProvider: {
get: async () => {
return await new Promise((resolve, reject) => {
const worker = new Worker("worker.js")
const reader = new WasmReader(worker)
const writer = new WasmWriter(worker)
resolve({
reader,
writer
})
})
}
}
})
} else {
await this.client.start()
}
// HACK: Prevent monaco from panicking when the Lean server crashes
this.client.handleFailedRequest = (type, token: any, error: any, defaultValue, showNotification?: boolean) => {
return defaultValue
}
let insideRestart = true
patchConverters(this.client.protocol2CodeConverter, this.client.code2ProtocolConverter)
try {
this.client.onDidChangeState(async (s) => {
// see https://github.com/microsoft/vscode-languageserver-node/issues/825
if (s.newState === State.Starting) {
console.log('[LeanClient] starting')
} else if (s.newState === State.Running) {
const end = Date.now()
console.log(`[LeanClient] running, started in ${end - startTime} ms`)
this.running = true // may have been auto restarted after it failed.
if (!insideRestart) {
this.restartedEmitter.fire(undefined)
}
} else if (s.newState === State.Stopped) {
this.running = false
console.log('[LeanClient] has stopped or it failed to start')
if (!this.noPrompt) {
// only raise this event and show the message if we are not the ones
// who called the stop() method.
this.stoppedEmitter.fire({ message: 'Lean server has stopped.', reason: '' })
await this.showRestartMessage()
}
}
})
await this.client.start()
// tell the new client about the documents that are already open!
// for (const key of this.isOpen.keys()) {
// const doc = this.isOpen.get(key)
// if (doc != null) this.notifyDidOpen(doc)
// }
// if we got this far then the client is happy so we are running!
this.running = true
} catch (error) {
console.log(error)
this.serverFailedEmitter.fire('' + error)
insideRestart = false
return
}
// HACK(WN): Register a default notification handler to fire on custom notifications.
// A mechanism to do this is provided in vscode-jsonrpc. One can register a `StarNotificationHandler`
// here: https://github.com/microsoft/vscode-languageserver-node/blob/b2fc85d28a1a44c22896559ee5f4d3ba37a02ef5/jsonrpc/src/common/connection.ts#L497
// which fires on any LSP notifications not in the standard, for example the `$/lean/..` ones.
// However this mechanism is not exposed in vscode-languageclient, so we hack around its implementation.
const starHandler = (method: string, params_: any) => {
if (method === '$/lean/fileProgress' && (this.client != null)) {
const params = params_ as LeanFileProgressParams
const uri = p2cConverter.asUri(params.textDocument.uri)
this.progressChangedEmitter.fire([uri.toString(), params.processing])
// save the latest progress on this Uri in case infoview needs it later.
this.progress.set(uri, params.processing)
}
this.customNotificationEmitter.fire({ method, params: params_ })
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.client.onNotification(starHandler as any, () => {})
// Reveal the standard error output channel when the server prints something to stderr.
// The vscode-languageclient library already takes care of writing it to the output channel.
// let stderrMsgBoxVisible = false;
// (this.client)._serverProcess.stderr.on('data', async (chunk: Buffer) => {
// if (shouldAutofocusOutput()) {
// this.client?.outputChannel.show(true)
// } else if (!stderrMsgBoxVisible) {
// stderrMsgBoxVisible = true
// const outputItem = 'Show stderr output'
// const outPrompt = `Lean server printed an error:\n${chunk.toString()}`
// if (await window.showErrorMessage(outPrompt, outputItem) === outputItem) {
// this.outputChannel.show(false)
// }
// stderrMsgBoxVisible = false
// }
// })
this.restartedEmitter.fire(undefined)
insideRestart = false
}
async openLean4Document (doc: TextDocument) {
if (this.isOpen.has(doc.uri.toString())) return
if (!await this.isSameWorkspace(doc.uri)) {
// skip it, this file belongs to a different workspace...
return
}
this.isOpen.set(doc.uri.toString(), doc)
if (!this.running) return // there was a problem starting lean server.
// didOpenEditor may have also changed the language, so we fire the
// event here because the InfoView should be wired up to receive it now.
this.didSetLanguageEmitter.fire(doc.languageId)
this.notifyDidOpen(doc)
}
notifyDidOpen (doc: TextDocument) {
// BUG: was `DidOpenTextDocumentNotification.type` instead of the string, but that failed
void this.client?.sendNotification('textDocument/didOpen', {
textDocument: {
uri: doc.uri.toString(),
languageId: doc.languageId,
version: 1,
text: doc.getText()
}
})
}
async isSameWorkspace (uri: Uri): Promise<boolean> {
// if (this.folderUri) {
// if (this.folderUri.scheme !== uri.scheme) return false
// if (this.folderUri.scheme === 'file') {
// const realPath1 = await fs.promises.realpath(this.folderUri.fsPath)
// const realPath2 = await fs.promises.realpath(uri.fsPath)
// return isFileInFolder(realPath2, realPath1)
// } else {
// return uri.toString().startsWith(this.folderUri.toString())
// }
// } else {
// return uri.scheme === 'untitled'
// }
return false
}
getWorkspaceFolder (): string {
return this.folderUri?.toString()
}
async start (): Promise<void> {
return await this.restart()
}
isStarted (): boolean {
return this.client !== undefined
}
isRunning (): boolean {
if (this.client != null) {
return this.running
}
return false
}
async stop (): Promise<void> {
// assert(() => this.isStarted())
if ((this.client != null) && this.running) {
this.noPrompt = true
try {
// some timing conditions can happen while running unit tests that cause
// this to throw an exception which then causes those tests to fail.
await this.client.stop()
} catch (e) {
console.log(`[LeanClient] Error stopping language client: ${e}`)
}
}
this.noPrompt = false
this.progress = new Map()
this.client = undefined
this.running = false
}
configChanged (e: ConfigurationChangeEvent): void {
// let newToolchainPath = this.storageManager.getLeanPath()
// if (!newToolchainPath) newToolchainPath = toolchainPath()
// if (this.toolchainPath !== newToolchainPath) {
// void this.restart()
// }
}
async restartFile (doc: TextDocument): Promise<void> {
if (!this.running) return // there was a problem starting lean server.
// assert(() => this.isStarted())
if (!await this.isSameWorkspace(doc.uri)) {
// skip it, this file belongs to a different workspace...
return
}
const uri = doc.uri.toString()
console.log(`[LeanClient] Restarting File: ${uri}`)
// This causes a text document version number discontinuity. In
// (didChange (oldVersion) => restartFile => didChange (newVersion))
// the client emits newVersion = oldVersion + 1, despite the fact that the
// didOpen packet emitted below initializes the version number to be 1.
// This is not a problem though, since both client and server are fine
// as long as the version numbers are monotonous.
void this.client?.sendNotification('textDocument/didClose', {
textDocument: {
uri
}
})
void this.client?.sendNotification('textDocument/didOpen', {
textDocument: {
uri,
languageId: 'lean4',
version: 1,
text: doc.getText()
}
})
this.restartedWorkerEmitter.fire(uri)
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
async sendRequest (method: string, params: any): Promise<any> {
return this.running && (this.client != null)
? await this.client.sendRequest(method, params)
: await new Promise<any>((_, reject) => { reject('Client is not running') })
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
sendNotification (method: string, params: any): Promise<void> | undefined {
return this.running && (this.client != null) ? this.client.sendNotification(method, params) : undefined
}
async getDiagnosticParams (uri: Uri, diagnostics: readonly Diagnostic[]): Promise<PublishDiagnosticsParams> {
const params: PublishDiagnosticsParams = {
uri: c2pConverter.asUri(uri),
diagnostics: await c2pConverter.asDiagnostics(diagnostics as Diagnostic[])
}
return params
}
getDiagnostics (): DiagnosticCollection | undefined {
return this.running ? this.client?.diagnostics : undefined
}
get initializeResult (): InitializeResult | undefined {
return this.running ? this.client?.initializeResult : undefined
}
private async checkToolchainVersion (folderUri: Uri): Promise<Date | undefined> {
// see if we have a well known toolchain label that corresponds
// to a known date like 'leanprover/lean4:nightly-2022-02-01'
// const toolchainVersion = await readLeanVersion(folderUri)
// if (toolchainVersion) {
// const match = /^leanprover\/lean4:nightly-(\d+)-(\d+)-(\d+)$/.exec(toolchainVersion)
// if (match != null) {
// return new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]))
// }
// if (toolchainVersion === 'leanprover/lean4:stable') {
// return new Date(2022, 2, 1)
// }
// }
return undefined
}
// async checkLakeVersion (executable: string, version: string): Promise<boolean> {
// // Check that the Lake version is high enough to support "lake serve" option.
// const versionOptions = version ? ['+' + version, '--version'] : ['--version']
// const start = Date.now()
// const lakeVersion = await batchExecute(executable, versionOptions, this.folderUri?.fsPath, undefined)
// logger.log(`[LeanClient] Ran '${executable} ${versionOptions.join(' ')}' in ${Date.now() - start} ms`)
// const actual = this.extractVersion(lakeVersion)
// if (actual.compare('3.0.0') > 0) {
// return true
// }
// return false
// }
// private extractVersion (v: string | undefined): SemVer {
// if (!v) return new SemVer('0.0.0')
// const prefix = 'Lake version'
// if (v.startsWith(prefix)) v = v.slice(prefix.length).trim()
// const pos = v.indexOf('(')
// if (pos > 0) v = v.slice(0, pos).trim()
// try {
// return new SemVer(v)
// } catch {
// return new SemVer('0.0.0')
// }
// }
}
+23 -26
View File
@@ -2,16 +2,29 @@
* @fileOverview Define API of the server-client communication
*/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Connection } from '../connection'
export interface GameTile {
title: string
short: string
long: string
languages: Array<string>
prerequisites: Array<string>
worlds: number
levels: number
image: string
}
export interface GameInfo {
title: null|string,
introduction: null|string,
info: null|string,
worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string}}, edges: string[][]},
worlds: null|{nodes: {[id:string]: {id: string, title: string, introduction: string, image: string}}, edges: string[][]},
worldSize: null|{[key: string]: number},
authors: null|string[],
conclusion: null|string,
tile: null|GameTile,
image: null|string
}
export interface InventoryTile {
@@ -22,6 +35,7 @@ export interface InventoryTile {
locked: boolean,
new: boolean,
hidden: boolean
altTitle: string,
}
export interface LevelInfo {
@@ -37,7 +51,8 @@ export interface LevelInfo {
lemmaTab: null|string,
statementName: null|string,
displayName: null|string,
template: null|string
template: null|string,
image: null|string
}
/** Used to display the inventory on the welcome page */
@@ -57,40 +72,22 @@ interface Doc {
category: string,
}
const customBaseQuery = async (
args : {game: string, method: string, params?: any},
{ signal, dispatch, getState, extra },
extraOptions
) => {
try {
const connection : Connection = extra.connection
let leanClient = await connection.startLeanClient(args.game)
console.log(`Sending request ${args.method}`)
let res = await leanClient.sendRequest(args.method, args.params)
console.log('Received response') //, res)
return {'data': res}
} catch (e) {
return {'error': e}
}
}
// Define a service using a base URL and expected endpoints
export const apiSlice = createApi({
reducerPath: 'gameApi',
baseQuery: customBaseQuery,
baseQuery: fetchBaseQuery({ baseUrl: window.location.origin + "/data" }),
endpoints: (builder) => ({
getGameInfo: builder.query<GameInfo, {game: string}>({
query: ({game}) => {return {game, method: 'info', params: {}}},
query: ({game}) => `${game}/game.json`,
}),
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
query: ({game, world, level}) => {return {game, method: "loadLevel", params: {world, level}}},
query: ({game, world, level}) => `${game}/level__${world}__${level}.json`,
}),
loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({
query: ({game}) => {return {game, method: "loadInventoryOverview", params: {}}},
query: ({game}) => `${game}/inventory.json`,
}),
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({
query: ({game, name, type}) => {return {game, method: "loadDoc", params: {name, type}}},
query: ({game, type, name}) => `${game}/doc__${type}__${name}.json`,
}),
}),
})
+41
View File
@@ -0,0 +1,41 @@
import React, { useState } from "react";
import { useAppDispatch, useAppSelector } from "../../hooks";
import {
PreferencesState,
setLayout as setPreferencesLayout,
setIsSavePreferences as setPreferencesIsSavePreferences,
getWindowDimensions,
AUTO_SWITCH_THRESHOLD
} from "../preferences";
const UsePreferences = () => {
const dispatch = useAppDispatch()
const [mobile, setMobile] = React.useState<boolean>()
const layout = useAppSelector((state) => state.preferences.layout);
const setLayout = (layout: PreferencesState["layout"]) => dispatch(setPreferencesLayout(layout))
const isSavePreferences = useAppSelector((state) => state.preferences.isSavePreferences);
const setIsSavePreferences = (isSave: boolean) => dispatch(setPreferencesIsSavePreferences(isSave))
const automaticallyAdjustLayout = () => {
const {width} = getWindowDimensions()
setMobile(width < AUTO_SWITCH_THRESHOLD)
}
React.useEffect(()=>{
if (layout === "auto"){
void automaticallyAdjustLayout()
window.addEventListener('resize', automaticallyAdjustLayout)
return () => window.removeEventListener('resize', automaticallyAdjustLayout)
} else {
setMobile(layout === "mobile")
}
}, [layout])
return {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences}
}
export default UsePreferences;
+30
View File
@@ -36,3 +36,33 @@ export async function saveState(state: any) {
// Ignore
}
}
const PREFERENCES_KEY = "preferences"
/** Load from browser storage */
export function loadPreferences() {
try {
const serializedState = localStorage.getItem(PREFERENCES_KEY);
return JSON.parse(serializedState)
} catch (e) {
return undefined;
}
}
export function savePreferences(state: any) {
try {
const serializedState = JSON.stringify(state)
localStorage.setItem(PREFERENCES_KEY, serializedState);
} catch (e) {
// Ignore
}
}
export function removePreferences() {
try {
localStorage.removeItem(PREFERENCES_KEY);
} catch (e) {
// Ignore
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createSlice } from "@reduxjs/toolkit";
import { loadPreferences, removePreferences, savePreferences } from "./local_storage";
export interface PreferencesState {
layout: "mobile" | "auto" | "desktop";
isSavePreferences: boolean;
}
export function getWindowDimensions() {
const {innerWidth: width, innerHeight: height } = window
return {width, height}
}
export const AUTO_SWITCH_THRESHOLD = 800
const initialState: PreferencesState = loadPreferences() ??{
layout: "auto",
isSavePreferences: false
}
export const preferencesSlice = createSlice({
name: "preferences",
initialState,
reducers: {
setLayout: (state, action) => {
state.layout = action.payload;
},
setIsSavePreferences: (state, action) => {
state.isSavePreferences = action.payload;
},
},
});
export const { setLayout, setIsSavePreferences } = preferencesSlice.actions;
+47 -34
View File
@@ -26,7 +26,8 @@ export interface GameProgressState {
inventory: string[],
difficulty: number,
openedIntro: boolean,
data: WorldProgressState
data: WorldProgressState,
typewriterMode?: boolean
}
/**
@@ -52,22 +53,22 @@ const initalLevelProgressState: LevelProgressState = {code: "", completed: false
/** Add an empty skeleton with progress for the current game */
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
if (!state.games[action.payload.game]) {
state.games[action.payload.game] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
if (!state.games[action.payload.game.toLowerCase()]) {
state.games[action.payload.game.toLowerCase()] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
}
if (!state.games[action.payload.game].data) {
state.games[action.payload.game].data = {}
if (!state.games[action.payload.game.toLowerCase()].data) {
state.games[action.payload.game.toLowerCase()].data = {}
}
}
/** Add an empty skeleton with progress for the current level */
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addGameProgress(state, action)
if (!state.games[action.payload.game].data[action.payload.world]) {
state.games[action.payload.game].data[action.payload.world] = {}
if (!state.games[action.payload.game.toLowerCase()].data[action.payload.world]) {
state.games[action.payload.game.toLowerCase()].data[action.payload.world] = {}
}
if (!state.games[action.payload.game].data[action.payload.world][action.payload.level]) {
state.games[action.payload.game].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
if (!state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level]) {
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
}
}
@@ -78,125 +79,137 @@ export const progressSlice = createSlice({
/** put edited code in the state and set completed to false */
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level].code = action.payload.code
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = false
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].code = action.payload.code
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].completed = false
},
/** TODO: docstring */
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level].selections = action.payload.selections
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].selections = action.payload.selections
},
/** mark level as completed */
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = true
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].completed = true
},
/** Set the list of rows where help is displayed */
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
addLevelProgress(state, action)
console.debug(`!setting help to: ${action.payload.help}`)
state.games[action.payload.game].data[action.payload.world][action.payload.level].help = action.payload.help
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].help = action.payload.help
},
/** delete all progress for this game */
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
state.games[action.payload.game] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
state.games[action.payload.game.toLowerCase()] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
},
/** delete progress for this level */
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level] = initalLevelProgressState
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level] = initalLevelProgressState
},
/** load progress, e.g. from external import */
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
console.debug(`setting data to:\n ${action.payload.data}`)
state.games[action.payload.game] = action.payload.data
state.games[action.payload.game.toLowerCase()] = action.payload.data
},
/** set the current inventory */
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
addGameProgress(state, action)
state.games[action.payload.game].inventory = action.payload.inventory
state.games[action.payload.game.toLowerCase()].inventory = action.payload.inventory
},
/** set the difficulty */
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
addGameProgress(state, action)
state.games[action.payload.game].difficulty = action.payload.difficulty
state.games[action.payload.game.toLowerCase()].difficulty = action.payload.difficulty
},
/** set the difficulty */
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) {
addGameProgress(state, action)
state.games[action.payload.game].openedIntro = action.payload.openedIntro
state.games[action.payload.game.toLowerCase()].openedIntro = action.payload.openedIntro
},
/** set the typewriter mode */
changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) {
addGameProgress(state, action)
state.games[action.payload.game.toLowerCase()].typewriterMode = action.payload.typewriterMode
}
}
})
/** if the level does not exist, return default values */
export function selectLevel(game: string, world: string, level: number) {
return (state) =>{
if (!state.progress.games[game]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState }
return state.progress.games[game].data[world][level]
if (!state.progress.games[game.toLowerCase()]) { return initalLevelProgressState }
if (!state.progress.games[game.toLowerCase()].data[world]) { return initalLevelProgressState }
if (!state.progress.games[game.toLowerCase()].data[world][level]) { return initalLevelProgressState }
return state.progress.games[game.toLowerCase()].data[world][level]
}
}
/** return the code of the current level */
export function selectCode(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).code
return selectLevel(game.toLowerCase(), world, level)(state).code
}
}
/** return the current inventory */
export function selectInventory(game: string) {
return (state) => {
if (!state.progress.games[game]) { return [] }
return state.progress.games[game].inventory
if (!state.progress.games[game.toLowerCase()]) { return [] }
return state.progress.games[game.toLowerCase()].inventory
}
}
/** return the code of the current level */
export function selectHelp(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).help
return selectLevel(game.toLowerCase(), world, level)(state).help
}
}
/** return the selections made in the current level */
export function selectSelections(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).selections
return selectLevel(game.toLowerCase(), world, level)(state).selections
}
}
/** return whether the current level is clompleted */
export function selectCompleted(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).completed
return selectLevel(game.toLowerCase(), world, level)(state).completed
}
}
/** return progress for the current game if it exists */
export function selectProgress(game: string) {
return (state) => {
return state.progress.games[game] ?? null
return state.progress.games[game.toLowerCase()] ?? null
}
}
/** return difficulty for the current game if it exists */
export function selectDifficulty(game: string) {
return (state) => {
return state.progress.games[game]?.difficulty ?? DEFAULT_DIFFICULTY
return state.progress.games[game.toLowerCase()]?.difficulty ?? DEFAULT_DIFFICULTY
}
}
/** return whether the intro has been read */
export function selectOpenedIntro(game: string) {
return (state) => {
return state.progress.games[game]?.openedIntro
return state.progress.games[game.toLowerCase()]?.openedIntro
}
}
/** return typewriter mode for the current game if it exists */
export function selectTypewriterMode(game: string) {
return (state) => {
return state.progress.games[game.toLowerCase()]?.typewriterMode ?? true
}
}
/** Export actions to modify the progress */
export const { changedSelection, codeEdited, levelCompleted, deleteProgress,
deleteLevelProgress, loadProgress, helpEdited, changedInventory, changedOpenedIntro,
changedDifficulty } = progressSlice.actions
changedDifficulty, changeTypewriterMode} = progressSlice.actions
+7 -6
View File
@@ -7,21 +7,19 @@ import { debounce } from "debounce";
import { connection } from '../connection'
import { apiSlice } from './api'
import { progressSlice } from './progress'
import { saveState } from "./local_storage";
import { preferencesSlice } from "./preferences"
import { saveState, savePreferences, removePreferences} from "./local_storage";
export const store = configureStore({
reducer: {
[apiSlice.reducerPath]: apiSlice.reducer,
[progressSlice.name]: progressSlice.reducer,
[preferencesSlice.name]: preferencesSlice.reducer,
},
// Make connection available in thunks:
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
thunk: {
extraArgument: { connection }
}
}).concat(apiSlice.middleware),
getDefaultMiddleware().concat(apiSlice.middleware),
});
/**
@@ -31,6 +29,9 @@ export const store = configureStore({
store.subscribe(
debounce(() => {
saveState(store.getState()[progressSlice.name]);
const preferencesState = store.getState()[preferencesSlice.name]
preferencesState.isSavePreferences ? savePreferences(preferencesState) : removePreferences()
}, 800)
);
-341
View File
@@ -1,341 +0,0 @@
import { DataCallback, AbstractMessageReader, MessageReader } from 'vscode-jsonrpc/lib/common/messageReader.js';
import { Message } from 'vscode-jsonrpc/lib/common/messages.js';
import { AbstractMessageWriter, MessageWriter } from 'vscode-jsonrpc/lib/common/messageWriter.js';
import { Emitter } from 'vscode-jsonrpc/lib/common/events.js';
import { Disposable, IWebSocket } from 'vscode-ws-jsonrpc/.';
declare var IO: any;
export class WasmWriter implements MessageWriter {
protected errorCount = 0;
errorEmitter
closeEmitter
constructor(private worker: Worker) {
this.errorEmitter = new Emitter()
this.closeEmitter = new Emitter()
}
dispose() {
this.errorEmitter.dispose();
this.closeEmitter.dispose();
}
get onError() {
return this.errorEmitter.event;
}
fireError(error, message, count) {
this.errorEmitter.fire([this.asError(error), message, count]);
}
get onClose() {
return this.closeEmitter.event;
}
fireClose() {
this.closeEmitter.fire(undefined);
}
asError(error) {
if (error instanceof Error) {
return error;
}
else {
return new Error(`Writer received error. Reason: ${error.message}`);
}
}
end(): void {
}
async write(msg: Message): Promise<void> {
try {
const content = JSON.stringify(msg);
this.worker.postMessage(content)
} catch (e) {
this.errorCount++;
this.fireError(e, msg, this.errorCount);
}
}
}
export class WasmReader implements MessageReader {
protected state: 'initial' | 'listening' | 'closed' = 'initial';
protected callback: DataCallback | undefined;
protected readonly events: { message?: any, error?: any }[] = [];
constructor(private worker: Worker) {
this.worker.onmessage = (ev) => {
this.readMessage(ev.data)
}
// this.socket.onMessage(message =>
// this.readMessage(message)
// );
// this.socket.onError(error =>
// this.fireError(error)
// );
// this.socket.onClose((code, reason) => {
// if (code !== 1000) {
// const error: Error = {
// name: '' + code,
// message: `Error during socket reconnect: code = ${code}, reason = ${reason}`
// };
// this.fireError(error);
// }
// this.fireClose();
// });
this.errorEmitter = new Emitter()
this.closeEmitter = new Emitter()
this.partialMessageEmitter = new Emitter()
}
protected errorCount = 0;
errorEmitter
closeEmitter
partialMessageEmitter
dispose() {
this.errorEmitter.dispose();
this.closeEmitter.dispose();
}
get onError() {
return this.errorEmitter.event;
}
get onClose() {
return this.closeEmitter.event;
}
get onPartialMessage() {
return this.partialMessageEmitter.event;
}
firePartialMessage(info) {
this.partialMessageEmitter.fire(info);
}
asError(error) {
if (error instanceof Error) {
return error;
}
else {
return new Error(`Reader received error. Reason: ${error.message ? error.message : 'unknown'}`);
}
}
listen(callback: DataCallback): Disposable {
if (this.state === 'initial') {
this.state = 'listening';
this.callback = callback;
while (this.events.length !== 0) {
const event = this.events.pop()!;
if (event.message) {
this.readMessage(event.message);
} else if (event.error) {
this.fireError(event.error);
} else {
this.fireClose();
}
}
}
return {
dispose: () => {
if (this.callback === callback) {
this.callback = undefined;
}
}
};
}
protected readMessage(message: any): void {
if (this.state === 'initial') {
this.events.splice(0, 0, { message });
} else if (this.state === 'listening') {
try {
const data = JSON.parse(message);
this.callback!(data);
} catch (err) {
const error: Error = {
name: '' + 400,
message: `Error during message parsing, reason = ${typeof err === 'object' ? (err as any).message : 'unknown'}`
};
this.fireError(error);
}
}
}
protected fireError(error: any): void {
if (this.state === 'initial') {
this.events.splice(0, 0, { error });
} else if (this.state === 'listening') {
this.errorEmitter.fire(this.asError(error));
}
}
protected fireClose(): void {
if (this.state === 'initial') {
this.events.splice(0, 0, {});
} else if (this.state === 'listening') {
this.closeEmitter.fire(undefined);
}
this.state = 'closed';
}
}
export class WebSocketMessageWriter implements MessageWriter {
protected errorCount = 0;
errorEmitter
closeEmitter
constructor(protected readonly socket: IWebSocket) {
this.errorEmitter = new Emitter();
this.closeEmitter = new Emitter();
}
dispose() {
this.errorEmitter.dispose();
this.closeEmitter.dispose();
}
get onError() {
return this.errorEmitter.event;
}
fireError(error, message, count) {
this.errorEmitter.fire([this.asError(error), message, count]);
}
get onClose() {
return this.closeEmitter.event;
}
fireClose() {
this.closeEmitter.fire(undefined);
}
asError(error) {
if (error instanceof Error) {
return error;
}
else {
return new Error(`Writer received error. Reason: ${(error.message) ? error.message : 'unknown'}`);
}
}
end(): void {
}
async write(msg: Message): Promise<void> {
console.log("WRITE",msg)
try {
const content = JSON.stringify(msg);
this.socket.send(content);
} catch (e) {
this.errorCount++;
this.fireError(e, msg, this.errorCount);
}
}
}
export class WebSocketMessageReader implements MessageReader {
protected state: 'initial' | 'listening' | 'closed' = 'initial';
protected callback: DataCallback | undefined;
protected readonly events: { message?: any, error?: any }[] = [];
errorEmitter
closeEmitter
partialMessageEmitter
constructor(protected readonly socket: IWebSocket) {
this.errorEmitter = new Emitter();
this.closeEmitter = new Emitter();
this.partialMessageEmitter = new Emitter();
this.socket.onMessage(message =>{
console.log("READ", message)
this.readMessage(message)
});
this.socket.onError(error =>
this.fireError(error)
);
this.socket.onClose((code, reason) => {
if (code !== 1000) {
const error: Error = {
name: '' + code,
message: `Error during socket reconnect: code = ${code}, reason = ${reason}`
};
this.fireError(error);
}
this.fireClose();
});
}
dispose() {
this.errorEmitter.dispose();
this.closeEmitter.dispose();
}
get onError() {
return this.errorEmitter.event;
}
get onClose() {
return this.closeEmitter.event;
}
get onPartialMessage() {
return this.partialMessageEmitter.event;
}
firePartialMessage(info) {
this.partialMessageEmitter.fire(info);
}
asError(error) {
if (error instanceof Error) {
return error;
}
else {
return new Error(`Reader received error. Reason: ${(error.message) ? error.message : 'unknown'}`);
}
}
listen(callback: DataCallback): Disposable {
if (this.state === 'initial') {
this.state = 'listening';
this.callback = callback;
while (this.events.length !== 0) {
const event = this.events.pop()!;
if (event.message) {
this.readMessage(event.message);
} else if (event.error) {
this.fireError(event.error);
} else {
this.fireClose();
}
}
}
return {
dispose: () => {
if (this.callback === callback) {
this.callback = undefined;
}
}
};
}
protected readMessage(message: any): void {
if (this.state === 'initial') {
this.events.splice(0, 0, { message });
} else if (this.state === 'listening') {
try {
const data = JSON.parse(message);
this.callback!(data);
} catch (err) {
const error: Error = {
name: '' + 400,
message: `Error during message parsing, reason = ${typeof err === 'object' ? (err as any).message : 'unknown'}`
};
this.fireError(error);
}
}
}
protected fireError(error: any): void {
if (this.state === 'initial') {
this.events.splice(0, 0, { error });
} else if (this.state === 'listening') {
this.errorEmitter.fire(this.asError(error));
}
}
protected fireClose(): void {
if (this.state === 'initial') {
this.events.splice(0, 0, {});
} else if (this.state === 'listening') {
this.closeEmitter.fire(undefined);
}
this.state = 'closed';
}
}
-21
View File
@@ -1,21 +0,0 @@
import {useState, useEffect} from 'react'
function getWindowDimensions() {
const {innerWidth: width, innerHeight: height } = window
return {width, height}
}
export function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions())
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return windowDimensions
}
+54 -319
View File
@@ -1,332 +1,67 @@
# Creating a game.
# Server
Ideally one takes the [NNG template](https://github.com/hhu-adam/NNG4) to create a new game.
The server is made out of two parts, named "relay" and "server".
## Game Structure
The former, "relay", is the server which
sets up a socket connection to the client, starts the lean servers to work on files and
relays messages between the lean server and the client. `index.mjs` is the file that needs to
be run, which is done for example using `pm2` or by calling `npm run start_server` or
`npm run production`, see more later.
A game consist of worlds which have multiple levels each. In the following we describe how to create a level file and how to combine these into a game.
The latter, "server", is the lean server which has two jobs. For one, it produces the "gameserver"
executable which is the lean server that handles the files the player plays on. The second job
is to provide the lean commands which are used when creating a game. These are located in
`Commands.lean`.
### Level
A level file is a lean file that imports at least `import GameServer.Commands` and starts with the following Lean commands.
## Integration into Games
Games need the "server" as a lake-dependency, which is done in the game's lakefile.
A game imports `GameServer.Commands` which provides to all the API required to
create a game.
In particular the lean command `MakeGame` compiles the entire game. Static information is
stored as JSON files in `.lake/gamedata` for faster loading, while other data is only
saved to lean env-extensions which the lean server has access to after loading the lean file.
For games to be run successfully, it is important that the "gameserver" executable inside
the game's `.lake` folder is actually built.
Currently, this happens through a lake-post-update-hook when calling `lake update -R` (in the game's folder), but if this fails, you can always build it manually by calling `lake build gameserver`.
(both commands are to be executed in the game's directory!)
## Modifying the server
### Starting the server
When using the [manual installation](running_locally.md#manual-installation) you can run the server
using
```lean
Game "NNG"
World "Addition"
Level 1
Title "The rfl tactic"
```
Note that the levels inside a world must have consecutive numbering starting with `1`. The `Game`
and `World` strings can be anything, see below.
#### Statement
The core of a level is the `Statement`, which is the exercise that should be proven.
```lean
/-- For all natural numbers $n$, we have $0 + n = n$. -/
@[simp]
Statement MyNat.zero_add
(n : ℕ) : 0 + n = n := by
Hint "You can start a proof by `induction n`."
induction n with n hn
· Hint "This is the base case."
rw [add_zero]
rfl
· Hint "This is the induction hypothesis"
rw [add_succ]
Branch
simp
Hint "A branch is an alternative tactic sequence. Does not need to finish the proof."
rw [hn]
rfl
```
##### Proof
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
There are a few extra tactics that help you structuring the proof:
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
the one where `Hint` is placed. For more options about hints, see below.
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
helps setting `Hints` in different places. The `Branch` does not affect the main
proof and does not need to finish any goals.
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
having a `Template` will force the user to use Editor-mode for this level.
##### Statement Name (optional)
If you specify a name (`MyNat.zero_add`), this lemma will be available in future levels.
(Note that a future level must also import this level,
so that Lean knows about the added statement).
The name must be *fully qualified*. (TODO: is that still true? Did we implement namespaces?)
##### Doc Comment (optional)
There are three places where the documentation comment appears:
1. as doc comment when hovering over the theorem
2. as exercise description at the top of the level: ``Theorem `zero_add`: yada yada.``
3. in the inventory. This can be overwritten by using
`LemmaDoc MyNat.zero_add "different yada yada"` as one might want to add a more detailed
description there including examples etc.
Both latter points support Markdown (including katex).
##### Attributes (optional)
the `@[ attributes ]` prefix should work just like you know it from the `theorem` keyword.
#### Introduction/Conclusion
Optionally, you can add an `Introduction "some text"` and `Conclusion "some text"` to your level.
The introduction will be shown at the beginning, the conclusion is displayed once the level
is solved.
#### Theorems/Tactics/Definitions
Only enabled theorems/tactics/definitions (called "items" here) are available in a level.
To add a new item in a level, you can add
```lean
NewTactic rfl simp
NewLemma MyNat.add_zero MyNat.add_succ
NewDefinition Nat Pow Mul
```
Once added, items will be available in all future levels/worlds,
unless you disable them for a particular level with
```lean
DisabledTactic tauto
DisabledLemma MyNat.add_zero
```
or specify explicitly which items should be available with
```lean
OnlyTactic rw rfl apply
OnlyLemma MyNat.add_zero
```
Lastly, all items need documentation entries (which are imported in the level),
see more about that below. There is also explains the `LemmaTab` keyword.
### World
Multiple levels are combined into a world and the worlds are then added to the game. It is recommended that all levels of a world are inside one folder (e.g. `NNG/Levels/Addition/`) and
then there is one world file (`NNG/Levels/Addition.lean`) which contains the following
```lean
import NNG.Levels.Addition.Level_1
import NNG.Levels.Addition.Level_2
Game "NNG"
World "Addition"
Title "Addition World"
Introduction "some text"
```
The `Title` is the world's display title. The `Introduction` is displayed before loading level 1.
Note that all levels of a world should be imported by the world file.
BUG: A level **must not** be imported in a different world's level. Instead, you have to import an entire world there: `import NNG.Levels.Addition`
### Game
The Game itself (i.e. the main file of you lake project, `NNG.lean`) should import all worlds and have the following layout, concluding with `MakeGame`:
```lean
import NNG.Levels.Addition
import NNG.Levels.Multiplication
import NNG.Levels.Power
Game "NNG"
Title "Natural Number Game"
Introduction "some text"
MakeGame
```
The game will automatically compute the order of the worlds depending on the sample proofs of the Levels (ignoring anything inside a `Branch`). You can add additional dependencies manually by adding `Dependency PowerWorld → ImpossibleWorld` before `MakeGame`.
The order of worlds influences which tactics and lemmas will be unlocked in a given level.
`MakeGame` will display warnings about things in the game that need to be fixed, like missing
documentation or if a tactic is never introduced.
### Documentation
Each tactic, theorem, or definition (all called items here) that is introduced in the game
needs a documentation entry. These are statements of the following form:
```lean
LemmaDoc MyNat.add_squared as "add_squared" in "Pow"
"(missing)"
TacticDoc constructor
"(missing)"
DefinitionDoc One as "1"
"(missing)"
```
Notes:
* The lemma name must be **fully qualified**. The string display name can be arbitrary.
* Tactics must have their proper name. use `TacticDoc «have» ""` if it does not work
without french quotes.
* Definition names can be arbitrary. E.g. I used `DefinitionDoc Symbol.Fun as "fun x ↦ x" "(missing)"` once.
Moreover, the lemmas are in sorted in tabs (the `in "Pow`) part. In each level file, you
can define which tab is open when the level is loaded by adding `LemmaTab "Pow"`.
There will be features added to get automatic information from mathlib!
## Escaping
(TODO: Move)
Inside the doc comment you don't need to escape the backslashes:
```lean
/-- $\operatorname{succ}(n)$. notation for naturals is `\N`. -/
Statement ...
```
However, inside interpolated strings (e.g. in `Hint`, `Introduction` and `Conclusion`)
you do need to escape backslashes
with `\\` and `{` with `\{`:
```lean
Hint "This code has some $\\operatorname\{succ}(n)$ math. The value of `h` is {h}.
Notation for naturals is `\\N`."
```
## Game design
Here are some things you should consider designing a new game:
* A world with more than 16 levels will be displayed with the levels spiraling outwards,
it might be desirable to stay below that bound. Above 22 levels the spiral start getting out
of control.
# Running Games Locally
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
## VSCode Dev Containers
1. **Install Docker and Dev Containers** *(once)*:<br/>
See [official instructions](https://code.visualstudio.com/docs/devcontainers/containers#_getting-started).
Explicitly this means:
* Install docker engine if you have not yet: [Instructions](https://docs.docker.com/engine/install/).
I followed the "Server" instructions for linux.
* Note that on Linux you need to add your user to the `docker` group
([see instructions](https://docs.docker.com/engine/install/linux-postinstall/)) and probably reboot.
* Open the games folder in VSCode: `cd NNG4 && code .` or "Open Folder" within VSCode
* a message appears prompting you to install the "Dev Containers" extension (by Microsoft).
2. **Open Project in Dev Container** *(everytime)*:<br/>
Once you have the Dev Containers Extension installed, (re)open the project folder of your game in VSCode.
A message appears asking you to "Reopen in Container".
* The first start will take a while, ca. 2-10 minutes. After the first
start this should be very quickly.
* Once built, it should open a tab "Simple Browser" inside VSCode displaying
the game. (Alternatively, open http://localhost:3000 in your browser).
3. **Editing Files** *(everytime)*:<br/>
After editing some files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`.
Now you can reload your browser to see the changes.
### Errors
* If you don't get the pop-up, you might have disabled them and you can reenable it by
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
you might have deleted stuff from docker via your shell. Try deleting the container and image
explicitly in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time)
## Without Dev Containers
Install `nvm`:
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
```
then reopen bash and test with `command -v nvm` if it is available (Should print "nvm").
Now install node:
```bash
nvm install node
```
Clone the game (e.g. `NNG4` here):
```bash
git clone https://github.com/hhu-adam/NNG4.git
# or: git clone git@github.com:hhu-adam/NNG4.git
```
Download dependencies and build the game:
```bash
cd NNG4
lake update
lake exe cache get # if your game depends on mathlib
lake build
```
Clone the game repository into a directory next to the game:
```bash
cd ..
git clone https://github.com/leanprover-community/lean4game.git
# or: git clone git@github.com:leanprover-community/lean4game.git
```
The folders `NNG4` and `lean4game` must be in the same directory!
In `lean4game`, install dependencies:
```bash
cd lean4game
npm install
```
If you are developing a game other than `Robo` or `NNG4`, adapt the
code at the beginning of `lean4game/server/index.mjs`:
```typescript
const games = {
"g/hhu-adam/robo": {
dir: "../../../../Robo",
queueLength: 5
},
"g/hhu-adam/nng4": {
dir: "../../../../NNG4",
queueLength: 5
}
}
```
Run the game:
```bash
npm start
```
This takes a little time. Eventually, the server is available on http://localhost:3000/
and the game is available on http://localhost:3000/#/g/hhu-adam/NNG4.
This way any changes to files in `client/` or `relay/` will cause the server to restart automatically.
### Modifying the GameServer
Alternative, you can run `npm run build` followed by the commands
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with this
setup by setting `export NODE_ENV=development` inside your local game before building it:
```bash
cd NNG4
export NODE_ENV=development
lake update
lake build
```
This causes lake to search locally for the `GameServer` lake package instead of using the version from github.
npm run start_client
npm run production
```
(in two separate terminals) to test the production mode of the server. This way it will only
change once you build and restart the server.
### Modifying the lean server
To test a modified lean server (i.e. content of `server/`), you can use the local dev setup and call
`lake update -R -Klean4game.local` in your game followed by `lake build`.
This will cause lake to look for the
local lean server as a dependency instead of the version it downloaded from git.
You can play a local game at https://localhost:3000/#/g/local/{FolderName} where you replace `{FolderName}` with the game folder name.
After modifications in `server/`, you will need to call `lake build gameserver` (called in `server/` or in your game's folder) to rebuild
the gameserver executable and
`lake build` (called in the game's folder) to rebuild the game.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## v4.5.0
### Breaking changes
* Fix (#183): local store accepts case insensitive URL. The game progress has previously been saved under case sensitive URLs. You might need to recover old progress from your browser storage.
## Other
+50 -28
View File
@@ -98,7 +98,7 @@ This introduction text is shown when one first enters a world.
1. Use the template above and make sure you import all levels of this world.
1. In `Game.lean` import the world with `import Game.Levels.MyWorld`
Now you created a world with one level and added it🎉 The command `MakeGame` in `Game.lean` shows you any problems you might need to fix. Currently it shows
Now you created a world with one level and added it🎉 The command `MakeGame` in `Game.lean` shows you any problems you might need to fix. Currently, it shows
```text
No world introducing sorry, but required by MyWorld
@@ -125,23 +125,32 @@ The player has an inventory with tactics, theorems, and definitions that unlock
```lean
NewTactic induction simp
NewLemma Nat.zero_mul
NewTheorem Nat.zero_mul
NewDefinition Pow
```
**Important:** All commands in this section 6a) expect the `Name` they take as input
to be **fully qualified**. For example `NewTheorem Nat.zero_mul` and not `NewTheorem zero_mul`.
#### Doc entries
You'll see a warning about a missing Lemma documentation. You can fix it by adding doc-entries like the following somewhere above it.
You'll see a warning about a missing Theorem documentation. You can fix it by adding doc-entries like the following somewhere above it.
```lean
LemmaDoc Nat.zero_mul as "zero_mul" in "Mul"
"some description"
/--
some description
-/
TheoremDoc Nat.zero_mul as "zero_mul" in "Mul"
/--
some description
-/
TacticDoc simp
"some description"
/--
some description
-/
DefinitionDoc Pow as "^"
"some description"
```
(e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it)
@@ -153,7 +162,7 @@ If you do not provide any content for the inventory, the game will try to find a
You have a few options to disable inventory items that have been unlocked in previous levels:
```lean
DisableTactic, DisableLemma, OnlyTactic, OnlyLemma
DisabledTactic, DisabledTheorem, OnlyTactic, OnlyTheorem
```
have the same syntax as above. The former two disable items for this level, the latter two
@@ -161,7 +170,7 @@ disable all items except the ones specified.
#### Theorem Tab
Theorems are sorted into tabs. with `LemmaTab "Mul"` you specify which tab should be open by default in this level.
Theorems are sorted into tabs. With `TheoremTab "Mul"` you specify which tab should be open by default in this level.
#### HiddenTactic
@@ -176,12 +185,14 @@ and only `rw` would show up in the inventory.
### 6. b) Statement
The statement is the exercise of the level. the basics work the same as they would in `example` or `theorem`. Note however, that you **must** do a tactic proof, i.e. the `:= by` is a hard-coded part of the syntax
The statement is the exercise of the level. The basics work the same as they would in `example` or `theorem`. Note however, that you **must** do a tactic proof, i.e. the `:= by` is a hard-coded part of the syntax
#### Name
You can give your exercise a name: `Statement my_first_exercise (n : Nat) ...`. If you do so, it will be added to the inventory and be available in future levels.
You can but a `Statement` inside namespaces like you would with `theorem`.
#### Doc String / Exercise statement
Add a docstring that contains the exercise statement in natural language. If you do this, it will appear at the top of the exercise. It supports Latex.
@@ -205,12 +216,12 @@ Statement my_simp_lemma ...
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
There are a few extra tactics that help you structuring the proof:
There are a few extra tactics that help you with structuring the proof:
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
the one where `Hint` is placed. For more options about hints, see below.
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
helps setting `Hints` in different places. The `Branch` does not affect the main
helps to set `Hints` in different places. The `Branch` does not affect the main
proof and does not need to finish any goals.
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
@@ -223,29 +234,40 @@ One thing to keep in mind is that the game will look at the main proof to figure
Most important for game development are probably the `Hints`.
The hints will be displayed whenever the player's current goal matches the goal the hint is
placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands. If you specify
placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands.
```
Hint (strict := true) "some hidden hint"
```
Read [More about Hints](hints.md) for how they work and what the options are.
a hint only matches iff the assumptions match exactly one-to-one. (Otherwise, it does not care if there are additional assumptions in context)
### 6. e) Extra: Images
You can add images on any layer of the game (i.e. game/world/level). These will be displayed in your game.
Further, you can choose to hide hints and only have them displayed when the player presses "More Help":
```
Hint (hidden := true) "some hidden hint"
```
The images need to be placed in `images/` and you need to add a command like `Image "images/path/to/myWorldImage.png"`
in one of the files you created in 2), 3), or 4) (i.e. game/world/level).
Lastly, you should put variable names in hints inside brackets:
```
Hint "now use `rw [{h}]` to use your assumption {h}."
```
That way, the game will replace it with the actual name the assumption has in the player's proof state.
NOTE: At present, only the images for a world are displayed. They appear in the introduction of the world.
## 7. Update your game
In principle, it is as simple as modifying `lean-toolchain` to update your game to a new Lean version. However, you should read about the details in [Update An Existing Game](https://github.com/leanprover-community/lean4game/blob/main/doc/update_game.md).
In principle, it is as simple as modifying `lean-toolchain` to update your game to a new Lean version. However, you should read about the details in [Update An Existing Game](update_game.md).
## 8. Publish your game
To publish your game on the official server, see [Publishing a game](publish_game.md)
There are a few more options you can add in `Game.lean` before the `MakeGame` command, which describe the tile that is visible on the server's landing page:
```lean
Languages "English"
CaptionShort "Game Template"
CaptionLong "You should use this game as a template for your own game and add your own levels."
Prerequisites "NNG"
CoverImage "images/cover.png"
```
* `Languages`: Currently only a single language (capital English name). The tile will show a corresponding flag.
* `CaptionShort`: One catchphrase. Appears above the image.
* `CaptionLong`: 2-4 sentences to describe the game.
* `Prerequisites` a list of other games you should play before this one, e.g. `Prerequisites "NNG" "STG"`. The game names are free-text.
* `CoverImage`: You can create a folder `images/` and put images there for the game to use. The maximal ratio is ca. 500x200 (W x H) but it might be cropped horizontally on narrow screens.
## Further Notes
+89
View File
@@ -0,0 +1,89 @@
# Hints
Most important for game development are probably the "Hints". You can add Hints at any place in your proof using the `Hint` tactic
```
Statement .... := by
Hint "Hint to show at the start"
rw [h]
Hint "some tip after using rw"
...
```
Note that hints are only **context-aware but not history-aware**. In particular, they only look at the assumptions and the current goal. Player's might encounter hints in a different order - or not at all - if they decide to go for a unique proof idea. The `Branch` tactic helps to place hints outside the sample solution's proof.
## 1. When do hints show?
A hint will be displayed if the player's goal matches the one where the hint was placed in the
sample solutions and the entire context from the sample solutions is present in the
player's context. The player's context may contain additional items.
This means if you have multiple identical
subgoals, you should only place a single hint in one of them, and it will be displayed in
all of them.
However, identical (non-hidden) hints which where already present in the step
before are omitted. This is to allow a player to add new assumptions to context, for example
with `have`, without seeing the same hint over and over again.
Hidden hints are not filtered.
## 2. Alternative Proofs / `Branch`
You can use `Branch` to place hints
in dead ends or alternative proof strands.
A proof inside a `Branch`-block is normally evaluated by lean, but it's discarded at the end
so that no progress has been made on proofing the goal.
```
Statement .... := by
Hint "use `rw` or `rewrite`."
Branch
rewrite [h]
Hint "now you still need `rfl`"
rw [h]
```
## 3. Variables names
Put variables in the hint text inside brackets like this: `{h}`! This way the server can replace
the variable's name with the one the user actually used.
For example, if the sample proof contains
```
have h : True := trivial
Hint "Now use `rw [{h}]` to use your assumption `{h}`."
```
but the player writes `have g : True := trivial`, they will see a hint saying
"Now use `rw [g]` to use your assumption `g`."
## 4. Hidden hints
Some hints can be hidden, and only show after the user clicks on a button to get additional
help. You mark a hint as hidden with `(hidden := true)`:
```
Hint (hidden := true) "some hidden hint"
```
## 5. Strict context matching
If you use the attribute `(strict := true)` a hint is only shown if the entire context
matches exactly the one where the hint is placed. With `(hint := false)`, which is the default,
it does not matter if additional assumptions are present in the player's context.
```
Hint (strict := true) "now use `have` to create a new assumption."
```
You should probably use `(strict := true)` if you want to give fine-grained details about
tactics like `have` which do not modify the goal or any existing assumptions, but only
create new assumptions.
## 6. Formatting
You can add use markdown to format your hints, for example you can use KaTex: `$\\iff$`
TODO: Write a doc about latex/markdown options available.
+32
View File
@@ -0,0 +1,32 @@
# Publishing games
You can publish your game on the official [Lean Game Server](https://adam.math.hhu.de) in a few simple
steps.
## 1. Upload Game to github
First, you need your game in a public Github repository and make sure the github action has run.
You can check this by spotting the green checkmark on the start page, or by looking at the "Actions"
tab.
## 2. Import the game
You call the URL that's listed under "What's Next?" in the latest action run. Explicitly you call
the URL of the form
> adam.math.hhu.de/import/trigger/{USER}/{REPOSITORY}
where `{USER}` and `{REPOSITORY}` are replaced with the github user and repository name.
You should see a white screen which shows import updates and eventually reports "Done."
## 3. Play the game
Now you can immediately play the game at `adam.math.hhu.de/#/g/{USER}/{REPOSITORY}`!
## 4. Main page
Adding games to the main page happens manually by the server maintainers. Tell us if you want us
to add a tile for your game!
For example, you can [contact Jon on Zulip](https://leanprover.zulipchat.com/#narrow/dm/385895-Jon-Eugster). Or [via Email](https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster).
+33 -19
View File
@@ -2,6 +2,8 @@
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
Please also consult the [Troubleshooting Collection](troubleshoot.md), where some known pitfalls are collected.
There are several options to play a game locally:
1. VSCode Dev Container: needs `docker` installed on your machine
@@ -31,24 +33,14 @@ The template game [GameSkeleton](https://github.com/hhu-adam/GameSkeleton) conta
* The first start will take a while, ca. 2-15 minutes. After the first
start this should be very quickly.
* Once built, you can open http://localhost:3000 in your browser. which should load the game.
* Once built, you can open http://localhost:3000 in your browser, which should load the game.
3. **Editing Files** *(everytime)*:<br/>
After editing some Lean files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`. Now you can reload your browser to see the changes.
### Errors
* If you don't get the pop-up, you might have disabled them and you can reenable it by
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
you might have deleted stuff from docker via your shell. Try deleting the container and image
explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time)
* On a working dev container setup, http://localhost:3000 should directly redirect you to http://localhost:3000/#/g/local/game, try if the latter is accessible.
## Codespaces
You can work on your game using Github codespaces (click "Code" and then "Codespaces" and then "create codespace on main"). It it should run the game locally in the background. You can open it for example under "Ports" and clicking on "Open in Browser".
You can work on your game using Github codespaces (click "Code" and then "Codespaces" and then "create codespace on main"). It should run the game locally in the background. You can open it for example under "Ports" and clicking on "Open in Browser".
Note: You have to wait until npm started properly, which might take a good while.
@@ -85,12 +77,11 @@ git clone https://github.com/hhu-adam/GameSkeleton.git
Download dependencies and build the game:
```bash
cd GameSkeleton
lake update
lake exe cache get # if your game depends on mathlib
lake update -R
lake build
```
Clone the game repository into a directory next to the game:
Clone the server repository into a directory next to the game:
```bash
cd ..
git clone https://github.com/leanprover-community/lean4game.git
@@ -109,16 +100,39 @@ Run the game:
npm start
```
You should see a message like this:
```bash
[server] > lean4-game@0.1.0 start_server
[server] > (cd server && lake build) && (cd relay && cross-env NODE_ENV=development nodemon -e mjs --exec "node ./index.mjs")
[server]
[client]
[client] > lean4-game@0.1.0 start_client
[client] > cross-env NODE_ENV=development vite --host
[client]
[server] [nodemon] 3.0.#
[server] [nodemon] to restart at any time, enter `rs`
[server] [nodemon] watching path(s): *.*
[server] [nodemon] watching extensions: mjs
[server] [nodemon] starting `node ./index.mjs`
[client]
[client] VITE v4.5.1 ready in \#\#\# ms
[client]
[client] ➜ Local: http://localhost:3000/
[client] ➜ Network: http://###.###.###.##:3000/
[client] [vite-plugin-static-copy] Collected 7 items.
[server] (node:#####) [DEP0040] [server] Listening on 8080
```
This takes a little time. Eventually, the game is available on http://localhost:3000/#/g/local/GameSkeleton. Replace `GameSkeleton` with the folder name of your local game.
## Modifying the GameServer
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with the same setup as above (manual installation) by setting `export LEAN4GAME=local` inside your local game before building it:
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with the same setup as above (manual installation) by using `lake update -R -Klean4game.local`:
```bash
cd NNG4
export LEAN4GAME=local
lake update
lake update -R -Klean4game.local
lake build
```
This causes lake to search locally for the `GameServer` lake package instead of using the version from github. Therefore, when you `lake build` your game, it will rebuild with the modified `GameServer`.
This causes lake to search locally for the `GameServer` lake package instead of using the version from github. Therefore, you can the local copy of the edit `GameServer` in `../lean4game` and
`lake build` will then directly use this modified copy to build your game.
+28
View File
@@ -0,0 +1,28 @@
# Notes for Server maintainer
In order to set up the server to allow imports, one needs to create a
[Github Access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). A fine-grained access token with only reading rights for public
repos will suffice.
You need to set the environment variables `LEAN4GAME_GITHUB_USER` and `LEAN4GAME_GITHUB_TOKEN`
with your user name and access token. For example, you can set these in `ecosystem.config.cjs` if
you're using `pm2`
Then people can call:
> https://{website}/import/trigger/{owner}/{repo}
where you replace:
- website: The website your server runs on, e.g. `localhost:3000`
- owner, repo: The owner and repository name of the game you want to load from github.
will trigger to download the latest version of your game from github onto your server.
Once this import reports "Done", you should be able to play your game under:
> https://{website}/#/g/{owner}/{repo}
## Data management
Everything downloaded remains in the folder `lean4game/games`.
The subfolder `tmp` contains downloaded artifacts and can be deleted without loss.
The other folders should only contain the built lean-games, sorted by owner and repo.
+37
View File
@@ -0,0 +1,37 @@
# Troubleshooting
Here are some issues experienced by users.
- You can reset the lake projects involved (i.e. the `server/` folder here as well as your [game's folder](https://github.com/hhu-adam/GameSkeleton)) with the following commands:
```
cd [THE PROJECT]
rm -rf .lake/
lake update -R
lake build
```
If you experience problems related to Lean or lake, you should first try to reset it this way.
# VSCode Dev-Container
* If you don't get the pop-up, you might have disabled them, and you can reenable it by
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
you might have deleted stuff from docker via your shell. Try deleting the container and image
explicitly in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time)
* On a working dev container setup, http://localhost:3000 should directly redirect you to http://localhost:3000/#/g/local/game, try if the latter is accessible.
# Manual Installation
Here are known issues/pitfalls with the local setup using `npm`.
* If `CDPATH` is set on your mac/linux system, it might provide issues with `npm start` resulting in a server crash or blank screen. In particular `npm start` will display
```
[server] sh: line 0: cd: server: No such file or directory
[server] npm run start_server exited with code 1
```
As a fix you might need to delete your manually set `CDPATH` environment variable.
# Publication
Errors concerning uploads to the server.
* Your game overview loads but the server crashes on loading a level: Check your game's github action is identical to the [GameSkeleton's](https://github.com/hhu-adam/GameSkeleton/blob/main/.github/workflows/build.yml), in particular that there is a step about building the "`gameserver`-executable".
+26 -11
View File
@@ -9,29 +9,44 @@ Before you continue, make sure there [exists a `v4.X.0`-tag in this repo](https:
Then, depending on the setup you use, do one of the following:
* Dev Container: Rebuild the VSCode Devcontainer.
* Local Setup: run `lake update` (followed by `lake exe cache get` if you depend on mathlib.)
* Gitpod/Codespaces: Create a fresh one
* **Dev Container**: Rebuild the VSCode Devcontainer (without Cache!).
* **Local Setup**: in your game's folder run the following:
```
lake update -R
lake build
```
This will update `lean4game` and `mathlib` in your project to the new lean version.
* Additionally, if you have a local copy of the server `lean4game`,
you should update this one to the matching version. Run the following in the folder `lean4game/`:
```
git fetch
git checkout {VERSION_TAG}
npm install
```
where `{VERSION_TAG}` is the tag from above of the form `v4.X.0`
* **Gitpod/Codespaces**: Create a fresh one
This will update your game (and the mathlib version you might be using) to the new lean version.
## Newest developing setup
There are a few files in your game repository which are used for the developing setup
(dev container/codespaces/gitpod). If you need to update your're developing setup, for example because it doesn't work
(dev container/codespaces/gitpod). If you need to update your developing setup, for example because it doesn't work
anymore, you will need to copy the relevant files from the [GameSkeleton](https://github.com/hhu-adam/GameSkeleton) template into your game repo.
The relevant files are:
```
.devcontainer/
.docker/
.github/
.gitpod/
.vscode/
lakefile.lean
.devcontainer/**
.docker/**
.gitpod
.vscode/**
```
simply copy them from the `GameSkeleton` into your game.
simply copy them from the `GameSkeleton` into your game and proceed as above,
i.e. `lake update -R && lake build`.
(Note: You should not need to modify any of these files, with the exception of the `lakefile.lean`,
where you need to add any dependencies of your game.)
where you need to add any dependencies of your game, or remove mathlib if you don't need it.)
+5 -3
View File
@@ -2,10 +2,12 @@
module.exports = {
apps : [{
name : "lean4game",
script : "server/index.mjs",
script : "relay/index.mjs",
env: {
NODE_ENV: "production",
PORT: 8002
LEAN4GAME_GITHUB_USER: "",
LEAN4GAME_GITHUB_TOKEN: "",
NODE_ENV: "production",
PORT: 8002
},
}]
}
-1
View File
@@ -33,7 +33,6 @@
</p>
</div>
</noscript>
<script src="coi-serviceworker.js"></script>
<script type="module" src="/client/src/index.tsx"></script>
</body>
+1900 -2592
View File
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -1,6 +1,7 @@
{
"name": "lean4-game",
"version": "0.1.0",
"type": "module",
"private": true,
"homepage": ".",
"dependencies": {
@@ -9,6 +10,10 @@
"@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^4.5.8",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@leanprover/infoview": "^0.4.3",
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1",
@@ -16,7 +21,6 @@
"@types/cytoscape": "^3.19.9",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react-swc": "^3.4.0",
"coi-serviceworker": "^0.1.7",
"cross-env": "^7.0.3",
"cytoscape": "^3.23.0",
"cytoscape-elk": "^2.1.0",
@@ -24,8 +28,8 @@
"debounce": "^1.2.1",
"express": "^4.18.2",
"lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c",
"lean4web": "github:hhu-adam/lean4web",
"octokit": "^2.0.14",
"lean4web": "github:hhu-adam/lean4web#414d9e62638a392fca278761b4c61a1d2e138bc7",
"octokit": "^3.1.2",
"path-browserify": "^1.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -37,6 +41,7 @@
"rehype-katex": "^6.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"request": "^2.88.2",
"request-progress": "^3.0.0",
"vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0",
@@ -53,7 +58,7 @@
"concurrently": "^7.6.0",
"css-loader": "^6.7.3",
"file-loader": "^6.2.0",
"nodemon": "^2.0.20",
"nodemon": "^3.0.1",
"react-refresh": "^0.14.0",
"style-loader": "^3.3.1",
"ts-loader": "^9.4.2",
@@ -62,12 +67,13 @@
},
"scripts": {
"start": "concurrently -n server,client -c blue,green \"npm run start_server\" \"npm run start_client\"",
"start_server": "cd server && lake build && cross-env NODE_ENV=development nodemon -e mjs --exec \"node ./index.mjs\"",
"start_server": "(cd server && lake build) && (cd relay && cross-env NODE_ENV=development nodemon -e mjs --exec \"node ./index.mjs\")",
"start_client": "cross-env NODE_ENV=development vite --host",
"build": "npm run build_server && npm run build_client",
"preview": "vite preview",
"build_server": "cd server && lake build",
"build_client": "cross-env NODE_ENV=production vite build",
"production": "cross-env NODE_ENV=production node server/index.mjs"
"production": "cross-env NODE_ENV=production node relay/index.mjs"
},
"eslintConfig": {
"extends": [
+10 -5
View File
@@ -1,12 +1,17 @@
#/bin/bash
# Note: This fails if there is no default toolchain installed
ELAN_HOME=$(lake env printenv ELAN_HOME)
# $1 : the game directory
# $2 : the lean4game folder
# $3 : the gameserver executable
(exec bwrap\
--ro-bind ../../lean4game /lean4game \
--ro-bind ../../$1 /game \
--ro-bind $ELAN_HOME /elan \
--ro-bind /usr /usr \
--bind $2 /lean4game \
--bind $1 /game \
--bind $ELAN_HOME /elan \
--bind /usr /usr \
--dev /dev \
--proc /proc \
--symlink usr/lib /lib\
@@ -22,6 +27,6 @@ ELAN_HOME=$(lake env printenv ELAN_HOME)
--unshare-uts \
--unshare-cgroup \
--die-with-parent \
--chdir "/lean4game/server/build/bin/" \
--chdir "/game/.lake/packages/GameServer/server/.lake/build/bin/" \
./gameserver --server /game
)
+31 -20
View File
@@ -11,6 +11,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TOKEN = process.env.LEAN4GAME_GITHUB_TOKEN
const USERNAME = process.env.LEAN4GAME_GITHUB_USER
const octokit = new Octokit({
auth: TOKEN
})
@@ -41,9 +42,10 @@ async function download(id, url, dest) {
requestProgress(request({
url,
headers: {
'User-Agent': 'abentkamp',
'accept': 'application/vnd.github+json',
'User-Agent': USERNAME,
'X-GitHub-Api-Version': '2022-11-28',
'Authorization': 'Bearer '+TOKEN
'Authorization': 'Bearer ' + TOKEN
}
}))
.on('progress', function (state) {
@@ -76,35 +78,44 @@ async function doImport (owner, repo, id) {
.reduce((acc, cur) => acc.created_at < cur.created_at ? cur : acc)
artifactId = artifact.id
const url = artifact.archive_download_url
if (!fs.existsSync("tmp")){
fs.mkdirSync("tmp");
// Make sure the download folder exists
if (!fs.existsSync(path.join(__dirname, "..", "games"))){
fs.mkdirSync(path.join(__dirname, "..", "games"));
}
if (!fs.existsSync(path.join(__dirname, "..", "games", "tmp"))){
fs.mkdirSync(path.join(__dirname, "..", "games", "tmp"));
}
progress[id].output += `Download from ${url}\n`
await download(id, url, `tmp/artifact_${artifactId}.zip`)
await download(id, url, path.join(__dirname, "..", "games", "tmp", `${owner.toLowerCase()}_${repo.toLowerCase()}_${artifactId}.zip`))
progress[id].output += `Download finished.\n`
await runProcess(id, "/bin/bash", [`${__dirname}/unpack.sh`, artifactId],".")
let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`);
manifest = JSON.parse(manifest);
if (manifest.length !== 1) {
throw `Unexpected manifest: ${JSON.stringify(manifest)}`
}
manifest[0].RepoTags = [`g/${owner.toLowerCase()}/${repo.toLowerCase()}:latest`]
fs.writeFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`, JSON.stringify(manifest));
await runProcess(id, "tar", ["-cvf", `../archive_${artifactId}.tar`, "."], `tmp/artifact_${artifactId}_inner/`)
await runProcess(id, "docker", ["load", "-i", `tmp/archive_${artifactId}.tar`])
await runProcess(id, "/bin/bash", [path.join(__dirname, "unpack.sh"), artifactId, owner.toLowerCase(), repo.toLowerCase()], path.join(__dirname, ".."))
// let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`);
// manifest = JSON.parse(manifest);
// if (manifest.length !== 1) {
// throw `Unexpected manifest: ${JSON.stringify(manifest)}`
// }
// manifest[0].RepoTags = [`g/${owner.toLowerCase()}/${repo.toLowerCase()}:latest`]
// fs.writeFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`, JSON.stringify(manifest));
// await runProcess(id, "tar", ["-cvf", `../archive_${artifactId}.tar`, "."], `tmp/artifact_${artifactId}_inner/`)
// // await runProcess(id, "docker", ["load", "-i", `tmp/archive_${artifactId}.tar`])
progress[id].done = true
progress[id].output += `Done.\n`
progress[id].output += `Done!\n`
progress[id].output += `Play the game at: {your website}/#/g/${owner}/${repo}\n`
} catch (e) {
progress[id].output += `Error: ${e.toString()}\n${e.stack}`
} finally {
// clean-up temp. files
if (artifactId) {
fs.rmSync(`tmp/artifact_${artifactId}.zip`, {force: true, recursive: true});
fs.rmSync(`tmp/artifact_${artifactId}`, {force: true, recursive: true});
fs.rmSync(`tmp/artifact_${artifactId}_inner`, {force: true, recursive: true});
fs.rmSync(`tmp/archive_${artifactId}.tar`, {force: true, recursive: true});
fs.rmSync(path.join(__dirname, "..", "games", "tmp", `${owner}_${repo}_${artifactId}.zip`), {force: true, recursive: false});
fs.rmSync(path.join(__dirname, "..", "games", "tmp", `${owner}_${repo}_${artifactId}`), {force: true, recursive: true});
}
progress[id].done = true
}
await new Promise(resolve => setTimeout(resolve, 10000))
}
export const importTrigger = (req, res) => {
+88 -57
View File
@@ -6,25 +6,21 @@ import * as url from 'url';
import * as rpc from 'vscode-ws-jsonrpc';
import * as jsonrpcserver from 'vscode-ws-jsonrpc/server';
import os from 'os';
import fs from 'fs';
import anonymize from 'ip-anonymize';
// import { importTrigger, importStatus } from './import.mjs'
import { importTrigger, importStatus } from './import.mjs'
// import fs from 'fs'
/**
* Add a game here if the server should keep a queue of pre-loaded games ready at all times.
*
* IMPORTANT! Tags here need to be lower case!
*/
const games = {
"g/hhu-adam/robo": {
dir: "Robo",
queueLength: 5
},
"g/hhu-adam/nng4": {
dir: "NNG4",
queueLength: 5
},
"g/hhu-adam/nng4-old": {
dir: "NNG4-OLD",
queueLength: 0
}
const queueLength = {
"g/hhu-adam/robo": 2,
"g/hhu-adam/nng4": 5,
"g/djvelleman/stg4": 0,
"g/trequetrum/lean4game-logic": 0,
}
const __filename = url.fileURLToPath(import.meta.url);
@@ -36,11 +32,18 @@ const PORT = process.env.PORT || 8080;
var router = express.Router();
// router.get('/import/status/:owner/:repo', importStatus)
// router.get('/import/trigger/:owner/:repo', importTrigger)
router.get('/import/status/:owner/:repo', importStatus)
router.get('/import/trigger/:owner/:repo', importTrigger)
const server = app
.use(express.static(path.join(__dirname, '../client/dist/')))
.use(express.static(path.join(__dirname, '..', 'client', 'dist'))) // TODO: add a dist folder from inside the game
.use('/data/g/:owner/:repo/*', (req, res, next) => {
const owner = req.params.owner;
const repo = req.params.repo
const filename = req.params[0];
req.url = filename;
express.static(path.join(getGameDir(owner,repo),".lake","gamedata"))(req, res, next);
})
.use('/', router)
.listen(PORT, () => console.log(`Listening on ${PORT}`));
@@ -54,41 +57,61 @@ const isDevelopment = environment === 'development'
/** We keep queues of started Lean Server processes to be ready when a user arrives */
const queue = {}
function tag(owner, repo) {
function getTag(owner, repo) {
return `g/${owner.toLowerCase()}/${repo.toLowerCase()}`
}
function startServerProcess(owner, repo) {
let game_dir = (owner == 'local') ?
repo : games[tag(owner, repo)]?.dir
function getGameDir(owner, repo) {
owner = owner.toLowerCase()
if (owner == 'local') {
if(!isDevelopment) {
console.error(`No local games in production mode.`)
return
return ""
}
} else {
if(!fs.existsSync(path.join(__dirname, '..', 'games'))) {
console.error(`Did not find the following folder: ${path.join(__dirname, '..', 'games')}`)
console.error('Did you already import any games?')
return ""
}
// TODO: This test does not work
// if (!fs.existsSync(path.join("../", game_dir))) {
// console.error(`Game folder does not exists: ${game_dir}`)
// return
// }
}
if (!game_dir) {
console.error(`Unknown game: ${tag(owner, repo)}`)
return
let game_dir = (owner == 'local') ?
path.join(__dirname, '..', '..', repo) : // note: here we need `repo` to be case sensitive
path.join(__dirname, '..', 'games', `${owner}`, `${repo.toLowerCase()}`)
if(!fs.existsSync(game_dir)) {
console.error(`Game '${game_dir}' does not exist!`)
return ""
}
return game_dir;
}
function startServerProcess(owner, repo) {
let game_dir = getGameDir(owner, repo)
if (!game_dir) return;
let serverProcess
if (isDevelopment) {
let args = ["--server", path.join("../../../../", game_dir)]
serverProcess = cp.spawn("./gameserver", args,
{ cwd: path.join(__dirname, "./build/bin/") })
let args = ["--server", game_dir]
let binDir = path.join(game_dir, ".lake", "packages", "GameServer", "server", ".lake", "build", "bin")
// Note: `cwd` is important to be the `bin` directory as `Watchdog` calls `./gameserver` again
if (fs.existsSync(binDir)) {
// Try to use the game's own copy of `gameserver`.
serverProcess = cp.spawn("./gameserver", args, { cwd: binDir })
} else {
// If the game is built with `-Klean4game.local` there is no copy in the lake packages.
serverProcess = cp.spawn("./gameserver", args,
{ cwd: path.join(__dirname, "..", "server", ".lake", "build", "bin") })
}
} else {
serverProcess = cp.spawn("./bubblewrap.sh",
[game_dir],
[ game_dir, path.join(__dirname, '..')],
{ cwd: __dirname })
}
serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`)
)
@@ -101,15 +124,21 @@ function startServerProcess(owner, repo) {
}
/** start Lean Server processes to refill the queue */
function fillQueue(owner, repo) {
while (queue[tag(owner, repo)].length < games[tag(owner, repo)].queueLength) {
const serverProcess = startServerProcess(tag(owner, repo))
queue[tag(owner, repo)].push(serverProcess)
function fillQueue(tag) {
while (queue[tag].length < queueLength[tag]) {
let serverProcess
serverProcess = startServerProcess(tag)
if (serverProcess == null) {
console.error('serverProcess was undefined/null')
return
}
queue[tag].push(serverProcess)
}
}
// // TODO: We disabled queue for now
// if (!isDevelopment) { // Don't use queue in development
// for (let tag in games) {
// for (let tag in queueLength) {
// queue[tag] = []
// fillQueue(tag)
// }
@@ -122,19 +151,21 @@ wss.addListener("connection", function(ws, req) {
if (!reRes) { console.error(`Connection refused because of invalid URL: ${req.url}`); return; }
const owner = reRes[1]
const repo = reRes[2]
// const tag = `g/${owner.toLowerCase()}/${repo.toLowerCase()}`
// // TODO
// if (isDevelopment && process.env.DEV_CONTAINER) {
// tag = `g/local/game`
// }
const tag = getTag(owner, repo)
let ps;
if (!queue[tag(owner, repo)] || queue[tag(owner, repo)].length == 0) {
ps = startServerProcess(owner, repo)
let ps
if (!queue[tag] || queue[tag].length == 0) {
ps = startServerProcess(owner, repo)
} else {
ps = queue[tag(owner, repo)].shift() // Pick the first Lean process; it's likely to be ready immediately
fillQueue(owner, repo)
console.info('Got process from the queue')
ps = queue[tag].shift() // Pick the first Lean process; it's likely to be ready immediately
fillQueue(tag)
}
if (ps == null) {
console.error('server process is undefined/null')
return
}
socketCounter += 1;
@@ -147,14 +178,14 @@ wss.addListener("connection", function(ws, req) {
onClose: (cb) => { ws.on("close", cb) },
send: (data, cb) => { ws.send(data,cb) }
}
const reader = new rpc.WebSocketMessageReader(socket);
const writer = new rpc.WebSocketMessageWriter(socket);
const reader = new rpc.WebSocketMessageReader(socket)
const writer = new rpc.WebSocketMessageWriter(socket)
const socketConnection = jsonrpcserver.createConnection(reader, writer, () => ws.close())
const serverConnection = jsonrpcserver.createProcessStreamConnection(ps);
const serverConnection = jsonrpcserver.createProcessStreamConnection(ps)
socketConnection.forward(serverConnection, message => {
if (isDevelopment) {console.log(`CLIENT: ${JSON.stringify(message)}`)}
return message;
});
})
serverConnection.forward(socketConnection, message => {
if (isDevelopment) {console.log(`SERVER: ${JSON.stringify(message)}`)}
return message;
@@ -165,9 +196,9 @@ wss.addListener("connection", function(ws, req) {
ws.on('close', () => {
console.log(`[${new Date()}] Socket closed - ${ip}`)
socketCounter -= 1;
socketCounter -= 1
})
socketConnection.onClose(() => serverConnection.dispose());
serverConnection.onClose(() => socketConnection.dispose());
socketConnection.onClose(() => serverConnection.dispose())
serverConnection.onClose(() => socketConnection.dispose())
})
+34
View File
@@ -0,0 +1,34 @@
#/bin/bash
ARTIFACT_ID=$1
OWNER=$2
REPO=$3
# mkdir -p games
cd games
# mkdir -p tmp
mkdir -p ${OWNER}
echo "Unpacking ZIP."
unzip -o tmp/${OWNER}_${REPO}_${ARTIFACT_ID}.zip -d tmp/${OWNER}_${REPO}_${ARTIFACT_ID}
echo "Unpacking game."
# exit the npm project to avoid reloading. TODO: Where should we actually save these?
echo "Delete old version of the game"
rm -rf ${OWNER}/${REPO}
mkdir -p ${OWNER}/${REPO}
for f in tmp/${OWNER}_${REPO}_${ARTIFACT_ID}/* #Should only be one file
do
echo "Unpacking $f"
#tar -xvzf $f -C games/${OWNER}/${REPO}
unzip -q -o $f -d ${OWNER}/${REPO}
done
# Delete temporary files
rm -f tmp/${OWNER}_${REPO}_${ARTIFACT_ID}.zip
rm -fr tmp/${OWNER}_${REPO}_${ARTIFACT_ID}
-3
View File
@@ -1,3 +0,0 @@
.lake
adam
lakefile32.olean
+12 -8
View File
@@ -1,13 +1,17 @@
import Lean.Server.Watchdog
import GameServer.FileWorker
import GameServer.Commands
import GameServer.Game
Game "TestGame"
Title "Hello Test"
-- TODO: The only reason we import `Commands` is so that it gets built to on `lake build`
-- should we have a different solution?
World "Test"
Level 1
unsafe def main : List String → IO UInt32 := fun args => do
let e ← IO.getStderr
Statement : 1 = 1 := sorry
Lean.enableInitializersExecution
MakeGame
-- TODO: remove this argument
if args[0]? == some "--server" then
GameServer.FileWorker.workerMain {} args
else
e.putStrLn s!"Expected `--server`"
return 1
+241 -343
View File
@@ -1,23 +1,14 @@
import GameServer.EnvExtensions
import GameServer.Helpers
import GameServer.Inventory
import GameServer.Options
import GameServer.SaveData
import GameServer.Hints
import I18n
open Lean Meta Elab Command
set_option autoImplicit false
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
register_option lean4game.showDependencyReasons : Bool := {
defValue := false
descr := "show reasons for calculated world dependencies."
}
/-- Let `MakeGame` print the reasons why the worlds depend on each other.
Note: currently unused in favour of setting `set_option trace.debug true`. -/
register_option lean4game.verbose : Bool := {
defValue := false
descr := "display more info messages to help developing the game."
}
/-! # Game metadata -/
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
@@ -43,150 +34,105 @@ elab "Level" level:num : command => do
/-- Define the title of the current game/world/level. -/
elab "Title" t:str : command => do
let title ← t.getString.translate
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with title := t.getString}
| .World => modifyCurWorld fun world => pure {world with title := t.getString}
| .Game => modifyCurGame fun game => pure {game with title := t.getString}
| .Level => modifyCurLevel fun level => pure {level with title := title}
| .World => modifyCurWorld fun world => pure {world with title := title}
| .Game => modifyCurGame fun game => pure {game with
title := t.getString
tile := {game.tile with title := title}}
/-- Define the introduction of the current game/world/level. -/
elab "Introduction" t:str : command => do
let intro ← t.getString.translate
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with introduction := t.getString}
| .World => modifyCurWorld fun world => pure {world with introduction := t.getString}
| .Game => modifyCurGame fun game => pure {game with introduction := t.getString}
| .Level => modifyCurLevel fun level => pure {level with introduction := intro}
| .World => modifyCurWorld fun world => pure {world with introduction := intro}
| .Game => modifyCurGame fun game => pure {game with introduction := intro}
/-- Define the info of the current game. Used for e.g. credits -/
elab "Info" t:str : command => do
let info ← t.getString.translate
match ← getCurLayer with
| .Level => pure ()
| .World => pure ()
| .Game => modifyCurGame fun game => pure {game with info := t.getString}
| .Level =>
logError "Can't use `Info` in a level!"
pure ()
| .World =>
logError "Can't use `Info` in a world"
pure ()
| .Game => modifyCurGame fun game => pure {game with info := info}
/-- Provide the location of the image for the current game/world/level.
Paths are relative to the lean project's root. -/
elab "Image" t:str : command => do
let file := t.getString
if not <| ← System.FilePath.pathExists file then
logWarningAt t s!"Make sure the cover image '{file}' exists."
if not <| file.startsWith "images/" then
logWarningAt t s!"The file name should start with `images/`. Make sure all images are in that folder."
match ← getCurLayer with
| .Level =>
logWarning "Level-images not implemented yet" -- TODO
modifyCurLevel fun level => pure {level with image := file}
| .World =>
modifyCurWorld fun world => pure {world with image := file}
| .Game =>
logWarning "Main image of the game not implemented yet" -- TODO
modifyCurGame fun game => pure {game with image := file}
/-- Define the conclusion of the current game or current level if some
building a level. -/
elab "Conclusion" t:str : command => do
let conclusion ← t.getString.translate
match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with conclusion := t.getString}
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString}
| .Game => modifyCurGame fun game => pure {game with conclusion := t.getString}
| .Level => modifyCurLevel fun level => pure {level with conclusion := conclusion}
| .World => modifyCurWorld fun world => pure {world with conclusion := conclusion}
| .Game => modifyCurGame fun game => pure {game with conclusion := conclusion}
/-- A list of games that should be played before this one. Example `Prerequisites "NNG" "STG"`. -/
elab "Prerequisites" t:str* : command => do
modifyCurGame fun game => pure {game with
tile := {game.tile with prerequisites := t.map (·.getString) |>.toList}}
/-- Short caption for the game (1 sentence) -/
elab "CaptionShort" t:str : command => do
let caption ← t.getString.translate
modifyCurGame fun game => pure {game with
tile := {game.tile with short := caption}}
/-- More detailed description what the game is about (2-4 sentences). -/
elab "CaptionLong" t:str : command => do
let caption ← t.getString.translate
modifyCurGame fun game => pure {game with
tile := {game.tile with long := caption}}
/-- A list of Languages the game is translated to. For example `Languages "German" "English"`.
NOTE: For the time being, only a single language is supported.
-/
elab "Languages" t:str* : command => do
modifyCurGame fun game => pure {game with
tile := {game.tile with languages := t.map (·.getString) |>.toList}}
/-- The Image of the game (optional). TODO: Not impementeds -/
elab "CoverImage" t:str : command => do
let file := t.getString
if not <| ← System.FilePath.pathExists file then
logWarningAt t s!"Make sure the cover image '{file}' exists."
if not <| file.startsWith "images/" then
logWarningAt t s!"The file name should start with `images/`. Make sure all images are in that folder."
modifyCurGame fun game => pure {game with
tile := {game.tile with image := file}}
/-! # Inventory
The inventory contains docs for tactics, lemmas, and definitions. These are all locked
The inventory contains docs for tactics, theorems, and definitions. These are all locked
in the first level and get enabled during the game.
-/
/-! ## Doc entries -/
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning.
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
I used this workaround, because I needed a new name (with correct namespace etc)
to be used, and I don't know how to create a new ident with same position but different name.
-/
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
(template : Option String := none) : CommandElabM Unit := do
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
let env ← getEnv
let n := name
-- Find a key with matching `(type, name)`.
match (inventoryTemplateExt.getState env).findIdx?
(fun x => x.name == n && x.type == type) with
-- Nothing to do if the entry exists
| some _ => pure ()
| none =>
match template with
-- Warn about missing documentation
| none =>
let docstring ← match (← getDocstring env name type) with
| some ds =>
logInfoAt ref (m!"Missing {type} Documentation. Using existing docstring. " ++
m!"Add {name}\nAdd `{type}Doc {name}` somewhere above this statement.")
pure s!"*(lean docstring)*\\\n{ds}"
| none =>
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
pure "(missing)"
-- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := docstring})
-- Add the default documentation
| some s =>
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := s })
logInfoAt ref (m!"Missing {type} Documentation: {name}, used default (e.g. provided " ++
m!"docstring) instead. If you want to write a different description, add " ++
m!"`{type}Doc {name}` somewhere above this statement.")
/-- Documentation entry of a tactic. Example:
```
@@ -196,37 +142,42 @@ TacticDoc rw "`rw` stands for rewrite, etc. "
* The identifier is the tactics name. Some need to be escaped like `«have»`.
* The description is a string supporting Markdown.
-/
elab "TacticDoc" name:ident content:str : command =>
elab doc:docComment ? "TacticDoc" name:ident content:str ? : command => do
let doc ← parseDocCommentLegacy doc content
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · {
type := .Tactic
name := name.getId
displayName := name.getId.toString
content := content.getString })
content := doc })
/-- Documentation entry of a lemma. Example:
/-- Documentation entry of a theorem. Example:
```
LemmaDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc."
TheoremDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc."
```
* The first identifier is used in the commands `[New/Only/Disabled]Lemma`.
It is preferably the true name of the lemma. However, this is not required.
* The first identifier is used in the commands `[New/Only/Disabled]Theorem`.
It is preferably the true name of the theorem. However, this is not required.
* The string following `as` is the displayed name (in the Inventory).
* The identifier after `in` is the category to group lemmas by (in the Inventory).
* The identifier after `in` is the category to group theorems by (in the Inventory).
* The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires
The lemma/definition to have the same fully qualified name as in mathlib.
The theorem/definition to have the same fully qualified name as in mathlib.
-/
elab "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str : command =>
elab doc:docComment ? "TheoremDoc" name:ident "as" displayName:str "in" category:str content:str ? :
command => do
let doc ← parseDocCommentLegacy doc content
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · {
type := .Lemma
name := name.getId
category := category.getString
displayName := displayName.getString
content := content.getString })
content := doc })
-- TODO: Catch the following behaviour.
-- 1. if `LemmaDoc` appears in the same file as `Statement`, it will silently use
-- 1. if `TheoremDoc` appears in the same file as `Statement`, it will silently use
-- it but display the info that it wasn't found in `Statement`
-- 2. if it appears in a later file, however, it will silently not do anything and keep
-- the first one.
@@ -244,37 +195,26 @@ DefinitionDoc Function.Bijective as "Bijective" "defined as `Injective f ∧ Sur
* The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires
The lemma/definition to have the same fully qualified name as in mathlib.
The theorem/definition to have the same fully qualified name as in mathlib.
-/
elab "DefinitionDoc" name:ident "as" displayName:str template:str : command =>
elab doc:docComment ? "DefinitionDoc" name:ident "as" displayName:str template:str ? : command => do
let doc ← parseDocCommentLegacy doc template
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · {
type := .Definition
name := name.getId,
displayName := displayName.getString,
content := template.getString })
content := doc })
/-! ## Add inventory items -/
def getStatement (name : Name) : CommandElabM MessageData := do
return ← addMessageContextPartial (.ofPPFormat { pp := fun
| some ctx => ctx.runMetaM <| PrettyPrinter.ppSignature name
| none => return "that's a bug." })
-- Note: We use `String` because we can't send `MessageData` as json, but
-- `MessageData` might be better for interactive highlighting.
/-- Get a string of the form `my_lemma (n : ℕ) : n + n = 2 * n`.
Note: A statement like `theorem abc : ∀ x : Nat, x ≥ 0` would be turned into
`theorem abc (x : Nat) : x ≥ 0` by `PrettyPrinter.ppSignature`. -/
def getStatementString (name : Name) : CommandElabM String := do
try
return ← (← getStatement name).toString
catch
| _ => throwError m!"Could not find {name} in context."
-- TODO: I think it would be nicer to unresolve Namespaces as much as possible.
def checkCommandNotDuplicated (items : Array Name) (cmd := "Command") : CommandElabM Unit := do
if ¬ items.isEmpty then
logWarning s!"You should only use one `{cmd}` per level, but it takes multiple arguments: `{cmd} obj₁ obj₂ obj₃`!"
/-- Declare tactics that are introduced by this level. -/
elab "NewTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.new) "NewTactic"
for name in ↑args do
checkInventoryDoc .Tactic name -- TODO: Add (template := "[docstring]")
modifyCurLevel fun level => pure {level with
@@ -282,14 +222,16 @@ elab "NewTactic" args:ident* : command => do
/-- Declare tactics that are introduced by this level but do not show up in inventory. -/
elab "NewHiddenTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.hidden) "NewHiddenTactic"
for name in ↑args do
checkInventoryDoc .Tactic name (template := "")
modifyCurLevel fun level => pure {level with
tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId),
hidden := level.tactics.hidden ++ args.map (·.getId)}}
/-- Declare lemmas that are introduced by this level. -/
elab "NewLemma" args:ident* : command => do
/-- Declare theorems that are introduced by this level. -/
elab "NewTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.new) "NewTheorem"
for name in ↑args do
try let _decl ← getConstInfo name.getId catch
| _ => logErrorAt name m!"unknown identifier '{name}'."
@@ -299,6 +241,7 @@ elab "NewLemma" args:ident* : command => do
/-- Declare definitions that are introduced by this level. -/
elab "NewDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.new) "NewDefinition"
for name in ↑args do checkInventoryDoc .Definition name -- TODO: Add (template := "[mathlib]")
modifyCurLevel fun level => pure {level with
definitions := {level.definitions with new := args.map (·.getId)}}
@@ -306,31 +249,36 @@ elab "NewDefinition" args:ident* : command => do
/-- Declare tactics that are temporarily disabled in this level.
This is ignored if `OnlyTactic` is set. -/
elab "DisabledTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.disabled) "DisabledTactic"
for name in ↑args do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with
tactics := {level.tactics with disabled := args.map (·.getId)}}
/-- Declare lemmas that are temporarily disabled in this level.
This is ignored if `OnlyLemma` is set. -/
elab "DisabledLemma" args:ident* : command => do
/-- Declare theorems that are temporarily disabled in this level.
This is ignored if `OnlyTheorem` is set. -/
elab "DisabledTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.disabled) "DisabledTheorem"
for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with disabled := args.map (·.getId)}}
/-- Declare definitions that are temporarily disabled in this level -/
elab "DisabledDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.disabled) "DisabledDefinition"
for name in ↑args do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with
definitions := {level.definitions with disabled := args.map (·.getId)}}
/-- Temporarily disable all tactics except the ones declared here -/
elab "OnlyTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.only) "OnlyTactic"
for name in ↑args do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with
tactics := {level.tactics with only := args.map (·.getId)}}
/-- Temporarily disable all lemmas except the ones declared here -/
elab "OnlyLemma" args:ident* : command => do
/-- Temporarily disable all theorems except the ones declared here -/
elab "OnlyTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.only) "OnlyTheorem"
for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with only := args.map (·.getId)}}
@@ -338,65 +286,69 @@ elab "OnlyLemma" args:ident* : command => do
/-- Temporarily disable all definitions except the ones declared here.
This is ignored if `OnlyDefinition` is set. -/
elab "OnlyDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.only) "OnlyDefinition"
for name in ↑args do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with
definitions := {level.definitions with only := args.map (·.getId)}}
/-- Define which tab of Lemmas is opened by default. Usage: `LemmaTab "Nat"`.
/-- Define which tab of Lemmas is opened by default. Usage: `TheoremTab "Nat"`.
If omitted, the current tab will remain open. -/
elab "LemmaTab" category:str : command =>
elab "TheoremTab" category:str : command =>
modifyCurLevel fun level => pure {level with lemmaTab := category.getString}
/-! DEPRECATED -/
elab doc:docComment ? "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str ? :
command => do
logWarning "Deprecated. Has been renamed to `TheoremDoc`"
let doc ← parseDocCommentLegacy doc content
modifyEnv (inventoryTemplateExt.addEntry · {
type := .Lemma
name := name.getId
category := category.getString
displayName := displayName.getString
content := doc })
elab "NewLemma" args:ident* : command => do
logWarning "Deprecated. Has been renamed to `NewTheorem`"
checkCommandNotDuplicated ((←getCurLevel).lemmas.new) "NewLemma"
for name in ↑args do
try let _decl ← getConstInfo name.getId catch
| _ => logErrorAt name m!"unknown identifier '{name}'."
checkInventoryDoc .Lemma name -- TODO: Add (template := "[mathlib]")
modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with new := args.map (·.getId)}}
elab "DisabledLemma" args:ident* : command => do
logWarning "Deprecated. Has been renamed to `DisabledTheorem`"
checkCommandNotDuplicated ((←getCurLevel).lemmas.disabled) "DisabledLemma"
for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with disabled := args.map (·.getId)}}
elab "OnlyLemma" args:ident* : command => do
logWarning "Deprecated. Has been renamed to `OnlyTheorem`"
checkCommandNotDuplicated ((←getCurLevel).lemmas.only) "OnlyLemma"
for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with only := args.map (·.getId)}}
elab "LemmaTab" category:str : command => do
logWarning "Deprecated. Has been renamed to `TheoremTab`"
modifyCurLevel fun level => pure {level with lemmaTab := category.getString}
/-! # Exercise Statement -/
/-- A `attr := ...` option for `Statement`. Add attributes to the defined theorem. -/
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO
-- TODO: Reuse the following code for checking available tactics in user code:
structure UsedInventory where
(tactics : HashSet Name := {})
(definitions : HashSet Name := {})
(lemmas : HashSet Name := {})
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do
match stx with
| .missing => return acc
| .node _info kind args =>
if kind == `GameServer.Tactic.Hint || kind == `GameServer.Tactic.Branch then return acc
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc
| .atom _info val =>
-- ignore syntax elements that do not start with a letter
-- and ignore some standard keywords
let allowed := ["with", "fun", "at", "only", "by"]
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
return {acc with tactics := acc.tactics.insert val}
else
return acc
| .ident _info _rawVal val _preresolved =>
let ns ←
try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error
return ← ns.foldlM (fun acc n => do
if let some (.thmInfo ..) := (← getEnv).find? n then
return {acc with lemmas := acc.lemmas.insertMany ns}
else
return {acc with definitions := acc.definitions.insertMany ns}
) acc
-- #check expandOptDocComment?
/-- Define the statement of the current level. -/
elab doc:docComment ? attrs:Parser.Term.attributes ?
"Statement" statementName:ident ? sig:declSig val:declVal : command => do
let lvlIdx ← getCurLevelIdx
let docContent : Option String := match doc with
| none => none
| some s => match s.raw[1] with
| .atom _ val => val.dropRight 2 |>.trim -- some (val.extract 0 (val.endPos - ⟨2⟩))
| _ => none --panic "not implemented error message" --throwErrorAt s "unexpected doc string{indentD s.raw[1]}"
let docContent ← parseDocComment doc
let docContent ← match docContent with
| none => pure none
| some d => d.translate
-- Save the messages before evaluation of the proof.
let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} })
@@ -453,12 +405,41 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
.nest hidden $
.compose (.ofGoal text) (.ofGoal goal) := msg.data then
let hint ← liftTermElabM $ withMCtx ctx.mctx $ withLCtx ctx.lctx #[] $ withEnv ctx.env do
let goalDecl ← goal.getDecl
let fvars := goalDecl.lctx.decls.toArray.filterMap id |> Array.map (·.fvarId)
-- NOTE: This code about `hintFVarsNames` is duplicated from `RpcHandlers`
-- where the variable bijection is constructed, and they
-- need to be matching.
-- NOTE: This is a bit a hack of somebody who does not know how meta-programming works.
-- All we want here is a list of `userNames` for the `FVarId`s in `hintFVars`...
-- and we wrap them in `«{}»` here since I don't know how to do it later.
let mut hintFVarsNames : Array Expr := #[]
for fvar in fvars do
let name₁ ← fvar.getUserName
hintFVarsNames := hintFVarsNames.push <| Expr.fvar ⟨s!"«\{{name₁}}»"⟩
let text ← instantiateMVars (mkMVar text)
-- Evaluate the text in the `Hint`'s context to get the old variable names.
let rawText := (← GameServer.evalHintMessage text) hintFVarsNames
let ctx₂ := {env := ← getEnv, mctx := ← getMCtx, lctx := ← getLCtx, opts := {}}
let rawText : String ← (MessageData.withContext ctx₂ rawText).toString
return {
goal := ← abstractCtx goal
text := ← instantiateMVars (mkMVar text)
text := text
rawText := rawText
strict := strict == 1
hidden := hidden == 1
}
-- Note: The current setup for hints is a bit convoluted, but for now we need to
-- send the text once through i18n to register it in the env extension.
-- This could probably be rewritten once i18n works fully.
let _ ← hint.rawText.translate
hints := hints.push hint
else
nonHintMsgs := nonHintMsgs.push msg
@@ -497,22 +478,7 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
/-! # Hints -/
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-- Remove any spaces at the beginning of a new line -/
partial def removeIndentation (s : String) : String :=
let rec loop (i : String.Pos) (acc : String) (removeSpaces := false) : String :=
let c := s.get i
let i := s.next i
if s.atEnd i then
acc.push c
else if removeSpaces && c == ' ' then
loop i acc (removeSpaces := true)
else if c == '\n' then
loop i (acc.push c) (removeSpaces := true)
else
loop i (acc.push c)
loop ⟨0⟩ ""
open GameServer in
/-- A tactic that can be used inside `Statement`s to indicate in which proof states players should
see hints. The tactic does not affect the goal state.
@@ -626,6 +592,7 @@ elab "Template" tacs:tacticSeq : tactic => do
modifyLevel (←getCurLevelId) fun level => do
return {level with template := s!"{template}"}
-- TODO: Notes for testing if a declaration has the simp attribute
-- -- Test: From zulip
@@ -644,98 +611,6 @@ elab "Template" tacs:tacticSeq : tactic => do
/-! # Make Game -/
def GameLevel.getInventory (level : GameLevel) : InventoryType → InventoryInfo
| .Tactic => level.tactics
| .Definition => level.definitions
| .Lemma => level.lemmas
def GameLevel.setComputedInventory (level : GameLevel) :
InventoryType → Array InventoryTile → GameLevel
| .Tactic, v => {level with tactics := {level.tactics with tiles := v}}
| .Definition, v => {level with definitions := {level.definitions with tiles := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}}
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
match (newArrows.find? id, decendants.find? id) with
| (some _, some _) => return (newArrows, decendants)
| _ =>
let mut newArr := newArrows
let mut desc := decendants
desc := desc.insert id {} -- mark as worked in case of loops
newArr := newArr.insert id {} -- mark as worked in case of loops
let children := arrows.findD id {}
let mut trimmedChildren := children
let mut theseDescs := children
for child in children do
(newArr, desc) := removeTransitiveAux child arrows newArr desc
let childDescs := desc.findD child {}
theseDescs := theseDescs.insertMany childDescs
for d in childDescs do
trimmedChildren := trimmedChildren.erase d
desc := desc.insert id theseDescs
newArr := newArr.insert id trimmedChildren
return (newArr, desc)
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
let mut newArr := {}
let mut desc := {}
for id in arrows.toArray.map Prod.fst do
(newArr, desc) := removeTransitiveAux id arrows newArr desc
if (desc.findD id {}).contains id then
logError <| m!"Loop at {id}. " ++
m!"This should not happen and probably means that `findLoops` has a bug."
-- DEBUG:
-- for ⟨x, hx⟩ in desc.toList do
-- m := m ++ m!"{x}: {hx.toList}\n"
-- logError m
return newArr
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
For performance reason it returns a HashSet of visited
nodes as well. This is filled with all nodes ever looked at as they cannot be
part of a loop anymore. -/
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
(path : Array Name := #[]) (visited : HashSet Name := {}) :
Array Name × HashSet Name := Id.run do
let mut visited := visited
match path.getIdx? node with
| some i =>
-- Found a loop: `node` is already the iᵗʰ element of the path
return (path.extract i path.size, visited.insert node)
| none =>
for successor in arrows.findD node {} do
-- If we already visited the successor, it cannot be part of a loop anymore
if visited.contains successor then
continue
-- Find any loop involving `successor`
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
visited := visited.insert successor
-- No loop found in the dependants of `successor`
if loop.isEmpty then
continue
-- Found a loop, return it
return (loop, visited)
return (#[], visited.insert node)
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
let mut visited : HashSet Name := {}
for node in arrows.toArray.map (·.1) do
-- Skip a node if it was already visited
if visited.contains node then
continue
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
-- search starting from `node`
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
visited := moreVisited
if !loop.isEmpty then
return loop.toList
return []
/-- The worlds of a game are joint by dependencies. These are
automatically computed but can also be defined with the syntax
`Dependency World₁ → World₂ → World₃`. -/
@@ -810,8 +685,12 @@ elab "MakeGame" : command => do
-- Items that should not be displayed in inventory
let mut hiddenItems : HashSet Name := {}
let allWorlds := game.worlds.nodes.toArray
let nrWorlds := allWorlds.size
let mut nrLevels := 0
-- Calculate which "items" are used/new in which world
for (worldId, world) in game.worlds.nodes.toArray do
for (worldId, world) in allWorlds do
let mut usedItems : HashSet Name := {}
let mut newItems : HashSet Name := {}
for inventoryType in #[.Tactic, .Definition, .Lemma] do
@@ -850,9 +729,12 @@ elab "MakeGame" : command => do
-- logInfo m!"{worldId} uses: {usedItems.toList}"
-- logInfo m!"{worldId} introduces: {newItems.toList}"
-- Moreover, count the number of levels in the game
nrLevels := nrLevels + world.levels.toArray.size
/- for each "item" this is a HashSet of `worldId`s that introduce this item -/
let mut worldsWithNewItem : HashMap Name (HashSet Name) := {}
for (worldId, _world) in game.worlds.nodes.toArray do
for (worldId, _world) in allWorlds do
for newItem in newItemsInWorld.findD worldId {} do
worldsWithNewItem := worldsWithNewItem.insert newItem $
(worldsWithNewItem.findD newItem {}).insert worldId
@@ -864,7 +746,7 @@ elab "MakeGame" : command => do
let mut dependencyReasons : HashMap (Name × Name) (HashSet Name) := {}
-- Calculate world dependency graph `game.worlds`
for (dependentWorldId, _dependentWorld) in game.worlds.nodes.toArray do
for (dependentWorldId, _dependentWorld) in allWorlds do
let mut dependsOnWorlds : HashSet Name := {}
-- Adding manual dependencies that were specified via the `Dependency` command.
for (sourceId, targetId) in game.worlds.edges do
@@ -915,14 +797,25 @@ elab "MakeGame" : command => do
logError m!"{w1} depends on {w2} because of {items.toList}."
else
worldDependsOnWorlds ← removeTransitive worldDependsOnWorlds
-- need to delete all existing edges as they are already present in `worldDependsOnWorlds`.
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with edges := Array.empty}}
for (dependentWorldId, worldIds) in worldDependsOnWorlds.toArray do
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with
edges := game.worlds.edges.append (worldIds.toArray.map fun wid => (wid, dependentWorldId))}}
-- Add the number of levels and worlds to the tile for the landing page
modifyCurGame fun game => pure {game with tile := {game.tile with
levels := nrLevels
worlds := nrWorlds }}
-- Apparently we need to reload `game` to get the changes to `game.worlds` we just made
let game ← getCurGame
let mut allItemsByType : HashMap InventoryType (HashSet Name) := {}
-- Compute which inventory items are available in which level:
for inventoryType in #[.Tactic, .Definition, .Lemma] do
@@ -984,6 +877,7 @@ elab "MakeGame" : command => do
name := item
displayName := data.displayName
category := data.category
altTitle := data.statement
hidden := hiddenItems.contains item })
@@ -1003,6 +897,7 @@ elab "MakeGame" : command => do
displayName := data.displayName
category := data.category
locked := false
altTitle := data.statement
hidden := hiddenItems.contains item }
itemsInWorld := itemsInWorld.insert worldId items
@@ -1022,7 +917,8 @@ elab "MakeGame" : command => do
displayName := data.displayName
category := data.category
locked := false
hidden := levelInfo.hidden.contains item }
altTitle := data.statement
hidden := hiddenItems.contains item }
-- add the exercise statement from the previous level
-- if it was named
@@ -1035,6 +931,7 @@ elab "MakeGame" : command => do
name := name
displayName := data.displayName
category := data.category
altTitle := data.statement
locked := false }
-- add marks for `disabled` and `new` lemmas here, so that they only apply to
@@ -1052,17 +949,18 @@ elab "MakeGame" : command => do
modifyLevel ⟨← getCurGameId, worldId, levelId⟩ fun level => do
return level.setComputedInventory inventoryType itemsArray
allItemsByType := allItemsByType.insert inventoryType allItems
/-! # Debugging tools -/
let getTiles (type : InventoryType) : CommandElabM (Array InventoryTile) := do
(allItemsByType.findD type {}).toArray.mapM (fun name => do
let some item ← getInventoryItem? name type
| throwError "Expected item to exist: {name}"
return item.toTile)
let inventory : InventoryOverview := {
lemmas := (← getTiles .Lemma).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
tactics := (← getTiles .Tactic).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
definitions := (← getTiles .Definition).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
lemmaTab := none
}
-- /-- Print current game for debugging purposes. -/
-- elab "PrintCurGame" : command => do
-- logInfo (toJson (← getCurGame))
/-- Print current level for debugging purposes. -/
elab "PrintCurLevel" : command => do
logInfo (repr (← getCurLevel))
/-- Print levels for debugging purposes. -/
elab "PrintLevels" : command => do
logInfo $ repr $ (← getCurWorld).levels.toArray
saveGameData allItemsByType inventory
+123 -19
View File
@@ -1,7 +1,13 @@
import GameServer.AbstractCtx
import GameServer.Graph
import GameServer.Hints
open GameServer
-- TODO: Is there a better place?
/-- Keywords that the server should not consider as tactics. -/
def GameServer.ALLOWED_KEYWORDS : List String :=
["with", "fun", "at", "only", "by", "generalizing"]
/-- The default game name if `Game "MyGame"` is not used. -/
def defaultGameName: String := "MyGame"
@@ -18,22 +24,6 @@ defined in this file.
open Lean
/-! ## Hints -/
/-- A hint to help the user with a specific goal state -/
structure GoalHintEntry where
goal : AbstractCtxResult
/-- Text of the hint as an expression of type `Array Expr → MessageData` -/
text : Expr
/-- If true, then hint should be hidden and only be shown on player's request -/
hidden : Bool := false
/-- If true, then the goal must contain only the assumptions specified in `goal` and no others -/
strict : Bool := false
instance : Repr GoalHintEntry := {
reprPrec := fun a n => reprPrec a.text n
}
/-! ## Inventory (documentation)
The inventory contains documentation that the user can access.
@@ -106,8 +96,16 @@ structure InventoryTile where
new := false
/-- hide the item in the inventory display -/
hidden := false
/-- hover text -/
altTitle : String := default
deriving ToJson, FromJson, Repr, Inhabited
def InventoryItem.toTile (item : InventoryItem) : InventoryTile := {
name := item.name,
displayName := item.displayName
category := item.category
}
/-- The extension that stores the doc templates. Note that you can only add, but never modify
entries! -/
initialize inventoryTemplateExt :
@@ -135,7 +133,18 @@ def getInventoryItem? [Monad m] [MonadEnv m] (n : Name) (type : InventoryType) :
m (Option InventoryItem) := do
return (inventoryExt.getState (← getEnv)).find? (fun x => x.name == n && x.type == type)
structure InventoryOverview where
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
lemmaTab : Option String
deriving ToJson, FromJson
-- TODO: Reuse the following code for checking available tactics in user code:
structure UsedInventory where
(tactics : HashSet Name := {})
(definitions : HashSet Name := {})
(lemmas : HashSet Name := {})
/-! ## Environment extensions for game specification -/
@@ -254,8 +263,63 @@ structure GameLevel where
lemmas: InventoryInfo := default
/-- A proof template that is printed in an empty editor. -/
template: Option String := none
deriving Inhabited, Repr
/-- The image for this level. -/
image : String := default
deriving Inhabited, Repr
/-- Json-encodable version of `GameLevel`
Fields:
- description: Lemma in mathematical language.
- descriptionGoal: Lemma printed as Lean-Code.
-/
structure LevelInfo where
index : Nat
title : String
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
introduction : String
conclusion : String
descrText : Option String := none
descrFormat : String := ""
lemmaTab : Option String
module : Name
displayName : Option String
statementName : Option String
template : Option String
image: Option String
deriving ToJson, FromJson
def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo :=
{ index := lvl.index,
title := lvl.title,
tactics := lvl.tactics.tiles,
lemmas := lvl.lemmas.tiles,
definitions := lvl.definitions.tiles,
descrText := lvl.descrText,
descrFormat := lvl.descrFormat --toExpr <| format (lvl.goal.raw) --toString <| Syntax.formatStx (lvl.goal.raw) --Syntax.formatStx (lvl.goal.raw) , -- TODO
introduction := lvl.introduction
conclusion := lvl.conclusion
lemmaTab := match lvl.lemmaTab with
| some tab => tab
| none =>
-- Try to set the lemma tab to the category of the first added lemma
match lvl.lemmas.tiles.find? (·.new) with
| some tile => tile.category
| none => none
statementName := lvl.statementName.toString
module := lvl.module
displayName := match lvl.statementName with
| .anonymous => none
| name => match (inventoryExt.getState env).find?
(fun x => x.name == name && x.type == .Lemma) with
| some n => n.displayName
| none => name.toString
-- Note: we could call `.find!` because we check in `Statement` that the
-- lemma doc must exist.
template := lvl.template
image := lvl.image
}
/-! ## World -/
@@ -271,17 +335,46 @@ structure World where
conclusion : String := default
/-- The levels of the world. -/
levels: HashMap Nat GameLevel := default
/-- The introduction image of the world. -/
image: String := default
deriving Inhabited
instance : ToJson World := ⟨
fun world => Json.mkObj [
("name", toJson world.name),
("title", world.title),
("introduction", world.introduction)]
("introduction", world.introduction),
("image", world.image)]
⟩
/-! ## Game -/
/-- A tile as they are displayed on the servers landing page. -/
structure GameTile where
/-- The title of the game -/
title: String
/-- One catch phrase about the game -/
short: String := default
/-- One paragraph description what the game is about -/
long: String := default
/-- List of languages the game supports
TODO: What's the expectected format
TODO: Must be a list with a single language currently
-/
languages: List String := default
/-- A list of games which this one builds upon -/
prerequisites: List String := default
/-- Number of worlds in the game -/
worlds: Nat := default
/-- Number of levels in the game -/
levels: Nat := default
/-- A cover image of the game
TODO: What's the format? -/
image: String := default
deriving Inhabited, ToJson, FromJson
structure Game where
/-- Internal name of the game. -/
name : Name
@@ -296,7 +389,18 @@ structure Game where
/-- TODO: currently unused. -/
authors : List String := default
worlds : Graph Name World := default
deriving Inhabited, ToJson
/-- The tile displayed on the server's landing page. -/
tile : GameTile := default
/-- The path to the background image of the world. -/
image : String := default
deriving Inhabited, ToJson, FromJson
def getGameJson (game : «Game») : Json := Id.run do
let gameJson : Json := toJson game
-- Add world sizes to Json object
let worldSize := game.worlds.nodes.toList.map (fun (n, w) => (n.toString, w.levels.size))
let gameJson := gameJson.mergeObj (Json.mkObj [("worldSize", Json.mkObj worldSize)])
return gameJson
/-! ## Game environment extension -/
File diff suppressed because it is too large Load Diff
+45 -216
View File
@@ -25,227 +25,56 @@ open Lsp
open JsonRpc
open IO
def getGame (game : Name): GameServerM Json := do
let some game ← getGame? game
| throwServerError "Game not found"
let gameJson : Json := toJson game
-- Add world sizes to Json object
let worldSize := game.worlds.nodes.toList.map (fun (n, w) => (n.toString, w.levels.size))
let gameJson := gameJson.mergeObj (Json.mkObj [("worldSize", Json.mkObj worldSize)])
return gameJson
/- Game-specific version of `InitializeParams` that allows for extra options: -/
/--
Fields:
- description: Lemma in mathematical language.
- descriptionGoal: Lemma printed as Lean-Code.
-/
structure LevelInfo where
index : Nat
title : String
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
introduction : String
conclusion : String
descrText : Option String := none
descrFormat : String := ""
lemmaTab : Option String
displayName : Option String
statementName : Option String
template : Option String
deriving ToJson, FromJson
structure InventoryOverview where
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
lemmaTab : Option String
deriving ToJson, FromJson
structure LoadLevelParams where
world : Name
level : Nat
structure InitializationOptions extends Lean.Lsp.InitializationOptions :=
difficulty : Nat
inventory : Array String
deriving ToJson, FromJson
-- structure LoadTemplateParams where
-- world : Name
-- level : Nat
-- deriving ToJson, FromJson
structure InitializeParams where
processId? : Option Int := none
clientInfo? : Option ClientInfo := none
/- We don't support the deprecated rootPath
(rootPath? : Option String) -/
rootUri? : Option String := none
initializationOptions? : Option InitializationOptions := none
capabilities : ClientCapabilities
/-- If omitted, we default to off. -/
trace : Trace := Trace.off
workspaceFolders? : Option (Array WorkspaceFolder) := none
deriving ToJson
structure DidOpenLevelParams where
uri : String
gameDir : String
levelModule : Name
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
inventory : Array String
/--
Check for tactics/theorems that are not unlocked.
0: no check
1: give warnings
2: give errors
-/
difficulty : Nat
/-- The name of the theorem to be proven in this level. -/
statementName : Name
deriving ToJson, FromJson
instance : FromJson InitializeParams where
fromJson? j := do
let processId? := j.getObjValAs? Int "processId"
let clientInfo? := j.getObjValAs? ClientInfo "clientInfo"
let rootUri? := j.getObjValAs? String "rootUri"
let initializationOptions? := j.getObjValAs? InitializationOptions "initializationOptions"
let capabilities ← j.getObjValAs? ClientCapabilities "capabilities"
let trace := (j.getObjValAs? Trace "trace").toOption.getD Trace.off
let workspaceFolders? := j.getObjValAs? (Array WorkspaceFolder) "workspaceFolders"
return ⟨
processId?.toOption,
clientInfo?.toOption,
rootUri?.toOption,
initializationOptions?.toOption,
capabilities,
trace,
workspaceFolders?.toOption⟩
structure LoadDocParams where
name : Name
type : InventoryType
deriving ToJson, FromJson
structure SetInventoryParams where
inventory : Array String
difficulty : Nat
deriving ToJson, FromJson
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
let p ← parseParams _ params
let m := p.textDocument
-- Execute the regular handling of the `didOpen` event
handleDidOpen p
let fw ← findFileWorker! m.uri
-- let s ← get
let c ← read
let some lvl ← GameServer.getLevelByFileName? c.initParams ((System.Uri.fileUriToPath? m.uri).getD m.uri |>.toString)
| do
c.hLog.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}"
c.hLog.flush
-- Send an extra notification to the file worker to inform it about the level data
let s ← get
fw.stdin.writeLspNotification {
method := "$/game/didOpenLevel"
param := {
uri := m.uri
gameDir := s.gameDir
levelModule := lvl.module
tactics := lvl.tactics.tiles
lemmas := lvl.lemmas.tiles
definitions := lvl.definitions.tiles
inventory := s.inventory
difficulty := s.difficulty
statementName := lvl.statementName
: DidOpenLevelParams
}
def InitializeParams.toLeanInternal (p : InitializeParams) : Lean.Lsp.InitializeParams :=
{
processId? := p.processId?
clientInfo? := p.clientInfo?
rootUri? := p.rootUri?
initializationOptions? := p.initializationOptions?.map fun o => {
editDelay? := o.editDelay?
hasWidgets? := o.hasWidgets?
}
partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "info" _ =>
let s ← get
let c ← read
c.hOut.writeLspResponse ⟨id, (← getGame s.game)⟩
return true
| Message.request id "loadLevel" params =>
let p ← parseParams LoadLevelParams (toJson params)
let s ← get
let c ← read
let some lvl ← getLevel? {game := s.game, world := p.world, level := p.level}
| do
c.hOut.writeLspResponseError ⟨id, .invalidParams, s!"Level not found: world {p.world}, level {p.level}", none⟩
return true
let env ← getEnv
let levelInfo : LevelInfo :=
{ index := lvl.index,
title := lvl.title,
tactics := lvl.tactics.tiles,
lemmas := lvl.lemmas.tiles,
definitions := lvl.definitions.tiles,
descrText := lvl.descrText,
descrFormat := lvl.descrFormat --toExpr <| format (lvl.goal.raw) --toString <| Syntax.formatStx (lvl.goal.raw) --Syntax.formatStx (lvl.goal.raw) , -- TODO
introduction := lvl.introduction
conclusion := lvl.conclusion
lemmaTab := match lvl.lemmaTab with
| some tab => tab
| none =>
-- Try to set the lemma tab to the category of the first added lemma
match lvl.lemmas.tiles.find? (·.new) with
| some tile => tile.category
| none => none
statementName := lvl.statementName.toString
displayName := match lvl.statementName with
| .anonymous => none
| name => match (inventoryExt.getState env).find?
(fun x => x.name == name && x.type == .Lemma) with
| some n => n.displayName
| none => name.toString
-- Note: we could call `.find!` because we check in `Statement` that the
-- lemma doc must exist.
template := lvl.template
}
c.hOut.writeLspResponse ⟨id, ToJson.toJson levelInfo⟩
return true
-- | Message.request id "loadTemplate" params =>
-- let p ← parseParams LoadTemplateParams (toJson params)
-- let s ← get
-- let c ← read
-- let some game ← getGame? s.game
-- | throwServerError "Game not found"
-- let some world := game.worlds.nodes.find? p.world
-- | throwServerError "World not found"
-- let mut templates : Array <| Option String := #[]
-- for (_, level) in world.levels.toArray do
-- templates := templates.push level.template
-- c.hOut.writeLspResponse ⟨id, ToJson.toJson templates⟩
-- return true
| Message.request id "loadDoc" params =>
let p ← parseParams LoadDocParams (toJson params)
let c ← read
let some doc ← getInventoryItem? p.name p.type
| do
c.hOut.writeLspResponseError ⟨id, .invalidParams,
s!"Documentation not found: {p.name}", none⟩
return true
-- TODO: not necessary at all?
-- Here we only need to convert the fields that were not `String` in the `InventoryDocEntry`
-- let doc : InventoryItem := { doc with
-- name := doc.name.toString }
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
return true
| Message.notification "$/game/setInventory" params =>
let p := (← parseParams SetInventoryParams (toJson params))
let s ← get
set {s with inventory := p.inventory, difficulty := p.difficulty}
let st ← read
let workers ← st.fileWorkersRef.get
for (_, fw) in workers do
fw.stdin.writeLspMessage msg
return true
| Message.request id "loadInventoryOverview" _ =>
let s ← get
let some game ← getGame? s.game
| return false
-- All Levels have the same tiles, so we just load them from level 1 of an arbitrary world
-- and reset `new`, `disabled` and `unlocked`.
-- Note: as we allow worlds without any levels (for developing), we might need
-- to try until we find the first world with levels.
for ⟨worldId, _⟩ in game.worlds.nodes.toList do
let some lvl ← getLevel? {game := s.game, world := worldId, level := 1}
| do continue
let inventory : InventoryOverview := {
tactics := lvl.tactics.tiles.map
({ · with locked := true, disabled := false, new := false }),
lemmas := lvl.lemmas.tiles.map
({ · with locked := true, disabled := false, new := false }),
definitions := lvl.definitions.tiles.map
({ · with locked := true, disabled := false, new := false }),
lemmaTab := none
}
let c ← read
c.hOut.writeLspResponse ⟨id, ToJson.toJson inventory⟩
return true
return false
| _ => return false
| _ => return false
capabilities := p.capabilities
trace := p.trace
workspaceFolders? := p.workspaceFolders?
}
end Game
+8
View File
@@ -18,6 +18,14 @@ instance [ToJson β] : ToJson (Graph Name β) := {
]
}
-- Just a dummy implementation for now:
instance : FromJson (Graph Name β) := {
fromJson? := fun _ => .ok {
nodes := {}
edges := {}
}
}
instance : EmptyCollection (Graph α β) := ⟨default⟩
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
+171
View File
@@ -0,0 +1,171 @@
import Lean
/-! This document contains various things which cluttered `Commands.lean`. -/
open Lean Meta Elab Command
/-! ## Doc Comment Parsing -/
/-- Read a doc comment and get its content. Return `""` if no doc comment available. -/
def parseDocComment! (doc: Option (TSyntax `Lean.Parser.Command.docComment)) :
CommandElabM String := do
match doc with
| none =>
logWarning "Add a text to this command with `/-- yada yada -/ MyCommand`!"
pure ""
| some s => match s.raw[1] with
| .atom _ val => pure <| val.dropRight 2 |>.trim -- some (val.extract 0 (val.endPos - ⟨2⟩))
| _ => pure "" --panic "not implemented error message" --throwErrorAt s "unexpected doc string{indentD s.raw[1]}"
/-- Read a doc comment and get its content. Return `none` if no doc comment available. -/
def parseDocComment (doc: Option (TSyntax `Lean.Parser.Command.docComment)) :
CommandElabM <| Option String := do
match doc with
| none => pure none
| some _ => parseDocComment! doc
/-- TODO: This is only used to provide some backwards compatibility and you can
replace `parseDocCommentLegacy` with `parseDocComment` in the future. -/
def parseDocCommentLegacy (doc: Option (TSyntax `Lean.Parser.Command.docComment))
(t : Option (TSyntax `str)) : CommandElabM <| String := do
match doc with
| none =>
match t with
| none =>
pure <| ← parseDocComment! doc
| some t =>
logWarningAt t "You should use the new Syntax:
/-- yada yada -/
YourCommand
instead of
YourCommand \"yada yada\"
"
pure t.getString
| some _ =>
match t with
| none =>
pure <| ← parseDocComment! doc
| some t =>
logErrorAt t "You must not provide both, a docstring and a string following the command!
Only use
/-- yada yada -/
YourCommand
and remove the string following it!"
pure <| ← parseDocComment! doc
/-! ## Statement string -/
def getStatement (name : Name) : CommandElabM MessageData := do
return ← addMessageContextPartial (.ofPPFormat { pp := fun
| some ctx => ctx.runMetaM <| PrettyPrinter.ppSignature name
| none => return "that's a bug." })
-- Note: We use `String` because we can't send `MessageData` as json, but
-- `MessageData` might be better for interactive highlighting.
/-- Get a string of the form `my_lemma (n : ℕ) : n + n = 2 * n`.
Note: A statement like `theorem abc : ∀ x : Nat, x ≥ 0` would be turned into
`theorem abc (x : Nat) : x ≥ 0` by `PrettyPrinter.ppSignature`. -/
def getStatementString (name : Name) : CommandElabM String := do
try
return ← (← getStatement name).toString
catch
| _ => throwError m!"Could not find {name} in context."
-- TODO: I think it would be nicer to unresolve Namespaces as much as possible.
/-- A `attr := ...` option for `Statement`. Add attributes to the defined theorem. -/
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO
/-! ## Loops in Graph-like construct
TODO: Why are we not using graphs here but our own construct `HashMap Name (HashSet Name)`?
-/
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
match (newArrows.find? id, decendants.find? id) with
| (some _, some _) => return (newArrows, decendants)
| _ =>
let mut newArr := newArrows
let mut desc := decendants
desc := desc.insert id {} -- mark as worked in case of loops
newArr := newArr.insert id {} -- mark as worked in case of loops
let children := arrows.findD id {}
let mut trimmedChildren := children
let mut theseDescs := children
for child in children do
(newArr, desc) := removeTransitiveAux child arrows newArr desc
let childDescs := desc.findD child {}
theseDescs := theseDescs.insertMany childDescs
for d in childDescs do
trimmedChildren := trimmedChildren.erase d
desc := desc.insert id theseDescs
newArr := newArr.insert id trimmedChildren
return (newArr, desc)
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
let mut newArr := {}
let mut desc := {}
for id in arrows.toArray.map Prod.fst do
(newArr, desc) := removeTransitiveAux id arrows newArr desc
if (desc.findD id {}).contains id then
logError <| m!"Loop at {id}. " ++
m!"This should not happen and probably means that `findLoops` has a bug."
-- DEBUG:
-- for ⟨x, hx⟩ in desc.toList do
-- m := m ++ m!"{x}: {hx.toList}\n"
-- logError m
return newArr
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
For performance reason it returns a HashSet of visited
nodes as well. This is filled with all nodes ever looked at as they cannot be
part of a loop anymore. -/
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
(path : Array Name := #[]) (visited : HashSet Name := {}) :
Array Name × HashSet Name := Id.run do
let mut visited := visited
match path.getIdx? node with
| some i =>
-- Found a loop: `node` is already the iᵗʰ element of the path
return (path.extract i path.size, visited.insert node)
| none =>
for successor in arrows.findD node {} do
-- If we already visited the successor, it cannot be part of a loop anymore
if visited.contains successor then
continue
-- Find any loop involving `successor`
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
visited := visited.insert successor
-- No loop found in the dependants of `successor`
if loop.isEmpty then
continue
-- Found a loop, return it
return (loop, visited)
return (#[], visited.insert node)
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
let mut visited : HashSet Name := {}
for node in arrows.toArray.map (·.1) do
-- Skip a node if it was already visited
if visited.contains node then
continue
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
-- search starting from `node`
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
visited := moreVisited
if !loop.isEmpty then
return loop.toList
return []
+54
View File
@@ -0,0 +1,54 @@
import GameServer.AbstractCtx
/-!
This file contains anything related to the `Hint` tactic used to add hints to a game level.
-/
open Lean Meta Elab
namespace GameServer
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-- A hint to help the user with a specific goal state -/
structure GoalHintEntry where
goal : AbstractCtxResult
/-- Text of the hint as an expression of type `Array Expr → MessageData` -/
text : Expr
rawText : String
/-- If true, then hint should be hidden and only be shown on player's request -/
hidden : Bool := false
/-- If true, then the goal must contain only the assumptions specified in `goal` and no others -/
strict : Bool := false
instance : Repr GoalHintEntry := {
reprPrec := fun a n => reprPrec a.text n
}
/-- For a hint `(hint : GoalHintEntry)` one uses `(← evalHintMessage hint.text) x`
where `(x : Array Expr)` contains the names of all the variables that should be inserted
in the text.
TODO: explain better. -/
unsafe def evalHintMessageUnsafe : Expr → MetaM (Array Expr → MessageData) :=
evalExpr (Array Expr → MessageData)
(.forallE default (mkApp (mkConst ``Array [levelZero]) (mkConst ``Expr))
(mkConst ``MessageData) .default)
@[implemented_by evalHintMessageUnsafe]
def evalHintMessage : Expr → MetaM (Array Expr → MessageData) := fun _ => pure (fun _ => "")
/-- Remove any spaces at the beginning of a new line -/
partial def removeIndentation (s : String) : String :=
let rec loop (i : String.Pos) (acc : String) (removeSpaces := false) : String :=
let c := s.get i
let i := s.next i
if s.atEnd i then
acc.push c
else if removeSpaces && c == ' ' then
loop i acc (removeSpaces := true)
else if c == '\n' then
loop i (acc.push c) (removeSpaces := true)
else
loop i (acc.push c)
loop ⟨0⟩ ""
+108
View File
@@ -0,0 +1,108 @@
import Lean.Environment
import Std.Tactic.OpenPrivate
import Lean.Data.Lsp.Communication
open Lean
inductive LoadingKind := | finalizeExtensions | loadConstants
deriving ToJson
structure LoadingParams : Type where
counter : Nat
kind : LoadingKind
deriving ToJson
-- Code adapted from `Lean/Environment.lean`
partial def importModulesCore' (imports : Array Import) : ImportStateM Unit := do
for i in imports do
if i.runtimeOnly || (← get).moduleNameSet.contains i.module then
continue
modify fun s => { s with moduleNameSet := s.moduleNameSet.insert i.module }
let mFile ← findOLean i.module
unless (← mFile.pathExists) do
throw <| IO.userError s!"object file '{mFile}' of module {i.module} does not exist"
let (mod, region) ← readModuleData mFile
importModulesCore' mod.imports
modify fun s => { s with
moduleData := s.moduleData.push mod
regions := s.regions.push region
moduleNames := s.moduleNames.push i.module
}
open private mkInitialExtensionStates Environment.mk setImportedEntries finalizePersistentExtensions
ensureExtensionsArraySize from Lean.Environment
private partial def finalizePersistentExtensions' (env : Environment) (mods : Array ModuleData) (opts : Options) : IO Environment := do
loop 0 env
where
loop (i : Nat) (env : Environment) : IO Environment := do
(← IO.getStdout).writeLspNotification {
method := "$/game/loading",
param := {counter := i, kind := .finalizeExtensions : LoadingParams} }
-- Recall that the size of the array stored `persistentEnvExtensionRef` may increase when we import user-defined environment extensions.
let pExtDescrs ← persistentEnvExtensionsRef.get
if i < pExtDescrs.size then
let extDescr := pExtDescrs[i]!
let s := extDescr.toEnvExtension.getState env
let prevSize := (← persistentEnvExtensionsRef.get).size
let prevAttrSize ← getNumBuiltinAttributes
let newState ← extDescr.addImportedFn s.importedEntries { env := env, opts := opts }
let mut env := extDescr.toEnvExtension.setState env { s with state := newState }
env ← ensureExtensionsArraySize env
if (← persistentEnvExtensionsRef.get).size > prevSize || (← getNumBuiltinAttributes) > prevAttrSize then
-- This branch is executed when `pExtDescrs[i]` is the extension associated with the `init` attribute, and
-- a user-defined persistent extension is imported.
-- Thus, we invoke `setImportedEntries` to update the array `importedEntries` with the entries for the new extensions.
env ← setImportedEntries env mods prevSize
-- See comment at `updateEnvAttributesRef`
env ← updateEnvAttributes env
loop (i + 1) env
else
return env
def finalizeImport' (s : ImportState) (imports : Array Import) (opts : Options) (trustLevel : UInt32 := 0) : IO Environment := do
let numConsts := s.moduleData.foldl (init := 0) fun numConsts mod =>
numConsts + mod.constants.size + mod.extraConstNames.size
let mut const2ModIdx : HashMap Name ModuleIdx := mkHashMap (capacity := numConsts)
let mut constantMap : HashMap Name ConstantInfo := mkHashMap (capacity := numConsts)
for h:modIdx in [0:s.moduleData.size] do
if modIdx % 100 = 0 then
let percentage := modIdx * 100 / s.moduleData.size
(← IO.getStdout).writeLspNotification {
method := "$/game/loading",
param := {counter := percentage, kind := .loadConstants : LoadingParams} }
let mod := s.moduleData[modIdx]'h.upper
for cname in mod.constNames, cinfo in mod.constants do
match constantMap.insert' cname cinfo with
| (constantMap', replaced) =>
constantMap := constantMap'
if replaced then
throwAlreadyImported s const2ModIdx modIdx cname
const2ModIdx := const2ModIdx.insert cname modIdx
for cname in mod.extraConstNames do
const2ModIdx := const2ModIdx.insert cname modIdx
let constants : ConstMap := SMap.fromHashMap constantMap false
let exts ← mkInitialExtensionStates
let env : Environment := Environment.mk
(const2ModIdx := const2ModIdx)
(constants := constants)
(extraConstNames := {})
(extensions := exts)
(header := {
quotInit := !imports.isEmpty -- We assume `core.lean` initializes quotient module
trustLevel := trustLevel
imports := imports
regions := s.regions
moduleNames := s.moduleNames
moduleData := s.moduleData
})
let env ← setImportedEntries env s.moduleData
finalizePersistentExtensions' env s.moduleData opts
def importModules' (imports : Array Import) : IO Environment := do
withImporting do
let (_, s) ← importModulesCore' imports |>.run
let env ← finalizeImport' s imports {} 0
return env
+37 -82
View File
@@ -1,74 +1,19 @@
/- This file is mostly copied from `Lean/Widget/InteractiveGoal.lean`. -/
import GameServer.Structures
import Lean.Widget.InteractiveGoal
/-!
This file is a modified copy of `Lean.Widget.InteractiveGoal`.
/-! Functionality related to tactic-mode and term-mode goals with embedded `CodeWithInfos`. -/
Note that the structures have been moved to `Structures.lean`, but most of the
functions here must be duplicated from `Lean.Widget.InteractiveGoal` in order
to use the duplicated structures.
-/
namespace GameServer
open Lean Lean.Widget Lean.Server
structure GameHint where
text : String
hidden : Bool
deriving FromJson, ToJson
/-- In the infoview, if multiple hypotheses `h₁`, `h₂` have the same type `α`, they are rendered
as `h₁ h₂ : α`. We call this a 'hypothesis bundle'. We use `none` instead of `some false` for
booleans to save space in the json encoding. -/
structure InteractiveHypothesisBundle where
/-- The user-friendly name for each hypothesis. -/
names : Array Name
/-- The ids for each variable. Should have the same length as `names`. -/
fvarIds : Array FVarId
type : CodeWithInfos
/-- The value, in the case the hypothesis is a `let`-binder. -/
val? : Option CodeWithInfos := none
/-- The hypothesis is a typeclass instance. -/
isInstance? : Option Bool := none
/-- The hypothesis is a type. -/
isType? : Option Bool := none
/-- The hypothesis's type is of type `Prop` -/
isAssumption? : Option Bool := none
/-- If true, the hypothesis was not present on the previous tactic state.
Only present in tactic-mode goals. -/
isInserted? : Option Bool := none
/-- If true, the hypothesis will be removed in the next tactic state.
Only present in tactic-mode goals. -/
isRemoved? : Option Bool := none
deriving Inhabited, RpcEncodable
/-- The shared parts of interactive term-mode and tactic-mode goals. -/
structure InteractiveGoalCore where
hyps : Array InteractiveHypothesisBundle
/-- The target type. -/
type : CodeWithInfos
/-- Metavariable context that the goal is well-typed in. -/
ctx : WithRpcRef Elab.ContextInfo
/-- An interactive tactic-mode goal. -/
structure InteractiveGoal extends InteractiveGoalCore where
/-- The name `foo` in `case foo`, if any. -/
userName? : Option String
/-- The symbol to display before the target type. Usually `⊢ ` but `conv` goals use `∣ `
and it could be extended. -/
goalPrefix : String
/-- Identifies the goal (ie with the unique name of the MVar that it is a goal for.) -/
mvarId : MVarId
/-- If true, the goal was not present on the previous tactic state. -/
isInserted? : Option Bool := none
/-- If true, the goal will be removed on the next tactic state. -/
isRemoved? : Option Bool := none
hints : Array GameHint := #[]
deriving RpcEncodable
/-- An interactive term-mode goal. -/
structure InteractiveTermGoal extends InteractiveGoalCore where
/-- Syntactic range of the term. -/
range : Lsp.Range
/-- Information about the term whose type is the term-mode goal. -/
term : WithRpcRef Elab.TermInfo
deriving RpcEncodable
-- duplicated with custom `InteractiveGoalCore`
-- @[inherit_doc Lean.Widget.InteractiveGoalCore.pretty]
def InteractiveGoalCore.pretty (g : InteractiveGoalCore) (userName? : Option String)
(goalPrefix : String) : Format := Id.run do
let indent := 2 -- Use option
@@ -79,8 +24,7 @@ def InteractiveGoalCore.pretty (g : InteractiveGoalCore) (userName? : Option Str
ret := addLine ret
let names := hyp.names
|>.toList
|>.filter (not ∘ Name.isAnonymous)
|>.map toString
|>.filter (· != toString Name.anonymous)
|> " ".intercalate
match names with
| "" =>
@@ -97,16 +41,24 @@ where
addLine (fmt : Format) : Format :=
if fmt.isNil then fmt else fmt ++ Format.line
-- duplicated with custom `InteractiveGoal`
-- @[inherit_doc Lean.Widget.InteractiveGoal.pretty]
def InteractiveGoal.pretty (g : InteractiveGoal) : Format :=
g.toInteractiveGoalCore.pretty g.userName? g.goalPrefix
-- duplicated with custom `InteractiveTermGoal`
-- @[inherit_doc Lean.Widget.InteractiveTermGoal.pretty]
def InteractiveTermGoal.pretty (g : InteractiveTermGoal) : Format :=
g.toInteractiveGoalCore.pretty none "⊢ "
-- duplicated with custom `InteractiveGoal`
-- @[inherit_doc Lean.Widget.InteractiveGoals]
structure InteractiveGoals where
goals : Array InteractiveGoal
deriving RpcEncodable
-- duplicated with custom `InteractiveGoals`
-- @[inherit_doc Lean.Widget.InteractiveGoals.append]
def InteractiveGoals.append (l r : InteractiveGoals) : InteractiveGoals where
goals := l.goals ++ r.goals
@@ -114,9 +66,10 @@ instance : Append InteractiveGoals := ⟨InteractiveGoals.append⟩
instance : EmptyCollection InteractiveGoals := ⟨{goals := #[]}⟩
open Meta in
/-- Extend an array of hypothesis bundles with another bundle. -/
-- duplicated with custom `InteractiveHypothesisBundle` and therefore added `isAssumption?`
@[inherit_doc Lean.Widget.addInteractiveHypothesisBundle]
def addInteractiveHypothesisBundle (hyps : Array InteractiveHypothesisBundle)
(ids : Array (Name × FVarId)) (type : Expr) (value? : Option Expr := none) :
(ids : Array (String × FVarId)) (type : Expr) (value? : Option Expr := none) :
MetaM (Array InteractiveHypothesisBundle) := do
if ids.size == 0 then
throwError "Can only add a nonzero number of ids as an InteractiveHypothesisBundle."
@@ -125,11 +78,12 @@ def addInteractiveHypothesisBundle (hyps : Array InteractiveHypothesisBundle)
return hyps.push {
names
fvarIds
type := (← ppExprTagged type)
val? := (← value?.mapM ppExprTagged)
isInstance? := if (← isClass? type).isSome then true else none
isType? := if (← instantiateMVars type).isSort then true else none
isAssumption? := if (← inferType type).isProp then true else none
type := (← ppExprTagged type)
val? := (← value?.mapM ppExprTagged)
isInstance? := if (← isClass? type).isSome then true else none
isType? := if (← instantiateMVars type).isSort then true else none
-- Added:
isAssumption? := if (← inferType type).isProp then true else none
}
open Meta in
@@ -142,13 +96,15 @@ def withGoalCtx (goal : MVarId) (action : LocalContext → MetavarDecl → n α)
withLCtx lctx mvarDecl.localInstances (action lctx mvarDecl)
open Meta in
/-- A variant of `Meta.ppGoal` which preserves subexpression information for interactivity. -/
def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM InteractiveGoal := do
-- Duplicated from `Lean.Widget.goalToInteractive` with custom structures
@[inherit_doc Lean.Widget.goalToInteractive]
def goalToInteractive (mvarId : MVarId) : MetaM InteractiveGoal := do
let ppAuxDecls := pp.auxDecls.get (← getOptions)
let ppImplDetailHyps := pp.implementationDetailHyps.get (← getOptions)
let showLetValues := pp.showLetValues.get (← getOptions)
withGoalCtx mvarId fun lctx mvarDecl => do
let pushPending (ids : Array (Name × FVarId)) (type? : Option Expr) (hyps : Array InteractiveHypothesisBundle)
let pushPending (ids : Array (String × FVarId)) (type? : Option Expr) (hyps : Array InteractiveHypothesisBundle)
: MetaM (Array InteractiveHypothesisBundle) :=
if ids.isEmpty then
pure hyps
@@ -156,7 +112,7 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
match type? with
| none => pure hyps
| some type => addInteractiveHypothesisBundle hyps ids type
let mut varNames : Array (Name × FVarId) := #[]
let mut varNames : Array (String × FVarId) := #[]
let mut prevType? : Option Expr := none
let mut hyps : Array InteractiveHypothesisBundle := #[]
for localDecl in lctx do
@@ -165,7 +121,7 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
else
match localDecl with
| LocalDecl.cdecl _index fvarId varName type _ _ =>
let varName := varName.simpMacroScopes
let varName := toString varName
let type ← instantiateMVars type
if prevType? == none || prevType? == some type then
varNames := varNames.push (varName, fvarId)
@@ -174,7 +130,7 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
varNames := #[(varName, fvarId)]
prevType? := some type
| LocalDecl.ldecl _index fvarId varName type val _ _ => do
let varName := varName.simpMacroScopes
let varName := toString varName
hyps ← pushPending varNames prevType? hyps
let type ← instantiateMVars type
let val? ← if showLetValues then pure (some (← instantiateMVars val)) else pure none
@@ -190,11 +146,10 @@ def goalToInteractive (mvarId : MVarId) (hints : Array GameHint): MetaM Interact
return {
hyps
type := goalFmt
ctx := ⟨← Elab.ContextInfo.save⟩
ctx := ⟨{← Elab.CommandContextInfo.save with }⟩
userName?
goalPrefix := getGoalPrefix mvarDecl
mvarId
hints
}
end GameServer
+152
View File
@@ -0,0 +1,152 @@
import Lean
import GameServer.EnvExtensions
open Lean Elab Command
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-! ## Doc entries -/
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning.
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
I used this workaround, because I needed a new name (with correct namespace etc)
to be used, and I don't know how to create a new ident with same position but different name.
-/
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
(template : Option String := none) : CommandElabM Unit := do
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
let env ← getEnv
let n := name
-- Find a key with matching `(type, name)`.
match (inventoryTemplateExt.getState env).findIdx?
(fun x => x.name == n && x.type == type) with
-- Nothing to do if the entry exists
| some _ => pure ()
| none =>
match template with
-- Warn about missing documentation
| none =>
let docstring ← match (← getDocstring env name type) with
| some ds =>
logInfoAt ref (m!"Missing {type} Documentation. Using existing docstring. " ++
m!"Add {name}\nAdd `{type}Doc {name}` somewhere above this statement.")
pure s!"*(lean docstring)*\\\n{ds}"
| none =>
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
pure "(missing)"
-- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := docstring})
-- Add the default documentation
| some s =>
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := s })
logInfoAt ref (m!"Missing {type} Documentation: {name}, used default (e.g. provided " ++
m!"docstring) instead. If you want to write a different description, add " ++
m!"`{type}Doc {name}` somewhere above this statement.")
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do
match stx with
| .missing => return acc
| .node _info kind args =>
if kind == `GameServer.Tactic.Hint || kind == `GameServer.Tactic.Branch then return acc
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc
| .atom _info val =>
-- ignore syntax elements that do not start with a letter
-- and ignore some standard keywords
let allowed := GameServer.ALLOWED_KEYWORDS
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
return {acc with tactics := acc.tactics.insert val}
else
return acc
| .ident _info _rawVal val _preresolved =>
let ns ←
try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error
return ← ns.foldlM (fun acc n => do
if let some (.thmInfo ..) := (← getEnv).find? n then
return {acc with lemmas := acc.lemmas.insertMany ns}
else
return {acc with definitions := acc.definitions.insertMany ns}
) acc
-- #check expandOptDocComment?
def GameLevel.getInventory (level : GameLevel) : InventoryType → InventoryInfo
| .Tactic => level.tactics
| .Definition => level.definitions
| .Lemma => level.lemmas
def GameLevel.setComputedInventory (level : GameLevel) :
InventoryType → Array InventoryTile → GameLevel
| .Tactic, v => {level with tactics := {level.tactics with tiles := v}}
| .Definition, v => {level with definitions := {level.definitions with tiles := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}}
+17
View File
@@ -0,0 +1,17 @@
import Lean
/-! This document contains custom options available in the game. -/
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
register_option lean4game.showDependencyReasons : Bool := {
defValue := false
descr := "show reasons for calculated world dependencies."
}
/-- Let `MakeGame` print the reasons why the worlds depend on each other.
Note: currently unused in favour of setting `set_option trace.debug true`. -/
register_option lean4game.verbose : Bool := {
defValue := false
descr := "display more info messages to help developing the game."
}
+228 -32
View File
@@ -1,5 +1,7 @@
import GameServer.EnvExtensions
import GameServer.InteractiveGoal
import Std.Data.Array.Init.Basic
import GameServer.Hints
open Lean
open Server
@@ -7,7 +9,6 @@ open Widget
open RequestM
open Meta
/-! ## GameGoal -/
namespace GameServer
@@ -46,8 +47,8 @@ partial def matchExpr (pattern : Expr) (e : Expr) (bij : FVarBijection := {}) :
| .bvar i1, .bvar i2 => if i1 == i2 then bij else none
| .fvar i1, .fvar i2 => bij.insert? i1 i2
| .mvar _, .mvar _ => bij
| .sort u1, .sort u2 => bij -- TODO?
| .const n1 ls1, .const n2 ls2 =>
| .sort _u1, .sort _u2 => bij -- TODO?
| .const n1 _ls1, .const n2 _ls2 =>
if n1 == n2 then bij else none -- && (← (ls1.zip ls2).allM fun (l1, l2) => Meta.isLevelDefEq l1 l2)
| .app f1 a1, .app f2 a2 =>
some bij
@@ -78,7 +79,7 @@ partial def matchExpr (pattern : Expr) (e : Expr) (bij : FVarBijection := {}) :
| _, _ => none
/-- Check if each fvar in `patterns` has a matching fvar in `fvars` -/
def matchDecls (patterns : Array Expr) (fvars : Array Expr) (strict := true) (initBij : FVarBijection := {}) : MetaM Bool := do
def matchDecls (patterns : Array Expr) (fvars : Array Expr) (strict := true) (initBij : FVarBijection := {}) : MetaM (Option FVarBijection) := do
-- We iterate through the array backwards hoping that this will find us faster results
-- TODO: implement backtracking
let mut bij := initBij
@@ -97,54 +98,240 @@ def matchDecls (patterns : Array Expr) (fvars : Array Expr) (strict := true) (in
-- usedFvars := usedFvars.set! (fvars.size - j - 1) true
bij := bij'.insert pattern.fvarId! fvar.fvarId!
break
if ! bij.forward.contains pattern.fvarId! then return false
if ! bij.forward.contains pattern.fvarId! then return none
if strict then
return fvars.all (fun fvar => bij.backward.contains fvar.fvarId!)
return true
unsafe def evalHintMessageUnsafe : Expr → MetaM (Array Expr → MessageData) :=
evalExpr (Array Expr → MessageData)
(.forallE default (mkApp (mkConst ``Array [levelZero]) (mkConst ``Expr))
(mkConst ``MessageData) .default)
@[implemented_by evalHintMessageUnsafe]
def evalHintMessage : Expr → MetaM (Array Expr → MessageData) := fun _ => pure (fun _ => "")
if !strict || fvars.all (fun fvar => bij.backward.contains fvar.fvarId!)
then return some bij
else return none
open Meta in
/-- Find all hints whose trigger matches the current goal -/
def findHints (goal : MVarId) (doc : FileWorker.EditableDocument) (initParams : Lsp.InitializeParams) : MetaM (Array GameHint) := do
def findHints (goal : MVarId) (m : DocumentMeta) (initParams : Lsp.InitializeParams) : MetaM (Array GameHint) := do
goal.withContext do
let some level ← getLevelByFileName? initParams doc.meta.mkInputContext.fileName
| throwError "Level not found: {doc.meta.mkInputContext.fileName}"
let some level ← getLevelByFileName? initParams m.mkInputContext.fileName
| throwError "Level not found: {m.mkInputContext.fileName}"
let hints ← level.hints.filterMapM fun hint => do
openAbstractCtxResult hint.goal fun hintFVars hintGoal => do
if let some fvarBij := matchExpr (← instantiateMVars $ hintGoal) (← instantiateMVars $ ← inferType $ mkMVar goal)
then
let lctx := (← goal.getDecl).lctx
if ← matchDecls hintFVars lctx.getFVars (strict := hint.strict) (initBij := fvarBij)
-- NOTE: This code for `hintFVarsNames` is also duplicated in the
-- "Statement" command, where `hint.rawText` is created. They need to be matching.
-- NOTE: This is a bit a hack of somebody who does not know how meta-programming works.
-- All we want here is a list of `userNames` for the `FVarId`s in `hintFVars`...
-- and we wrap them in `«{}»` here since I don't know how to do it later.
let mut hintFVarsNames : Array Expr := #[]
for fvar in hintFVars do
let name₁ ← fvar.fvarId!.getUserName
hintFVarsNames := hintFVarsNames.push <| Expr.fvar ⟨s!"«\{{name₁}}»"⟩
let lctx := (← goal.getDecl).lctx -- the player's local context
if let some bij ← matchDecls hintFVars lctx.getFVars
(strict := hint.strict) (initBij := fvarBij)
then
let text := (← evalHintMessage hint.text) hintFVars
let ctx := {env := ← getEnv, mctx := ← getMCtx, lctx := ← getLCtx, opts := {}}
let userFVars := hintFVars.map fun v => bij.forward.findD v.fvarId! v.fvarId!
-- Evaluate the text in the player's context to get the new variable names.
let text := (← evalHintMessage hint.text) (userFVars.map Expr.fvar)
let ctx := {env := ← getEnv, mctx := ← getMCtx, lctx := lctx, opts := {}}
let text ← (MessageData.withContext ctx text).toString
return some { text := text, hidden := hint.hidden }
-- Here we map the goal's variable names to the player's variable names.
let mut varNames : Array <| Name × Name := #[]
for (fvar₁, fvar₂) in bij.forward.toArray do
-- get the `userName` of the fvar in the opened local context of the hint.
let name₁ ← fvar₁.getUserName
-- get the `userName` in the player's local context.
let name₂ := (lctx.get! fvar₂).userName
varNames := varNames.push (name₁, name₂)
return some {
text := text,
hidden := hint.hidden,
rawText := hint.rawText,
varNames := varNames }
else return none
else
return none
return hints
open RequestM in
def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option InteractiveGoals)) := do
-- TODO: no need to have `RequestM`, just anything where `mut` works
/-- Add custom diagnostics about whether the level is completed. -/
def completionDiagnostics (goalCount : Nat) (prevGoalCount : Nat) (completed : Bool)
(completedWithWarnings : Bool) (pos : Lsp.Position)
(startDiags : Array InteractiveDiagnostic := #[]) :
RequestM <| Array InteractiveDiagnostic := do
let mut out : Array InteractiveDiagnostic := startDiags
if goalCount == 0 then
if completed then
out := out.push {
message := .text "level completed! 🎉"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information }
else if completedWithWarnings then
out := out.push {
message := .text "level completed with warnings… 🎭"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information }
else
pure ()
else if goalCount < prevGoalCount then
out := out.push {
message := .text "intermediate goal solved! 🎉"
range := {
start := pos
«end» := pos
}
severity? := Lsp.DiagnosticSeverity.information
}
return out
def filterUnsolvedGoal (a : Array InteractiveDiagnostic) :
Array InteractiveDiagnostic :=
a.filter (fun d => match d.message with
| .append ⟨(.text x) :: _⟩ => x != "unsolved goals"
| _ => true)
/-- Request that returns the goals at the end of each line of the tactic proof
plus the diagnostics (i.e. warnings/errors) for the proof.
-/
def getProofState (_ : Lsp.PlainGoalParams) : RequestM (RequestTask (Option ProofState)) := do
let doc ← readDoc
let rc ← readThe RequestContext
let text := doc.meta.text
-- BUG: trimming here is a problem, since the snap might already be evaluated before
-- the trimming and then the positions don't match anymore :((
withWaitFindSnap
doc
-- TODO (Alex): I couldn't find a good condition to find the correct snap. So we are looking
-- for the first snap with goals here.
-- NOTE (Jon): The entire proof is in one snap, so hoped that Position `0` is good enough.
(fun snap => ¬ (snap.infoTree.goalsAt? doc.meta.text 0).isEmpty)
(notFoundX := return none)
fun snap => do
-- `snap` is the one snapshot containing the entire proof.
let mut steps : Array <| InteractiveGoalsWithHints := #[]
-- Question: Is there a difference between the diags of this snap and the last snap?
-- Should we get the diags from there?
-- Answer: The last snap only copied the diags from the end of this snap
let mut diag : Array InteractiveDiagnostic := snap.interactiveDiags.toArray
-- Level is completed if there are no errrors or warnings
let completedWithWarnings : Bool := ¬ diag.any (·.severity? == some .error)
let completed : Bool := completedWithWarnings ∧ ¬ diag.any (·.severity? == some .warning)
let mut intermediateGoalCount := 0
-- only the positions that have non-whitespace characters since the last position
-- should add a new proof step.
let positionsWithSource : Array (String.Pos × String) :=
text.positions.zipWithIndex.filterMap (
fun (pos, i) => match i with
| 0 => some (pos, "")
| i' + 1 =>
let source : String := Substring.toString ⟨text.source, text.positions.get! i', pos⟩
if source.trim.length == 0 then
none
else
some (pos, source))
-- Drop the last position as we ensured that there is always a newline at the end
for ((pos, source), i) in positionsWithSource.zipWithIndex do
-- iterate over all steps in the proof and get the goals and hints at each position
-- diags are labeled in Lsp-positions, which differ from the lean-internal
-- positions by `1`.
let lspPosAt := text.utf8PosToLspPos pos
let mut diagsAtPos : Array InteractiveDiagnostic := filterUnsolvedGoal <|
-- `+1` for getting the errors after the line.
match i with
| 0 =>
-- `lspPosAt` is `(0, 0)`
diag.filter (fun d => d.range.start == lspPosAt )
| i' + 1 =>
diag.filter (fun d =>
((text.utf8PosToLspPos <| (positionsWithSource.get! i').1) ≤ d.range.start) ∧
d.range.start < lspPosAt )
if let goalsAtResult@(_ :: _) := snap.infoTree.goalsAt? doc.meta.text pos then
let goalsAtPos' : List <| List InteractiveGoalWithHints ← goalsAtResult.mapM
fun { ctxInfo := ci, tacticInfo := tacticInfo, useAfter := useAfter, .. } => do
-- TODO: What does this function body do?
-- let ciAfter := { ci with mctx := ti.mctxAfter }
let ci := if useAfter then
{ ci with mctx := tacticInfo.mctxAfter }
else
{ ci with mctx := tacticInfo.mctxBefore }
-- compute the interactive goals
let goalMvars : List MVarId ← ci.runMetaM {} do
return if useAfter then tacticInfo.goalsAfter else tacticInfo.goalsBefore
let interactiveGoals : List InteractiveGoalWithHints ← ci.runMetaM {} do
goalMvars.mapM fun goal => do
let hints ← findHints goal doc.meta rc.initParams
let interactiveGoal ← goalToInteractive goal
return ⟨interactiveGoal, hints⟩
-- TODO: This code is way old, can it be deleted?
-- compute the goal diff
-- let goals ← ciAfter.runMetaM {} (do
-- try
-- Widget.diffInteractiveGoals useAfter ti goals
-- catch _ =>
-- -- fail silently, since this is just a bonus feature
-- return goals
-- )
return interactiveGoals
let goalsAtPos : Array InteractiveGoalWithHints := ⟨goalsAtPos'.foldl (· ++ ·) []⟩
diagsAtPos ← completionDiagnostics goalsAtPos.size intermediateGoalCount
completed completedWithWarnings lspPosAt diagsAtPos
intermediateGoalCount := goalsAtPos.size
steps := steps.push ⟨goalsAtPos, source, diagsAtPos, lspPosAt.line, lspPosAt.character⟩
else
-- No goals present
steps := steps.push ⟨#[], source, diagsAtPos, lspPosAt.line, none⟩
-- Filter out the "unsolved goals" message
diag := filterUnsolvedGoal diag
let lastPos := text.utf8PosToLspPos positionsWithSource.back.1
let remainingDiags : Array InteractiveDiagnostic :=
diag.filter (fun d => lastPos ≤ d.range.start)
return some {
steps := steps,
diagnostics := remainingDiags,
completed := completed,
completedWithWarnings := completedWithWarnings,
lastPos := lastPos.line
}
open RequestM in
-- The editor apparently uses this
def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Option <| InteractiveGoals)) := do
let doc ← readDoc
-- let rc ← readThe RequestContext
let text := doc.meta.text
let hoverPos := text.lspPosToUtf8Pos p.position
-- TODO: I couldn't find a good condition to find the correct snap. So we are looking
-- for the first snap with goals here:
withWaitFindSnap doc (fun s => ¬ (s.infoTree.goalsAt? doc.meta.text hoverPos).isEmpty)
(notFoundX := return none) fun snap => do
if let rs@(_ :: _) := snap.infoTree.goalsAt? doc.meta.text hoverPos then
let goals : List InteractiveGoals ← rs.mapM fun { ctxInfo := ci, tacticInfo := ti, useAfter := useAfter, .. } => do
let goals : List <| Array InteractiveGoal ← rs.mapM fun { ctxInfo := ci, tacticInfo := ti, useAfter := useAfter, .. } => do
let ciAfter := { ci with mctx := ti.mctxAfter }
let ci := if useAfter then ciAfter else { ci with mctx := ti.mctxBefore }
-- compute the interactive goals
@@ -152,8 +339,8 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio
return List.toArray <| if useAfter then ti.goalsAfter else ti.goalsBefore
let goals ← ci.runMetaM {} do
goals.mapM fun goal => do
let hints ← findHints goal doc rc.initParams
return ← goalToInteractive goal hints
-- let hints ← findHints goal doc.meta rc.initParams
return ← goalToInteractive goal
-- compute the goal diff
-- let goals ← ciAfter.runMetaM {} (do
-- try
@@ -162,8 +349,8 @@ def getInteractiveGoals (p : Lsp.PlainGoalParams) : RequestM (RequestTask (Optio
-- -- fail silently, since this is just a bonus feature
-- return goals
-- )
return {goals}
return some <| goals.foldl (· ++ ·) ⟨#[]⟩
return goals
return some <| ⟨goals.foldl (· ++ ·) #[]⟩
else
return none
@@ -171,7 +358,16 @@ builtin_initialize
registerBuiltinRpcProcedure
`Game.getInteractiveGoals
Lsp.PlainGoalParams
(Option InteractiveGoals)
(Option <| InteractiveGoals
)
getInteractiveGoals
builtin_initialize
registerBuiltinRpcProcedure
`Game.getProofState
Lsp.PlainGoalParams
(Option ProofState)
getProofState
end GameServer
+81
View File
@@ -0,0 +1,81 @@
import GameServer.EnvExtensions
import I18n
open Lean Meta Elab Command
/-! ## Copy images -/
open IO.FS System FilePath in
/-- Copies the folder `images/` to `.lake/gamedata/images/` -/
def copyImages : IO Unit := do
let target : FilePath := ".lake" / "gamedata"
if ← FilePath.pathExists "images" then
for file in ← walkDir "images" do
let outFile := target.join file
-- create the directories
if ← file.isDir then
createDirAll outFile
else
if let some parent := outFile.parent then
createDirAll parent
-- copy file
let content ← readBinFile file
writeBinFile outFile content
namespace GameData
def gameDataPath : System.FilePath := ".lake" / "gamedata"
def gameFileName := s!"game.json"
def docFileName := fun (inventoryType : InventoryType) (name : Name) => s!"doc__{inventoryType}__{name}.json"
def levelFileName := fun (worldId : Name) (levelId : Nat) => s!"level__{worldId}__{levelId}.json"
def inventoryFileName := s!"inventory.json"
end GameData
open GameData in
-- TODO: register all of this as ToJson instance?
def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name))
(inventory : InventoryOverview): CommandElabM Unit := do
let game ← getCurGame
let env ← getEnv
let path := (← IO.currentDir) / gameDataPath
if ← path.isDir then
IO.FS.removeDirAll path
IO.FS.createDirAll path
-- copy the images folder
copyImages
for (worldId, world) in game.worlds.nodes.toArray do
for (levelId, level) in world.levels.toArray do
IO.FS.writeFile (path / levelFileName worldId levelId) (toString (toJson (level.toInfo env)))
IO.FS.writeFile (path / gameFileName) (toString (getGameJson game))
for inventoryType in [InventoryType.Lemma, .Tactic, .Definition] do
for name in allItemsByType.findD inventoryType {} do
let some item ← getInventoryItem? name inventoryType
| throwError "Expected item to exist: {name}"
IO.FS.writeFile (path / docFileName inventoryType name) (toString (toJson item))
IO.FS.writeFile (path / inventoryFileName) (toString (toJson inventory))
-- write PO file for translation
I18n.createPOTemplate
open GameData
def loadData (f : System.FilePath) (α : Type) [FromJson α] : IO α := do
let str ← IO.FS.readFile f
let json ← match Json.parse str with
| .ok v => pure v
| .error e => throw (IO.userError e)
let data ← match fromJson? json with
| .ok v => pure v
| .error e => throw (IO.userError e)
return data
def loadGameData (gameDir : System.FilePath) : IO Game :=
loadData (gameDir / gameDataPath / gameFileName) Game
def loadLevelData (gameDir : System.FilePath) (worldId : Name) (levelId : Nat) : IO LevelInfo :=
loadData (gameDir / gameDataPath / levelFileName worldId levelId) LevelInfo
+102
View File
@@ -0,0 +1,102 @@
import Lean.Widget.InteractiveGoal
import Lean.Widget.InteractiveDiagnostic
import Lean.Data.Lsp.Diagnostics
/-!
This file contains the custom data structures use by the server.
Some of them overwrite built-in structures from Lean.
In particular, the structures from `Lean.Widget.InteractiveGoal` are duplicated with
the following extension:
* `isAssumption?` in `InteractiveHypothesisBundle`: stores if a hypothesis is of type `Prop`.
NOTE: Changes here need to be reflected in the corresponding `interface` in `rcp_api.ts`
on the client-side.
-/
open Lean Server Widget
namespace GameServer
/-- Extend the interactive hypothesis bundle with an option to distinguish
"assumptions" from "objects". "Assumptions" are hypotheses of type `Prop`. -/
-- @[inherit_doc Lean.Widget.InteractiveHypothesisBundle]
structure InteractiveHypothesisBundle extends Lean.Widget.InteractiveHypothesisBundle where
/-- The hypothesis's type is of type `Prop` -/
isAssumption? : Option Bool := none
deriving RpcEncodable
-- duplicated but with custom `InteractiveHypothesisBundle`
@[inherit_doc Lean.Widget.InteractiveGoalCore]
structure InteractiveGoalCore where
hyps : Array InteractiveHypothesisBundle
type : CodeWithInfos
ctx : WithRpcRef Elab.ContextInfo
-- duplicated but with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveGoal]
structure InteractiveGoal extends InteractiveGoalCore where
userName? : Option String
goalPrefix : String
mvarId : MVarId
isInserted? : Option Bool := none
isRemoved? : Option Bool := none
deriving RpcEncodable
-- duplicated with custom `InteractiveGoalCore`
@[inherit_doc Lean.Widget.InteractiveTermGoal]
structure InteractiveTermGoal extends InteractiveGoalCore where
range : Lsp.Range
term : WithRpcRef Elab.TermInfo
deriving RpcEncodable
/-- A hint in the game at the corresponding goal. -/
structure GameHint where
/-- The text with the variable names already inserted.
Note: This is in theory superfluous and will be completely replaced by `rawText`. We just left
it in for debugging for now. -/
text : String
/-- Flag whether the hint should be hidden initially. -/
hidden : Bool
/-- The text with the variables not inserted yet. -/
rawText : String
/-- The assignment of variable names in the `rawText` to the ones the player used. -/
varNames : Array <| Name × Name
deriving FromJson, ToJson
/-- Bundled `InteractiveGoal` together with an array of hints that apply at this stage. -/
structure InteractiveGoalWithHints where
goal : InteractiveGoal
/-- Extended the `InteractiveGoal` by an array of hints at that goal. -/
hints : Array GameHint
deriving RpcEncodable
structure InteractiveGoalsWithHints where
goals : Array InteractiveGoalWithHints
/-- The content of the line evaluated. -/
command : String
diags : Array InteractiveDiagnostic := default
line : Option Nat -- only for debugging
column : Option Nat -- only for debugging
deriving RpcEncodable
instance : Inhabited InteractiveGoalsWithHints := ⟨default, default, default, none, none⟩
/-- Collected goals throughout the proof. Used for communication with the game client. -/
structure ProofState where
/-- goals after each line. includes the hints. -/
steps : Array <| InteractiveGoalsWithHints
/-- diagnostics contains all errors and warnings.
TODO: I think they contain information about which line they belong to. Verify this.
-/
diagnostics : Array InteractiveDiagnostic := default
/-- Whether the level is considered solved. -/
completed : Bool
completedWithWarnings : Bool
lastPos : Nat -- only for debugging
deriving RpcEncodable
-257
View File
@@ -1,257 +0,0 @@
import Lean.Server.Watchdog
import GameServer.FileWorker
import GameServer.EnvExtensions
import GameServer.Game
namespace WasmServer.Watchdog
open Lean
open Server
open Watchdog
open IO
open Lsp
open JsonRpc
open System.Uri
open MyServer.FileWorker
structure WasmFileState :=
fileWorkerState : FileWorker.WorkerState
gameWorkerState : GameWorkerState
headerTask : Task (Except Error (Snapshots.Snapshot × SearchPath))
structure WasmServerState :=
initParams? : Option InitializeParams
gameServerState : GameServerState
fileState : HashMap String WasmFileState := {}
def wasmSearchPath : SearchPath := ["/lib", "/gamelib"]
@[export game_make_state]
unsafe def makeState : IO WasmServerState := do
let e ← IO.getStderr
try
Lean.enableInitializersExecution
searchPathRef.set wasmSearchPath
let env ← importModules #[
{ module := `GameServer : Import }
] {} 0
let state : GameServerState := {
env,
game := `TestGame,
gameDir := "test",
inventory := #[]
difficulty := 0
}
return ⟨none, state, {}⟩
catch err =>
e.putStrLn s!"Import error: {err}"
throw err
def readMessage (s : String) : IO JsonRpc.Message := do
let j ← ofExcept (Json.parse s)
let m ← match fromJson? j with
| Except.ok (m : JsonRpc.Message) => pure m
| Except.error inner => throw $ userError s!"JSON '{j.compress}' did not have the format of a JSON-RPC message.\n{inner}"
return m
def readLspRequestAs (s : String) (expectedMethod : String) (α : Type) [FromJson α] : IO (Request α) := do
let m ← readMessage s
match m with
| Message.request id method params? =>
if method = expectedMethod then
let j := toJson params?
match fromJson? j with
| Except.ok v => pure $ JsonRpc.Request.mk id expectedMethod (v : α)
| Except.error inner => throw $ userError s!"Unexpected param '{j.compress}' for method '{expectedMethod}'\n{inner}"
else
throw $ userError s!"Expected method '{expectedMethod}', got method '{method}'"
| _ => throw $ userError s!"Expected JSON-RPC request, got: '{(toJson m).compress}'"
def initializeServer (id : RequestID) : IO Unit := do
let o ← IO.getStdout
o.writeLspResponse {
id := id
result := {
capabilities := mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Game Server"
version? := "0.1.1"
}
: InitializeResult
}
}
return ()
def mkServerContext (state : WasmServerState) : IO ServerContext := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
let srcSearchPath ← searchPathRef.get
let references ← IO.mkRef (← loadReferences)
let fileWorkersRef ← IO.mkRef (RBMap.empty : FileWorkerMap)
let workerPath := "no-worker-path"
let some initParams := state.initParams?
| throwServerError "no yet initialized"
return {
hIn := i
hOut := o
hLog := e
args := []
fileWorkersRef := fileWorkersRef
initParams
workerPath
srcSearchPath
references
}
def runGameServerM (state : WasmServerState) (x : GameServerM α) : IO (α × WasmServerState) := do
let (res, gameServerState) ← ReaderT.run
(StateT.run x state.gameServerState)
(← mkServerContext state)
return (res, {state with gameServerState})
def mkWorkerContext (state : WasmServerState) (headerTask : Task (Except Error (Snapshots.Snapshot × SearchPath))) :
IO FileWorker.WorkerContext := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
let some initParams := state.initParams?
| throwServerError "no yet initialized"
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
return {
hIn := i
hOut := o
hLog := e
headerTask := headerTask
initParams := initParams
clientHasWidgets
}
def runGameWorkerM (state : WasmServerState) (fileState : WasmFileState) (x : GameWorkerM α) :
IO (α × WasmFileState) := do
let s := fileState.fileWorkerState
let ctx ← mkWorkerContext state fileState.headerTask
let ((res, gameWorkerState), s) ← StateRefT'.run (s := s) <| ReaderT.run (r := ctx) <|
StateT.run (s := fileState.gameWorkerState) <| x
let fileState := {fileState with gameWorkerState := gameWorkerState, fileWorkerState := s}
return (res, fileState)
def parseParams {paramType : Type} [FromJson paramType] (params : Json) : IO paramType :=
match fromJson? params with
| Except.ok parsed => pure parsed
| Except.error inner => throwServerError s!"Got param with wrong structure: {params.compress}\n{inner}"
def requestWorkerUri (method : String) (params : Json) : IO (Option DocumentUri) := do
if method == "$/lean/rpc/connect" then
let ps : Lsp.RpcConnectParams ← parseParams params
pure <| fileSource ps
else match (← routeLspRequest method params) with
| Except.error e =>
throwServerError e.message
| Except.ok uri => pure uri
open FileWorker in
def handleDidOpen (params : DidOpenTextDocumentParams) (state : WasmServerState) : IO WasmServerState := do
let some initParams := state.initParams?
| throwServerError "no yet initialized"
let (_, state) ← runGameServerM state do
let some lvl ← GameServer.getLevelByFileName? initParams
((System.Uri.fileUriToPath? params.textDocument.uri).getD params.textDocument.uri |>.toString)
| throwServerError s!"Level not found: {params.textDocument.uri} | {initParams.rootUri?}"
let env ← importModules #[
{ module := lvl.module : Import }
] {} 0
(← getStderr).putStr "Import for level completed"
let doc := params.textDocument
let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap, .always⟩
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
let (headerStx, headerTask) ← mkHeaderTask meta (← getStdout) wasmSearchPath env {} clientHasWidgets
let cancelTk ← CancelToken.new
let levelParams := {
uri := meta.uri
gameDir := state.gameServerState.gameDir
levelModule := lvl.module
tactics := lvl.tactics.tiles
lemmas := lvl.lemmas.tiles
definitions := lvl.definitions.tiles
inventory := state.gameServerState.inventory
difficulty := state.gameServerState.difficulty
statementName := lvl.statementName
: Game.DidOpenLevelParams
}
let ctx ← mkWorkerContext state headerTask
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk levelParams ctx (startAfterMs := 0)
| Except.error e => throw (e : ElabTaskError))
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
let s : WasmFileState := {
fileWorkerState := {
doc := doc
initHeaderStx := headerStx
pendingRequests := RBMap.empty
rpcSessions := RBMap.empty
}
gameWorkerState := { levelParams }
headerTask
}
let fileState := state.fileState.insert params.textDocument.uri s
return {state with fileState}
return state
@[export game_send_message]
unsafe def sendMessage (s : String) (state : WasmServerState) : IO WasmServerState := do
let e ← IO.getStderr
try
let m ← readMessage s
match m with
| Message.request id "initialize" (some params) =>
let p : InitializeParams ← parseParams (toJson params)
initializeServer id
let p := {p with rootUri? := some (toString state.gameServerState.game)}
return {state with initParams? := some p}
| _ =>
let (isGameEv, state) ← runGameServerM state (Game.handleServerEvent (.clientMsg m))
if isGameEv then
return state
else
match m with
| Message.notification method (some params) =>
let handle := (fun α [FromJson α] (handler : α → WasmServerState → IO WasmServerState)
=> parseParams (toJson params) >>= (handler · state))
match method with --TODO
| "textDocument/didOpen" => handle DidOpenTextDocumentParams handleDidOpen
-- | "textDocument/didChange" => handle DidChangeTextDocumentParams handleDidChange
-- | "textDocument/didClose" => handle DidCloseTextDocumentParams handleDidClose
-- | "workspace/didChangeWatchedFiles" => handle DidChangeWatchedFilesParams handleDidChangeWatchedFiles
-- | "$/cancelRequest" => handle CancelParams handleCancelRequest
-- | "$/lean/rpc/connect" => handle RpcConnectParams (forwardNotification method)
-- | "$/lean/rpc/release" => handle RpcReleaseParams (forwardNotification method)
-- | "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (forwardNotification method)
| _ => return state
| Message.request id method (some params) =>
let some uri ← requestWorkerUri method (toJson params)
| throwServerError s!"Could not find Uri for request: {method}"
let some fileState := state.fileState.find? uri
| throwServerError s!"File not open: {uri}"
let (_, fileState) ← runGameWorkerM state fileState do
MyServer.FileWorker.mainLoop1 m
let fileState := state.fileState.insert uri fileState
return {state with fileState}
| Message.responseError _ _ e .. =>
throwServerError s!"Unhandled response error: {e}"
| _ => throwServerError "Got invalid JSON-RPC message"
-- match m with
-- | _ =>
-- e.putStrLn s!"Expected JSON-RPC request, got: '{(toJson m).compress}'"
-- return state
catch err =>
e.putStrLn s!"Server error: {err}"
return state
-158
View File
@@ -1,158 +0,0 @@
/- This file is mostly copied from `Lean/Server/Watchdog.lean`. -/
import Lean.Server.Watchdog
import GameServer.Game
namespace MyServer.Watchdog
open Lean
open Server
open Watchdog
open IO
open Lsp
open JsonRpc
open System.Uri
partial def mainLoop (clientTask : Task ServerEvent) : GameServerM Unit := do
let st ← read
let workers ← st.fileWorkersRef.get
let mut workerTasks := #[]
for (_, fw) in workers do
if let WorkerState.running := fw.state then
workerTasks := workerTasks.push <| fw.commTask.map (ServerEvent.workerEvent fw)
let ev ← IO.waitAny (clientTask :: workerTasks.toList)
if ← Game.handleServerEvent ev then -- handle Game requests
mainLoop (←runClientTask)
else
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "shutdown" _ =>
shutdown
st.hOut.writeLspResponse ⟨id, Json.null⟩
| Message.request id method (some params) =>
handleRequest id method (toJson params)
mainLoop (←runClientTask)
| Message.response .. =>
-- TODO: handle client responses
mainLoop (←runClientTask)
| Message.responseError _ _ e .. =>
throwServerError s!"Unhandled response error: {e}"
| Message.notification method (some params) =>
if method == "textDocument/didOpen" then
-- for lean4game, we need to pass in extra information when a level is opened:
Game.handleDidOpenLevel (← parseParams _ (toJson params))
else
handleNotification method (toJson params)
mainLoop (←runClientTask)
| _ => throwServerError "Got invalid JSON-RPC message"
| ServerEvent.clientError e => throw e
| ServerEvent.workerEvent fw ev =>
match ev with
| WorkerEvent.ioError e =>
throwServerError s!"IO error while processing events for {fw.doc.uri}: {e}"
| WorkerEvent.crashed _ =>
handleCrash fw.doc.uri #[]
mainLoop clientTask
| WorkerEvent.terminated =>
throwServerError "Internal server error: got termination event for worker that should have been removed"
| .importsChanged =>
startFileWorker fw.doc
mainLoop clientTask
def initAndRunWatchdogAux : GameServerM Unit := do
let st ← read
try
discard $ st.hIn.readLspNotificationAs "initialized" InitializedParams
let clientTask ← runClientTask
mainLoop clientTask
catch err =>
shutdown
throw err
/- NOTE(WN): It looks like instead of sending the `exit` notification,
VSCode just closes the stream. In that case, pretend we got an `exit`. -/
let Message.notification "exit" none ←
try st.hIn.readLspMessage
catch _ => pure (Message.notification "exit" none)
| throwServerError "Got `shutdown` request, expected an `exit` notification"
def createEnv (gameDir : String) (module : String) : IO Environment := do
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
let out ← IO.Process.output
{ cwd := gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
if out.exitCode != 0 then
throwServerError s!"Error while running Lake: {out.stderr}"
-- Make the paths relative to the current directory
let paths : List System.FilePath := System.SearchPath.parse out.stdout.trim
let currentDir ← IO.currentDir
let paths := paths.map fun p => currentDir / (gameDir : System.FilePath) / p
-- Set the search path
Lean.searchPathRef.set paths
let env ← importModules #[{ module := `Init : Import }, { module := module : Import }] {} 0
return env
def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
if args.length < 2 then
throwServerError s!"Expected 1-3 command line arguments in addition to `--server`:
game directory, the name of the main module (optional), and the name of the game (optional)."
let gameDir := args[1]!
let module := if args.length < 3 then defaultGameModule else args[2]!
let gameName := if args.length < 4 then defaultGameName else args[3]!
let workerPath := "./gameserver"
-- TODO: Do the following commands slow us down?
let srcSearchPath ← initSrcSearchPath (← getBuildDir)
let references ← IO.mkRef (← loadReferences)
let fileWorkersRef ← IO.mkRef (RBMap.empty : FileWorkerMap)
let i ← maybeTee "wdIn.txt" false i
let o ← maybeTee "wdOut.txt" true o
let e ← maybeTee "wdErr.txt" true e
let state := {
env := ← createEnv gameDir module,
game := gameName,
gameDir := gameDir,
inventory := #[]
difficulty := 0
}
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
-- We misuse the `rootUri` field to the gameName
let rootUri? := gameName
let initRequest := {initRequest with param := {initRequest.param with rootUri?}}
o.writeLspResponse {
id := initRequest.id
result := {
capabilities := mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Game Server"
version? := "0.1.1"
}
: InitializeResult
}
}
let context : ServerContext := {
hIn := i
hOut := o
hLog := e
args := args
fileWorkersRef := fileWorkersRef
initParams := initRequest.param
workerPath
srcSearchPath
references
}
discard $ ReaderT.run (StateT.run initAndRunWatchdogAux state) context
def watchdogMain (args : List String) : IO UInt32 := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
try
initAndRunWatchdog args i o e
return 0
catch err =>
e.putStrLn s!"Watchdog error: {err}"
return 1
end MyServer.Watchdog
-23
View File
@@ -1,23 +0,0 @@
import GameServer.FileWorker
import GameServer.Watchdog
import GameServer.WasmServer
import GameServer.Commands
-- TODO: The only reason we import `Commands` is so that it gets built to on `lake build`
-- should we have a different solution?
unsafe def main : List String → IO UInt32 := fun args => do
let e ← IO.getStderr
Lean.enableInitializersExecution
if args[0]? == some "--server" then
MyServer.Watchdog.watchdogMain args
else if args[0]? == some "--worker" then
MyServer.FileWorker.workerMain {}
else
e.putStrLn s!"Expected `--server` or `--worker`"
return 1
-- TODO: Potentially it could be useful to pass in the `gameName` via the websocket connection
+23 -4
View File
@@ -1,4 +1,23 @@
{"version": 6,
"packagesDir": "lake-packages",
"packages": [],
"name": "GameServer"}
{"version": 7,
"packagesDir": ".lake/packages",
"packages":
[{"url": "https://github.com/leanprover/std4.git",
"type": "git",
"subDir": null,
"rev": "a7543d1a6934d52086971f510e482d743fe30cf3",
"name": "std",
"manifestFile": "lake-manifest.json",
"inputRev": "v4.6.0",
"inherited": false,
"configFile": "lakefile.lean"},
{"url": "https://github.com/hhu-adam/lean-i18n.git",
"type": "git",
"subDir": null,
"rev": "c5b84feffb28dbd5b1ac74b3bf63271296fabfa5",
"name": "i18n",
"manifestFile": "lake-manifest.json",
"inputRev": "v4.6.0",
"inherited": false,
"configFile": "lakefile.lean"}],
"name": "GameServer",
"lakeDir": ".lake"}
+17 -2
View File
@@ -3,11 +3,26 @@ open Lake DSL
package GameServer
@[default_target]
-- Using this assumes that each dependency has a tag of the form `v4.X.0`.
def leanVersion : String := s!"v{Lean.versionString}"
require std from git "https://github.com/leanprover/std4.git" @ leanVersion
require i18n from git "https://github.com/hhu-adam/lean-i18n.git" @ leanVersion
lean_lib GameServer
@[default_target]
lean_exe gameserver {
root := `Main
root := `GameServer
supportInterpreter := true
}
/--
When a package depending on GameServer updates its dependencies,
build the `gameserver` executable.
-/
post_update pkg do
let rootPkg ← getRootPackage
if rootPkg.name = pkg.name then
return -- do not run in GameServer itself
discard <| runBuild gameserver.build >>= (·.await)
-15
View File
@@ -1,15 +0,0 @@
import Lake
open Lake DSL
package GameServer {
buildDir := ".lake/build32"
}
@[default_target]
lean_lib GameServer
@[default_target]
lean_exe gameserver {
root := `Main
supportInterpreter := true
}
+1 -1
View File
@@ -1 +1 @@
leanprover/lean4:v4.3.0-rc2
leanprover/lean4:v4.6.0
-58
View File
@@ -1,58 +0,0 @@
#include <stdio.h>
#include <lean/lean.h>
extern lean_object* game_send_message(lean_object*, lean_object*, lean_object*);
extern lean_object* game_make_state(lean_object*);
// see https://leanprover.github.io/lean4/doc/dev/ffi.html#initialization
extern void lean_initialize_runtime_module();
extern void lean_initialize();
extern void lean_io_mark_end_initialization();
extern lean_object * initialize_GameServer_WasmServer(uint8_t builtin, lean_object *);
lean_object * state;
lean_object * io_world;
void main() {
lean_initialize_runtime_module();
lean_initialize();
lean_object * res;
// use same default as for Lean executables
uint8_t builtin = 1;
io_world = lean_io_mk_world();
res = initialize_GameServer_WasmServer(builtin, io_world);
if (lean_io_result_is_ok(res)) {
lean_dec(res);
} else {
lean_io_result_show_error(res);
lean_dec(res);
return; // do not access Lean declarations if initialization failed
}
lean_init_task_manager();
lean_io_mark_end_initialization();
res = game_make_state(io_world);
if (lean_io_result_is_ok(res)) {
state = lean_io_result_get_value(res);
lean_inc(state);
lean_dec(res);
} else {
lean_io_result_show_error(res);
lean_dec(res);
return; // do not access Lean declarations if initialization failed
}
}
void send_message(char* msg){
lean_object * s = lean_mk_string(msg);
lean_object * res = game_send_message(s, state, io_world);
if (lean_io_result_is_ok(res)) {
state = lean_io_result_get_value(res);
lean_inc(state);
lean_dec(res);
} else {
lean_io_result_show_error(res);
lean_dec(res);
}
}
-13
View File
@@ -1,13 +0,0 @@
#/bin/bash
ARTIFACT_ID=$1
echo "Unpacking ZIP."
unzip -o tmp/artifact_${ARTIFACT_ID}.zip -d tmp/artifact_${ARTIFACT_ID}
echo "Unpacking TAR."
for f in tmp/artifact_${ARTIFACT_ID}/* #Should only be one file
do
echo "Unpacking $f"
mkdir tmp/artifact_${ARTIFACT_ID}_inner
tar -xvf $f -C tmp/artifact_${ARTIFACT_ID}_inner
done
+4 -1
View File
@@ -11,6 +11,9 @@
"downlevelIteration": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"lib": [
"ES2021.String"
]
},
"exclude": ["server"]
"exclude": ["server", "relay"]
}
+15 -4
View File
@@ -5,6 +5,12 @@ import svgr from "vite-plugin-svgr"
// https://vitejs.dev/config/
export default defineConfig({
//root: 'client/src',
build: {
// Relative to the root
// Note: This has to match the path in `relay/index.mjs`
outDir: 'client/dist',
},
plugins: [
react(),
svgr({
@@ -17,15 +23,14 @@ export default defineConfig({
{
src: 'node_modules/@leanprover/infoview/dist/*.production.min.js',
dest: '.'
},
{
src: 'node_modules/coi-serviceworker/coi-serviceworker.js',
dest: '.'
}
]
})
],
publicDir: "client/public",
optimizeDeps: {
exclude: ['games']
},
server: {
port: 3000,
proxy: {
@@ -33,6 +38,12 @@ export default defineConfig({
target: 'ws://localhost:8080',
ws: true
},
'/import': {
target: 'http://localhost:8080',
},
'/data': {
target: 'http://localhost:8080',
},
}
},
resolve: {
-37
View File
@@ -1,37 +0,0 @@
#!/bin/bash
cd server
mkdir -p .lake/toolchains
if [ ! -f .lake/toolchains/lean-4.3.0-rc2-linux_wasm32.tar.zst ]
then
wget -P .lake/toolchains https://github.com/leanprover/lean4/releases/download/v4.3.0-rc2/lean-4.3.0-rc2-linux_wasm32.tar.zst
tar --use-compress-program=unzstd -xvf .lake/toolchains/lean-4.3.0-rc2-linux_wasm32.tar.zst -C .lake/toolchains
fi
if [ ! -f .lake/toolchains/lean-4.3.0-rc2-linux_x86.tar.zst ]
then
wget -P .lake/toolchains https://github.com/leanprover/lean4/releases/download/v4.3.0-rc2/lean-4.3.0-rc2-linux_x86.tar.zst
tar --use-compress-program=unzstd -xvf .lake/toolchains/lean-4.3.0-rc2-linux_x86.tar.zst -C .lake/toolchains
fi
# Linking will fail, but that's ok. We only need the c files.
.lake/toolchains/lean-4.3.0-rc2-linux_x86/bin/lake build -f=lakefile32.lean
lake build
OUT_DIR=../client/public
LEAN_SYSROOT=.lake/toolchains/lean-4.3.0-rc2-linux_wasm32
LEAN_LIBDIR=$LEAN_SYSROOT/lib/lean
emcc -o $OUT_DIR/server.js main.c -I $LEAN_SYSROOT/include -L $LEAN_LIBDIR .lake/build/ir/GameServer/*.c -lInit -lLean -lleancpp -lleanrt \
-sFORCE_FILESYSTEM -lnodefs.js -s EXIT_RUNTIME=0 -s MAIN_MODULE=1 -s LINKABLE=1 -s EXPORT_ALL=1 -s ALLOW_MEMORY_GROWTH=1 -fwasm-exceptions -pthread -flto \
-sPTHREAD_POOL_SIZE_STRICT=2 \
--preload-file "${LEAN_SYSROOT}/lib/lean/Init"@/lib/Init \
--preload-file "${LEAN_SYSROOT}/lib/lean/Init.olean"@/lib/Init.olean \
--preload-file "${LEAN_SYSROOT}/lib/lean/Init.ilean"@/lib/Init.ilean \
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean"@/lib/Lean \
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean.olean"@/lib/Lean.olean \
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean.ilean"@/lib/Lean.ilean \
--preload-file "./.lake/build32/lib"@/gamelib
-106
View File
@@ -1,106 +0,0 @@
const path = require("path");
const webpack = require('webpack');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const WebpackShellPluginNext = require('webpack-shell-plugin-next');
module.exports = env => {
const single_game = process.env.LEAN4GAME_SINGLE_GAME
const environment = process.env.NODE_ENV
const isDevelopment = environment === 'development'
const babelOptions = {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: [
isDevelopment && require.resolve('react-refresh/babel'),
].filter(Boolean),
};
global.$RefreshReg$ = () => {};
global.$RefreshSig$ = () => () => {};
return {
entry: [single_game ? "./client/src/index_local.tsx" : "./client/src/index.tsx"],
mode: isDevelopment ? 'development' : 'production',
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: [/server/, /node_modules/],
use: [{
loader: require.resolve('babel-loader'),
options: babelOptions,
}]
},
{
test: /\.tsx?$/,
use: [{
loader: 'ts-loader',
options: { allowTsInNodeModules: true }
}],
// exclude: /node_modules(?!\/(lean4web|lean4|lean4-infoview))/,
// Allow .ts imports from node_modules/lean4web and node_modules/lean4
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
},
{
test: /\.(jpg|png)$/,
use: {
loader: 'file-loader',
},
},
]
},
resolve: {
extensions: ["*", ".js", ".jsx", ".tsx", ".ts"],
fallback: {
"http": require.resolve("stream-http") ,
"path": require.resolve("path-browserify")
},
},
output: {
path: path.resolve(__dirname, "client/dist/"),
filename: "bundle.js",
},
devServer: {
proxy: {
'/websocket': {
target: 'ws://localhost:8080',
ws: true
},
'/import': {
target: 'http://localhost:3000',
router: () => 'http://localhost:8080',
},
},
static: path.join(__dirname, 'client/public/'),
port: 3000,
hot: true,
},
devtool: "source-map",
plugins: [
!isDevelopment && new WebpackShellPluginNext({
onBuildEnd:{
scripts: [
// It's hard to set up webpack to copy the index.html correctly,
// so we copy it explicitly after every build:
'cp client/public/index.html client/dist/',
// Similarly, I haven't been able to load `onigasm.wasm` properly:
'cp client/public/onigasm.wasm client/dist/',],
blocking: false,
parallel: true
}
}),
isDevelopment && new ReactRefreshWebpackPlugin(),
].filter(Boolean),
// Webpack is not happy about the dynamically loaded widget code in the function
// `dynamicallyLoadComponent` in `infoview/userWidget.tsx`. If we want to support
// dynamically loaded widget code, we need to make sure that the files are available.
ignoreWarnings: [/Critical dependency: the request of a dependency is an expression/]
};
}