Compare commits

...
213 Commits
Author SHA1 Message Date
Jon Eugster 92acdb7f85 wip 2024-10-10 09:33:43 +02:00
Jon Eugster 64ad36dd4f wip 2024-09-12 12:02:20 +02:00
Jon Eugster 3a885245ae WIP implementing new editor 2024-08-28 13:11:52 +02:00
Jon Eugster 545ac8b0f7 grey-out locked items 2024-08-26 22:50:29 +02:00
Jon Eugster 5a09776b5b npm audit fix 2024-08-20 16:48:35 +02:00
Jon Eugster 38cac494ab fix maximal recursion in useEffect 2024-07-31 16:29:03 +02:00
Jon Eugster 5b6f5f0db7 adding keys 2024-07-31 16:28:51 +02:00
Jon Eugster fbe48d26c5 add support for diags in chat 2024-06-20 13:14:47 +02:00
Jon Eugster d05767d74c add dev version of new typewriter input 2024-06-18 17:37:28 +02:00
Jon Eugster aab6dbb329 cleanup 2024-06-18 16:10:54 +02:00
Jon Eugster 5310e69d98 cleanup 2024-06-18 16:00:42 +02:00
Jon Eugster 67090f53fa add basic infos to inventory 2024-06-18 15:40:35 +02:00
Jon Eugster 2896237772 css 2024-06-13 10:05:12 +02:00
Jon Eugster 30d9b881b9 use GoalsTab in editor mode 2024-06-12 19:40:08 +02:00
Jon Eugster 937c4714dd update game info 2024-06-12 18:57:50 +02:00
Jon Eugster 75b367e3d1 remove LemmaDoc etc. 2024-06-12 12:20:10 +02:00
Jon Eugster b815b86c0d sort theorems alphabetically #110 2024-06-12 11:55:37 +02:00
Jon Eugster 8b4215e407 update documentation about opening local notations #216 2024-06-12 11:50:08 +02:00
Jon Eugster a0ecaeeece fix button display #199 2024-06-12 01:15:57 +02:00
Jon Eugster dea5fd0558 add buttons to delete level/world only #199 2024-06-12 01:08:40 +02:00
Jon Eugster c03e2f123e add next/home back button to chat 2024-06-12 00:32:20 +02:00
Jon Eugster b1cdcfc113 press enter to go to next level #170 2024-06-12 00:31:56 +02:00
Jon Eugster 503ea51f95 add framework to allow unbundle hypotheses #105 2024-06-11 23:42:58 +02:00
Jon Eugster aed2899fb6 do not display any errors before the first step #209 2024-06-11 23:08:05 +02:00
Jon Eugster 1f14ad185f internally rename lemma to theorem #108 2024-06-11 23:00:27 +02:00
Jon Eugster 17d2ba5a2c use JuliaMono and Roboto as fonts consistently #192 2024-06-11 21:05:55 +02:00
Jon Eugster 2a070332f2 side-by-side goals #90 2024-06-11 20:39:42 +02:00
Jon Eugster aedf073a33 unlock inventory items in relaxed mode #194 2024-06-11 02:12:26 +02:00
Jon Eugster d684f260bb cleanup imports of world_tree 2024-06-11 01:40:06 +02:00
Jon Eugster 925d729725 doc 2024-06-11 01:38:46 +02:00
Jon Eugster 9f4fe656c8 style 2024-06-11 01:37:48 +02:00
Jon Eugster 72ccdd1478 move Button and Markdown 2024-06-11 01:37:06 +02:00
Jon Eugster b66954248f cleanup context 2024-06-11 01:31:00 +02:00
Jon Eugster 096bd55f9b image for world intro 2024-06-11 01:10:40 +02:00
Jon Eugster 02d0d57453 fail gracefully on bad gameId #150 2024-06-11 01:05:43 +02:00
Jon Eugster 762c1ef0c4 drop comment 2024-06-11 00:56:29 +02:00
Jon Eugster 4652f6d50f add support for external images in chat #235 2024-06-11 00:25:50 +02:00
Jon Eugster f158250341 improve chat scrolling #82 2024-06-10 23:59:23 +02:00
Jon Eugster 7dc0a507ed fix css 2024-06-07 01:20:30 +02:00
Jon Eugster 80d4b88f5a mostly remove 'world' as a word #237 2024-06-06 12:33:04 +02:00
Jon Eugster 714b4f45b1 redefine chat scrolling #230 2024-06-06 02:37:26 +02:00
Jon Eugster 6da902aede cleanup chat css 2024-06-06 01:27:42 +02:00
Jon Eugster cdffe03f9a cleanup chat 2024-06-05 18:47:20 +02:00
Jon Eugster adeed03da8 cleanup; including cleaning up chat 2024-06-05 18:41:01 +02:00
Jon Eugster 8e3dfdea30 wip 2024-05-24 15:34:33 +02:00
Jon Eugster 71fad5699e turn defeq-hints on by default #45 2024-05-23 16:35:52 +02:00
Jon Eugster a7d746a8e5 add defeq-hints #45 2024-05-23 16:33:55 +02:00
Jon Eugster c735211cd8 cleanup 2024-05-17 00:43:54 +02:00
Jon Eugster 369b77f00f add search parameter to set language #220 2024-05-17 00:17:44 +02:00
Jon Eugster 895c71dc91 cleanup 2024-05-16 23:40:21 +02:00
Jon Eugster 8b5d6ff2f3 improve language selection 2024-05-16 23:26:50 +02:00
Jon Eugster c1642cf09b cleanup: popups 2024-05-16 17:27:16 +02:00
Jon Eugster d82ef8af8f refactor: navigation and other stuff 2024-05-16 02:31:41 +02:00
Jon Eugster 08875e4415 style error page 2024-05-07 17:13:03 +02:00
Jon Eugster 5765c78a1d add submenu for inventory categories 2024-05-03 16:30:36 +02:00
Jon Eugster b9112bfb09 add 'using' as known keyword 2024-05-03 15:16:19 +02:00
Jon Eugster 05fbee9365 mark inventory item from the last level 2024-04-25 15:11:31 +02:00
Jon Eugster 1daef0d80f show exercise name even if no text present 2024-04-25 15:10:51 +02:00
Jon Eugster 06cc52bb7e mark tabs with new theorems 2024-04-24 17:01:33 +02:00
Jon Eugster eac945e7b5 update impressum & privacy policy 2024-04-24 14:31:29 +02:00
Jon Eugster a1b1a33a9b remove more flags #208 2024-04-19 10:07:09 +02:00
Jon Eugster 02978a38ed add allowed keywords #215 2024-04-18 18:22:19 +02:00
Jon Eugster 8cf358e17b add config option to disable flags in preferences #208 2024-04-18 18:06:56 +02:00
Jon Eugster 3ddcc35137 Merge pull request #206 from JiechengZhao/cn-i18n
add Chinese translation
2024-04-18 12:27:56 +02:00
Jon Eugster 4e7c958348 Update client/public/locales/zh/translation.json 2024-04-18 12:24:29 +02:00
Hydrogenbear 237371a77f Merge branch 'main' of https://github.com/leanprover-community/lean4game into cn-i18n 2024-04-18 10:23:14 +08:00
Jon Eugster f308e1ad49 fix CSS of tooltips #207 2024-04-17 12:54:13 +02:00
Jon Eugster 62f1fb87d1 add numpad-enter key #212 2024-04-17 12:23:53 +02:00
Hydrogenbear c5df85ce66 Add a missed translation 2024-04-11 19:38:33 +08:00
Jon Eugster 848b2cddc8 fix hhu-adam/Robo#22 2024-04-11 11:59:03 +02:00
Jon Eugster d034148bec Update README.md 2024-04-11 10:30:08 +02:00
Jon Eugster 3d9d244b31 mark another string from translation 2024-04-10 17:12:55 +02:00
Jon Eugster feaf6dae0c Update translate.md 2024-04-10 16:17:44 +02:00
Jon Eugster 6059ff1da7 Update translate.md 2024-04-10 16:17:27 +02:00
Jon Eugster 6f95f41d14 Update translate.md 2024-04-10 16:15:19 +02:00
Jon Eugster 335209b094 Update README.md 2024-04-10 16:13:06 +02:00
Jon Eugster de1ff4fadc Update create_game.md 2024-04-10 16:12:35 +02:00
Jon Eugster db66cb1fcc Create translate.md 2024-04-10 16:11:12 +02:00
Jon Eugster 66aa8e688e npm audit 2024-04-10 14:46:48 +02:00
Jon Eugster a523bab1d6 fix css warning 2024-04-10 14:45:17 +02:00
Jon Eugster 499bb00d4f fix language loading on landing page 2024-04-10 14:43:30 +02:00
Jon Eugster 67b03d9ccf bump to v4.7.0 2024-04-10 14:06:41 +02:00
Jon Eugster a15dd0a1bd bump i18n 2024-04-10 11:23:27 +02:00
Hydrogenbear ded9c38170 Merge remote-tracking branch 'upstream/dev' into cn-i18n 2024-04-10 08:30:36 +08:00
Jon Eugster d456621875 apply suggestions from PR #206 manually 2024-04-09 09:47:05 +02:00
Jon Eugster 74059bd5af fix typo 2024-04-08 23:20:21 +02:00
Jon Eugster ce71bc81c6 remove logInfo for Branch finishing proof 2024-04-08 12:23:07 +02:00
Jon Eugster 7cedfc5038 mark some server messages for translation 2024-04-06 15:40:06 +02:00
Jon Eugster a8d3169ebb add some translation keys 2024-04-06 15:25:31 +02:00
Jon Eugster 9b05a27888 use translation of level title 2024-04-06 14:56:19 +02:00
Hydrogenbear af5426856e Some updates, and fix. 2024-04-01 12:35:17 +08:00
Jiecheng 470a184cac some update 2024-04-01 07:50:22 +08:00
Hydrogenbear 1006097e32 Merge branch 'dev' of https://github.com/leanprover-community/lean4game into cn-i18n 2024-03-30 09:46:40 +08:00
Jon Eugster 3124d90cd6 typo 2024-03-29 12:36:39 +01:00
Jon Eugster 5a768c25b7 mark two more translations 2024-03-29 12:34:57 +01:00
Jon Eugster 39be34e83a improve landing page 2024-03-29 12:27:14 +01:00
Jon Eugster dd7a2d1bc1 bump lean-i18n 2024-03-29 12:23:37 +01:00
Jon Eugster 684a1bc72b fix initial translation loading on starting page 2024-03-29 12:16:36 +01:00
Jon Eugster 10391b616c add chinese translations 2024-03-28 20:45:50 +01:00
Hydrogenbear 1431ff8b49 remove the mistyped key 2024-03-28 10:21:10 +08:00
Hydrogenbear d3a55a4dd3 add Chinese translation 2024-03-28 10:07:57 +08:00
Jon Eugster aaa6c3b576 add german translations 2024-03-27 02:17:46 +01:00
Jon Eugster d60dc3fcb2 mark most of the game text for translation 2024-03-27 02:03:05 +01:00
Jon Eugster 1a54edffd4 bump lean-i18n and other stuff 2024-03-27 01:37:58 +01:00
Jon Eugster ad1add5264 support translations of the games 2024-03-27 00:22:51 +01:00
Jon Eugster a26022e3fc fix css 2024-03-26 19:10:23 +01:00
Jon Eugster 7c44d49b2a images on all levels 2024-03-26 17:24:53 +01:00
Jon Eugster a111814973 improve error messages 2024-03-26 14:59:02 +01:00
Jon Eugster a7784ef66b center github icon 2024-03-26 10:04:39 +01:00
Jon Eugster 389e120117 add translations 2024-03-26 02:49:48 +01:00
Jon Eugster ee9fd18a56 use global configs in i18n-scanner 2024-03-26 01:16:21 +01:00
Jon Eugster 8b29f88407 use flags for game tiles 2024-03-26 01:02:23 +01:00
Jon Eugster ebda6cc162 more progress on internationalisation 2024-03-26 00:30:55 +01:00
Jon Eugster 7e9514fe96 mark all texts for translation #179 2024-03-25 23:51:19 +01:00
Jon Eugster 038dbe71b8 fix i18next-scanner overwriting existing translations 2024-03-25 23:00:05 +01:00
Jon Eugster 19d2ea363a refactor i18next-scanner config 2024-03-25 22:44:26 +01:00
Jon Eugster 3ac8fdace7 mark translations in app-bar 2024-03-25 22:30:55 +01:00
Jon Eugster 8d0493acb5 make preferences work #179 2024-03-25 22:23:03 +01:00
Jon Eugster 27c661f08f modify generated statement in inventory 2024-03-25 22:23:03 +01:00
Jon Eugster b37f050da5 Merge pull request #204 from noamraph/patch-1
Update level.css - hide .katex-mathml, to fix scrolling issue
2024-03-25 18:51:48 +01:00
Noam Yorav-Raphael 09c81ea43f Update level.css - hide .katex-mathml, to fix scrolling issue
Fixes https://github.com/leanprover-community/lean4game/issues/202
2024-03-25 19:10:33 +02:00
Jon Eugster ff12b34295 add suspense to wait for loading #179 2024-03-25 13:43:01 +01:00
Jon Eugster 45bc0468df implement i18next and i18next-scanner 2024-03-24 17:37:31 +01:00
Jon Eugster c24efb1377 Merge pull request #203 from JiechengZhao/main
add next_i18n
2024-03-23 13:25:18 +01:00
Hydrogenbear 830bffaf11 Remove gitignore introduced by accidently lake init 2024-03-23 09:07:30 +08:00
Jon Eugster a9447a70d4 add interface buttons for i18n #179 2024-03-23 01:11:42 +01:00
Jon Eugster eaa4eecad2 add doc 2024-03-22 23:27:08 +01:00
Jon Eugster 1828e73b30 add preample tactic sequence to Statement 2024-03-22 18:59:01 +01:00
Jon Eugster ce9f5c7840 fix locked editor mode 2024-03-22 18:11:14 +01:00
Hydrogenbear 64d7879c32 delete accidently lake init 2024-03-21 18:13:31 +08:00
Hydrogenbear cb711205a2 Add react_i18n 2024-03-21 18:11:17 +08:00
Jon Eugster f42829422d delete hidden hints in chat on Retry 2024-03-15 18:38:32 +01:00
Jon Eugster ee6741232f add doc 2024-03-15 18:06:33 +01:00
Jon Eugster fa4ae5672d bump to v4.6.1 2024-03-15 17:07:10 +01:00
Jon Eugster 9bc0a3de46 add let_intros for better experience with levels about functions 2024-03-15 17:06:43 +01:00
Jon Eugster 6cdbfbd9cb Revert "DisableTheorem and co. should not warn if doc does not exist"
This reverts commit 381930e547.
2024-03-14 21:18:02 +01:00
Jon Eugster f55581a5f2 doc 2024-03-14 19:43:54 +01:00
Jon Eugster 381930e547 DisableTheorem and co. should not warn if doc does not exist 2024-03-14 19:43:47 +01:00
Jon Eugster e07570181c typo 2024-03-11 19:36:54 +01:00
Jon Eugster 87689e1c3a dont show 'intermediate goal solved' on error 2024-03-11 19:29:53 +01:00
Jon Eugster 47297e4194 temporary fix to improve message on server crash 2024-03-11 17:33:06 +01:00
Jon Eugster f3f077741d fix client breaking if server timed out. 2024-03-11 12:33:47 +01:00
Jon Eugster edf1085310 fix ts warnings 2024-03-11 12:18:30 +01:00
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
116 changed files with 13653 additions and 12549 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
name: Build name: Build
run-name: Build the project run-name: Build the project
on: [push] on:
workflow_dispatch:
push:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+7
View File
@@ -0,0 +1,7 @@
GameSkeleton
HiddenTactic
subgoals
KaTex
gameserver
lakefile
Zulip
+13 -1
View File
@@ -8,7 +8,8 @@ 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 5: [How to Run Games Locally](doc/running_locally.md)
* Step 7: [How to Update an existing Game](doc/update_game.md) * Step 7: [How to Update an existing Game](doc/update_game.md)
* Step 8: [How to Publishing a Game](doc/publish_game.md) * Step 9: [How to Publishing a Game](doc/publish_game.md)
* [Troubleshooting](doc/troubleshoot.md)
## Documentation ## Documentation
@@ -36,6 +37,17 @@ not fully written yet.
Contributions to `lean4game` are always welcome! Contributions to `lean4game` are always welcome!
### Translation
The interface can be translated to various languages. For adding a translation, one needs to do the following:
1. In `client/src/config.json`, add your new language. The "iso" key is the ISO language code, i.e. it should be accepted by "i18next" and "GNU gettext"; the "flag" key is once accepted by [react-country-flag](https://www.npmjs.com/package/react-country-flag).
2. Run `npm run translate`. This should create a new file `client/public/locales/{language}/translation.json`. (alternatively you can copy-paste `client/public/locales/en/translation.json`)
3. Add all translations.
4. Commit the changes you made to `config.json` together with the new `translation.json`.
For translating games, see [Translating a game](doc/translate.md).
## Security ## Security
Providing the use access to a Lean instance running on the server is a severe security risk. That is why we start the Lean server with bubblewrap. Providing the use access to a Lean instance running on the server is a severe security risk. That is why we start the Lean server with bubblewrap.
+152
View File
@@ -0,0 +1,152 @@
const lean4gameConfig = require("./src/config.json")
const typescriptTransform = require('i18next-scanner-typescript');
const fs = require('fs');
const chalk = require('chalk');
const eol = require('eol');
const path = require('path');
const VirtualFile = require('vinyl');
function flush(done) {
const { parser } = this;
const { options } = parser;
// Flush to resource store
const resStore = parser.get({ sort: options.sort });
const { jsonIndent } = options.resource;
const lineEnding = String(options.resource.lineEnding).toLowerCase();
Object.keys(resStore).forEach((lng) => {
const namespaces = resStore[lng];
Object.keys(namespaces).forEach((ns) => {
const resPath = parser.formatResourceSavePath(lng, ns);
let resContent;
try {
resContent = JSON.parse(
fs.readFileSync(
fs.realpathSync(path.join('public', 'locales', resPath))
).toString('utf-8')
);
} catch (e) {
console.log("no previous translation found!")
resContent = {};
}
const obj = { ...namespaces[ns], ...resContent };
let text = JSON.stringify(obj, null, jsonIndent) + '\n';
if (lineEnding === 'auto') {
text = eol.auto(text);
} else if (lineEnding === '\r\n' || lineEnding === 'crlf') {
text = eol.crlf(text);
} else if (lineEnding === '\n' || lineEnding === 'lf') {
text = eol.lf(text);
} else if (lineEnding === '\r' || lineEnding === 'cr') {
text = eol.cr(text);
} else { // Defaults to LF
text = eol.lf(text);
}
let contents = null;
try {
// "Buffer.from(string[, encoding])" is added in Node.js v5.10.0
contents = Buffer.from(text);
} catch (e) {
// Fallback to "new Buffer(string[, encoding])" which is deprecated since Node.js v6.0.0
contents = new Buffer(text);
}
this.push(new VirtualFile({
path: resPath,
contents: contents
}));
});
});
done();
}
module.exports = {
input: [
'client/src/**/*.{tsx,ts}',
// Use ! to filter out files or directories
'!client/i18n/**',
'!**/node_modules/**',
],
options: {
debug: true,
removeUnusedKeys: true,
func: {
list: ['i18next.t', 'i18n.t', 't'],
extensions: ['.js', '.jsx'] // not .ts or .tsx since we use i18next-scanner-typescript!
},
trans: {
component: 'Trans',
i18nKey: 'i18nKey',
defaultsKey: 'defaults',
extensions: ['.js', '.jsx'], // not .ts or .tsx since we use i18next-scanner-typescript!
fallbackKey: (ns, value) => {return value},
// https://react.i18next.com/latest/trans-component#usage-with-simple-html-elements-like-less-than-br-greater-than-and-others-v10.4.0
supportBasicHtmlNodes: true, // Enables keeping the name of simple nodes (e.g. <br/>) in translations instead of indexed keys.
keepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p'], // Which nodes are allowed to be kept in translations during defaultValue generation of <Trans>.
// // https://github.com/acornjs/acorn/tree/master/acorn#interface
// acorn: {
// ecmaVersion: 2020,
// sourceType: 'module', // defaults to 'module'
// }
},
lngs: lean4gameConfig.languages.map(e => e.iso),
ns: [],
defaultLng: 'en',
defaultNs: 'translation',
defaultValue: (lng, ns, key) => {
if (lng === 'en') {
return key; // Use key as value for base language
}
return ''; // Return empty string for other languages
},
resource: {
loadPath: './client/public/locales/{{lng}}/{{ns}}.json',
savePath: './client/public/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2,
lineEnding: '\n'
},
nsSeparator: false, // namespace separator
keySeparator: false, // key separator
plurals: false,
interpolation: {
prefix: '{{',
suffix: '}}'
},
metadata: {},
allowDynamicKeys: false,
},
transform: typescriptTransform(
// options
{
// default value for extensions
extensions: [".ts", ".tsx"],
// optional ts configuration
tsOptions: {
target: "es2017",
},
},
function(outputText, file, enc, done) {
'use strict';
const parser = this.parser;
parser.parseTransFromString(outputText);
parser.parseFuncFromString(outputText);
done();
}
),
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 607 KiB

Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2020 - 2023, cormullion
with Reserved Font Name JuliaMono.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2021 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
{
"Tactics": "Taktiken",
"Lean Game Server": "Lean-Spielserver",
"<p>Game rules determine if it is allowed to skip levels and if the games runs checks to only allow unlocked tactics and theorems in proofs.</p><1>Note: \"Unlocked\" tactics (or theorems) are determined by two things: The set of minimal tactics needed to solve a level, plus any tactics you unlocked in another level. That means if you unlock <1>simp</1> in a level, you can use it henceforth in any level.</1><p>The options are:</p>": "<p>Die Spielregeln bestimmen ob es erlaubt ist, Levels zu überspringen und ob das Spiel überprüft welche Taktiken und Theoreme freigeschaltet sind und nur diese im Beweis akzeptiert.</p><1>Bemerkung: \"Freigeschaltete\" Taktiken (und Theoreme) werden durch zwei Faktoren bestimmt: The Menge der Taktiken die minimal notwendig sind um den Level zu lösen und dazu die Menge aller Taktiken, die in einem anderen Level freigeschaltet wurden. Das bedeutet wenn <1>simp</1> in einem Level freigeschaltet wird, kann diese Taktik danach in jeglichen Levels verwendet werden.",
"Game Rules": "Spielregeln",
"levels": "Levels",
"tactics": "Taktiken",
"regular": "regulär",
"relaxed": "relaxed",
"none": "keine",
"Rules": "Regeln",
"Intro": "Einführung",
"Start": "Start",
"Next": "Weiter",
"Previous": "Zurück",
"Editor mode is enforced!": "Editor kann nicht verlassen werden!",
"Editor mode": "Editor",
"Typewriter mode": "Schreibmaschine",
"Preferences": "Einstellungen",
"Game Info": "Spielinfo",
"Erase": "Löschen",
"Download": "Herunterladen",
"Upload": "Laden",
"Home": "Home",
"back to games selection": "Zurück zur Spielauswahl",
"World": "Welt",
"Show more help!": "Mehr Hilfe",
"Goal": "Goal",
"Current Goal": "Aktuelles Goal",
"Objects": "Objekte",
"Assumptions": "Annahmen",
"Further Goals": "Weitere Goals",
"No Goals": "Keine Goals",
"Loading goal…": "Goal wird geladen…",
"Click somewhere in the Lean file to enable the infoview.": "Ein Klick in den Lean-Code aktiviert den Infoview.",
"Waiting for Lean server to start…": "Warte auf das Starten des Lean-Servers…",
"Level completed! 🎉": "Level gelöst! 🎉",
"Level completed with warnings 🎭": "Level mit Warnungen abgeschlossen 🎭",
"Active Goal": "Aktuelles Goal",
"Crashed! Go to editor mode and fix your proof! Last server response:": "",
"Line": "Zeile",
"Character": "Charakter",
"Loading messages…": "",
"Execute": "Ausführen",
"Definitions": "Definitionen",
"Theorems": "Theoreme",
"Not unlocked yet": "Noch nicht verfügbar",
"Not available in this level": "In diesem Level nicht verfügbar",
"A repository of learning games for the proof assistant <1>Lean</1> <i>(Lean 4)</i> and its mathematical library <5>mathlib</5>": "Eine Sammlung von Lernspielen für den Beweisassistenten <1>Lean</1> <i>(Lean 4)</i> und dessen mathematische Bibliothek <5>mathlib</5>",
"No Games loaded. Use <1>http://localhost:3000/#/g/local/FOLDER</1> to open a game directly from a local folder.": "Kein Spiel geladen. öffne <1>http://localhost:3000/#/g/local/FOLDER</1> um ein Spiel direkt von einem lokalen Ordner zu laden.",
"<p>As this server runs lean on our university machines, it has a limited capacity. Our current estimate is about 70 simultaneous games. We hope to address and test this limitation better in the future.</p><1>Most aspects of the games and the infrastructure are still in development. Feel free to file a <1>GitHub Issue</1> about any problems you experience!</1>": "",
"This server has been developed as part of the project <1>ADAM : Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität in Düsseldorf.": "",
"Prerequisites": "Voraussetzungen",
"Worlds": "Welten",
"Levels": "Levels",
"Language": "Sprachen",
"Development notes": "Entwicklungshinweis",
"Adding new games": "Neue Spiele hinzufügen",
"Funding": "Finanzierung",
"<p>Do you want to delete your saved progress irreversibly?</p><p>(This deletes your proofs and your collected inventory. Saves from other games are not deleted.)</p>": "<p>Soll der Spielstand unwiderruflich gelöscht werden?</p><p>(Dies löscht sämtliche Beweise und das gesammelte Inventar. Spielstände anderer Spiele werden nicht gelöscht.)</p>",
"Delete Progress?": "Spielstand löschen?",
"Delete": "Löschen",
"Download & Delete": "Herunterladen & Löschen",
"Cancel": "Abbrechen",
"Layout": "Seitenlayout",
"Always visible": "Immer sichtbar",
"Save my settings (in the browser store)": "Einstellungen im Browser speichern.",
"<p>Select a JSON file with the saved game progress to load your progress.</p><1><0>Warning:</0> This will delete your current game progress! Consider <2>downloading your current progress</2> first!</1>": "<p>Wähle eine JSON-Datei mit dem Spielstand um diesen zu laden.</p><1><0>Achtung:</0> Diese Aktion überschreibt den aktuellen Spielstand! Es wird empfohlen zuerst den <2>aktuellen Spielstand herunterzuladen</2>!</1>",
"Upload Saved Progress": "Spielstand hochladen",
"Load selected file": "Ausgewählte Datei hochladen",
"Mobile": "Mobil",
"Auto": "Auto",
"Desktop": "Desktop",
"<0>If you are considering writing your own game, you should use the <1>GameSkeleton Github Repo</1> as a template and read <3>How to Create a Game</3>.</0><1>You can directly load your games into the server and play it using the correct URL. The <1>instructions above</1> 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.</1><p>Featured games on this page are added manually. Please get in contact and we'll happily add yours.</p>": "",
"Level": "",
"Introduction": "Einführung",
"Retry proof from here": "Ab hier erneut versuchen",
"Retry": "",
"Failed command": "Befehl fehlgeschlagen",
"view the Lean game server on Github": "Lean game Server auf Github ansehen",
"Theorem": "Theorem",
"Impressum": "Impressum",
"Privacy Policy": "Datenschutzerklärung",
"<0>Impressum</0><1><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></1><2><strong>Legal form:</strong><br/>The Heinrich Heine University Düsseldorf is a corporation under public law. It is legally represented by the Rector Prof. Dr. Anja Steinbeck. The responsible supervisory authority is the Ministry of Culture and Science of North Rhine-Westphalia, Völklinger Straße 49, 40221 Düsseldorf.</2><3><strong>VAT identification number:</strong><br/>according to §27a Sales Tax Act<br/>DE 811222416</3><4><0>Impressum HHU</0></4>": "<0>Impressum</0><1><strong>Kontakt:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Deutschland<br/>+49 211 81-14690<br/><14>Kontaktinformationen</14></1><2><strong>Rechtsform:</strong><br />Die Heinrich-Heine Universität Düsseldorf ist eine Körperschaft des Öffentlichen Rechts. Sie wird durch die Rektorin Prof. Dr. Anja Steinbeck gesetzlich vertreten. Zuständige Aufsichtsbehörde ist das Ministerium für Kultur und Wissenschaft des Landes Nordrhein-Westfalen, Völklinger Straße 49, 40221 Düsseldorf.</2><3><strong>Umsatzsteuer-Identifikationsnummer:</strong><br />gemäß §27a Umsatzsteuergesetz<br />DE 811222416</3><4><0>Impressum der HHU</0></4>",
"<0>Progress saving</0><1>The game stores your progress in your local browser storage. If you delete it, your progress will be lost!<br/>Warning: In most browsers, deleting cookies will also clear the local storage (or \"local site data\"). Make sure to download your game progress first!</1><2>Development</2><3>The game engine has been created by <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>. On a prototype by <strong>Patrick Massot</strong>.</3><4>The source code of this Lean game engine is <1>available on Github</1>. If you experience any problems, please file an <3>Issue on Github</3> or get directly in contact.</4><5>Funding</5><6>The game engine has been developed as part of the project <1>ADAM: Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität Düsseldorf. It is funded by the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.</6>": "<0>Spielstandspeicherung</0><1>Das Spiel speichert den Spielstand im lokalen Browserspeicher. Wird dieser gelöscht, ist der Spielstand nicht wiederherstellbar!<br/>Achtung: In üblichen Browsern bewirkt ein löschen von Cookies auch ein löschen des lokalen Browserspeichers (z.B. \"local site data\"). Der Spielstand sollte vorgehend heruntergeladen werden!</1><2>Entwicklung</2><3>Der Spieleserver wurde entwickelt von <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>. Basierend auf einem Prototyp von <strong>Patrick Massot</strong>.</3><4>Der Sourcecode dieses Lean-Game-Engine ist <1>auf Github verfügbar</1>. Bei Problemen, bitte einen <3>Github-Issue</3> ausfüllen oder direkt mit uns Kontakt aufnehmen.</4><5>Funding</5><6>Dieser Spielserver wurde im Rahmen des Projekts <1>ADAM: Anticipating the Digital Age of Mathematics</1> an der Heinrich-Heine-Universität Düsseldorf entwickelt. Es wird finanziell durch das Projekt <i>Freiraum 2022</i> der <i>Stiftung Innovation in der Hochschullehre</i> unterstützt.</6>",
"<0>Privacy Policy</0><p>Our server collects metadata (such as IP address, browser, operating system) and the data that the user enters into the editor. The data is used to compute the Lean output and display it to the user. The information will be stored as long as the user stays on our website and will be deleted immediately afterwards. We keep logs to improve our software, but the contained data is anonymized.</p><p>We do not use cookies, but your game progress is stored in the browser as site data. Your game progress is not saved on the server; if you delete your browser storage, it is completely gone.</p><p>Our server is located in Germany.</p><4><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></4>": "<0>Datenschutzerklärung</0><p>Unser Server verarbeitet Benutzer-Metadaten (zum Beispiel IP-Addresse, Browser-Version, Betriebssystem) sowie die Benutzereingaben in den Editor. Diese Daten werden verwendet um die Lean-Ausgabe zu berechnen und dem Benutzer darzustellen. Die Informationen werden nur gespeichert, solange der Benutzer auf unserer Webseite bleibt und anschliessend sofort gelöscht. Wir führen Log-Dateien um die Software zu verbessern, aber die enthaltenen Daten sind anonymisiert.</p><p>Wir verwenden keine Cookies, aber der Spielstand wird im Browser als \"Site Data\" gespeichert. Der Spielstand wird nicht auf dem Server gespeichert; wird er lokal gelöscht, kann er nicht wiederhergestellt werden.</p><p>Unser Server ist in Deutschland stationiert.</p><4><strong>Kontakt:</strong><br/>Markus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Deutschland<br/>+49 211 81-14690<br/><14>Kontaktdetails</14></4>",
"home": "",
"close language menu": "Sprachmenü schließen",
"open language menu": "Sprachmenü öffnen",
"close menu": "Menü schließen",
"open menu": "Menü öffnen",
"Editor Mode": "",
"Typewriter Mode": ""
}
+94
View File
@@ -0,0 +1,94 @@
{
"Tactics": "Tactics",
"Lean Game Server": "Lean Game Server",
"<p>Game rules determine if it is allowed to skip levels and if the games runs checks to only allow unlocked tactics and theorems in proofs.</p><1>Note: \"Unlocked\" tactics (or theorems) are determined by two things: The set of minimal tactics needed to solve a level, plus any tactics you unlocked in another level. That means if you unlock <1>simp</1> in a level, you can use it henceforth in any level.</1><p>The options are:</p>": "<p>Game rules determine if it is allowed to skip levels and if the games runs checks to only allow unlocked tactics and theorems in proofs.</p><1>Note: \"Unlocked\" tactics (or theorems) are determined by two things: The set of minimal tactics needed to solve a level, plus any tactics you unlocked in another level. That means if you unlock <1>simp</1> in a level, you can use it henceforth in any level.</1><p>The options are:</p>",
"Game Rules": "Game Rules",
"levels": "levels",
"tactics": "tactics",
"regular": "regular",
"relaxed": "relaxed",
"none": "none",
"Rules": "Rules",
"Intro": "Intro",
"Start": "Start",
"Next": "Next",
"Previous": "Previous",
"Editor mode is enforced!": "Editor mode is enforced!",
"Editor mode": "Editor mode",
"Typewriter mode": "Typewriter mode",
"Preferences": "Preferences",
"Game Info": "Game Info",
"Erase": "Erase",
"Download": "Download",
"Upload": "Upload",
"Home": "Home",
"back to games selection": "back to games selection",
"World": "World",
"Show more help!": "Show more help!",
"Goal": "Goal",
"Current Goal": "Current Goal",
"Objects": "Objects",
"Assumptions": "Assumptions",
"Further Goals": "Further Goals",
"No Goals": "No Goals",
"Loading goal…": "Loading goal…",
"Click somewhere in the Lean file to enable the infoview.": "Click somewhere in the Lean file to enable the infoview.",
"Waiting for Lean server to start…": "Waiting for Lean server to start…",
"Level completed! 🎉": "Level completed! 🎉",
"Level completed with warnings 🎭": "Level completed with warnings 🎭",
"Active Goal": "Active Goal",
"Crashed! Go to editor mode and fix your proof! Last server response:": "Crashed! Go to editor mode and fix your proof! Last server response:",
"Line": "Line",
"Character": "Character",
"Loading messages…": "Loading messages…",
"Execute": "Execute",
"Definitions": "Definitions",
"Theorems": "Theorems",
"Not unlocked yet": "Not unlocked yet",
"Not available in this level": "Not available in this level",
"A repository of learning games for the proof assistant <1>Lean</1> <i>(Lean 4)</i> and its mathematical library <5>mathlib</5>": "A repository of learning games for the proof assistant <1>Lean</1> <i>(Lean 4)</i> and its mathematical library <5>mathlib</5>",
"No Games loaded. Use <1>http://localhost:3000/#/g/local/FOLDER</1> to open a game directly from a local folder.": "No Games loaded. Use <1>http://localhost:3000/#/g/local/FOLDER</1> to open a game directly from a local folder.",
"<p>As this server runs lean on our university machines, it has a limited capacity. Our current estimate is about 70 simultaneous games. We hope to address and test this limitation better in the future.</p><1>Most aspects of the games and the infrastructure are still in development. Feel free to file a <1>GitHub Issue</1> about any problems you experience!</1>": "<p>As this server runs lean on our university machines, it has a limited capacity. Our current estimate is about 70 simultaneous games. We hope to address and test this limitation better in the future.</p><1>Most aspects of the games and the infrastructure are still in development. Feel free to file a <1>GitHub Issue</1> about any problems you experience!</1>",
"This server has been developed as part of the project <1>ADAM : Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität in Düsseldorf.": "This server has been developed as part of the project <1>ADAM : Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität in Düsseldorf.",
"Prerequisites": "Prerequisites",
"Worlds": "Worlds",
"Levels": "Levels",
"Language": "Language",
"Development notes": "Development notes",
"Adding new games": "Adding new games",
"Funding": "Funding",
"<p>Do you want to delete your saved progress irreversibly?</p><p>(This deletes your proofs and your collected inventory. Saves from other games are not deleted.)</p>": "<p>Do you want to delete your saved progress irreversibly?</p><p>(This deletes your proofs and your collected inventory. Saves from other games are not deleted.)</p>",
"Delete Progress?": "Delete Progress?",
"Delete": "Delete",
"Download & Delete": "Download & Delete",
"Cancel": "Cancel",
"Layout": "Layout",
"Always visible": "Always visible",
"Save my settings (in the browser store)": "Save my settings (in the browser store)",
"<p>Select a JSON file with the saved game progress to load your progress.</p><1><0>Warning:</0> This will delete your current game progress! Consider <2>downloading your current progress</2> first!</1>": "<p>Select a JSON file with the saved game progress to load your progress.</p><1><0>Warning:</0> This will delete your current game progress! Consider <2>downloading your current progress</2> first!</1>",
"Upload Saved Progress": "Upload Saved Progress",
"Load selected file": "Load selected file",
"Mobile": "Mobile",
"Auto": "Auto",
"Desktop": "Desktop",
"<0>If you are considering writing your own game, you should use the <1>GameSkeleton Github Repo</1> as a template and read <3>How to Create a Game</3>.</0><1>You can directly load your games into the server and play it using the correct URL. The <1>instructions above</1> 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.</1><p>Featured games on this page are added manually. Please get in contact and we'll happily add yours.</p>": "<0>If you are considering writing your own game, you should use the <1>GameSkeleton Github Repo</1> as a template and read <3>How to Create a Game</3>.</0><1>You can directly load your games into the server and play it using the correct URL. The <1>instructions above</1> 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.</1><p>Featured games on this page are added manually. Please get in contact and we'll happily add yours.</p>",
"Level": "Level",
"Introduction": "Introduction",
"Retry proof from here": "Retry proof from here",
"Retry": "Retry",
"Failed command": "Failed command",
"view the Lean game server on Github": "view the Lean game server on Github",
"Theorem": "Theorem",
"Impressum": "Impressum",
"Privacy Policy": "Privacy Policy",
"<0>Impressum</0><1><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></1><2><strong>Legal form:</strong><br/>The Heinrich Heine University Düsseldorf is a corporation under public law. It is legally represented by the Rector Prof. Dr. Anja Steinbeck. The responsible supervisory authority is the Ministry of Culture and Science of North Rhine-Westphalia, Völklinger Straße 49, 40221 Düsseldorf.</2><3><strong>VAT identification number:</strong><br/>according to §27a Sales Tax Act<br/>DE 811222416</3><4><0>Impressum HHU</0></4>": "<0>Impressum</0><1><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></1><2><strong>Legal form:</strong><br/>The Heinrich Heine University Düsseldorf is a corporation under public law. It is legally represented by the Rector Prof. Dr. Anja Steinbeck. The responsible supervisory authority is the Ministry of Culture and Science of North Rhine-Westphalia, Völklinger Straße 49, 40221 Düsseldorf.</2><3><strong>VAT identification number:</strong><br/>according to §27a Sales Tax Act<br/>DE 811222416</3><4><0>Impressum HHU</0></4>",
"<0>Progress saving</0><1>The game stores your progress in your local browser storage. If you delete it, your progress will be lost!<br/>Warning: In most browsers, deleting cookies will also clear the local storage (or \"local site data\"). Make sure to download your game progress first!</1><2>Development</2><3>The game engine has been created by <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>. On a prototype by <strong>Patrick Massot</strong>.</3><4>The source code of this Lean game engine is <1>available on Github</1>. If you experience any problems, please file an <3>Issue on Github</3> or get directly in contact.</4><5>Funding</5><6>The game engine has been developed as part of the project <1>ADAM: Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität Düsseldorf. It is funded by the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.</6>": "<0>Progress saving</0><1>The game stores your progress in your local browser storage. If you delete it, your progress will be lost!<br/>Warning: In most browsers, deleting cookies will also clear the local storage (or \"local site data\"). Make sure to download your game progress first!</1><2>Development</2><3>The game engine has been created by <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>. On a prototype by <strong>Patrick Massot</strong>.</3><4>The source code of this Lean game engine is <1>available on Github</1>. If you experience any problems, please file an <3>Issue on Github</3> or get directly in contact.</4><5>Funding</5><6>The game engine has been developed as part of the project <1>ADAM: Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität Düsseldorf. It is funded by the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.</6>",
"<0>Privacy Policy</0><p>Our server collects metadata (such as IP address, browser, operating system) and the data that the user enters into the editor. The data is used to compute the Lean output and display it to the user. The information will be stored as long as the user stays on our website and will be deleted immediately afterwards. We keep logs to improve our software, but the contained data is anonymized.</p><p>We do not use cookies, but your game progress is stored in the browser as site data. Your game progress is not saved on the server; if you delete your browser storage, it is completely gone.</p><p>Our server is located in Germany.</p><4><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></4>": "<0>Privacy Policy</0><p>Our server collects metadata (such as IP address, browser, operating system) and the data that the user enters into the editor. The data is used to compute the Lean output and display it to the user. The information will be stored as long as the user stays on our website and will be deleted immediately afterwards. We keep logs to improve our software, but the contained data is anonymized.</p><p>We do not use cookies, but your game progress is stored in the browser as site data. Your game progress is not saved on the server; if you delete your browser storage, it is completely gone.</p><p>Our server is located in Germany.</p><4><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></4>",
"home": "home",
"close language menu": "close language menu",
"open language menu": "open language menu",
"close menu": "close menu",
"open menu": "open menu",
"Editor Mode": "Editor Mode",
"Typewriter Mode": "Typewriter Mode"
}
+95
View File
@@ -0,0 +1,95 @@
{
"Intro": "",
"Game Introduction": "",
"World selection": "",
"Start": "",
"Inventory": "",
"next level": "",
"Next": "",
"back to world selection": "",
"Leave World": "",
"previous level": "",
"Previous": "",
"Editor mode is enforced!": "",
"Editor mode": "",
"Typewriter mode": "",
"information, Impressum, privacy policy": "",
"Preferences": "",
"Game Info & Credits": "",
"Game Info": "",
"Clear Progress": "",
"Erase": "",
"Download Progress": "",
"Download": "",
"Load Progress from JSON": "",
"Upload": "",
"Home": "",
"back to games selection": "",
"close inventory": "",
"show inventory": "",
"World": "",
"Show more help!": "",
"Goal": "",
"Objects": "",
"Assumptions": "",
"Current Goal": "",
"Further Goals": "",
"No Goals": "",
"Loading goal…": "",
"Click somewhere in the Lean file to enable the infoview.": "",
"Waiting for Lean server to start…": "",
"Level completed! 🎉": "",
"Level completed with warnings 🎭": "",
"Failed command": "",
"Retry proof from here": "",
"Retry": "",
"Active Goal": "",
"Crashed! Go to editor mode and fix your proof! Last server response:": "",
"Line": "",
"Character": "",
"Loading messages…": "",
"Execute": "",
"Tactics": "",
"Definitions": "",
"Theorems": "",
"Not unlocked yet": "",
"Not available in this level": "",
"A repository of learning games for the proof assistant <1>Lean</1> <i>(Lean 4)</i> and its mathematical library <5>mathlib</5>": "",
"No Games loaded. Use <1>http://localhost:3000/#/g/local/FOLDER</1> to open a game directly from a local folder.": "",
"<p>As this server runs lean on our university machines, it has a limited capacity. Our current estimate is about 70 simultaneous games. We hope to address and test this limitation better in the future.</p><1>Most aspects of the games and the infrastructure are still in development. Feel free to file a <1>GitHub Issue</1> about any problems you experience!</1>": "",
"<0>If you are considering writing your own game, you should use the <1>GameSkeleton Github Repo</1> as a template and read <3>How to Create a Game</3>.</0><1>You can directly load your games into the server and play it using the correct URL. The <1>instructions above</1> 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.</1><p>Featured games on this page are added manually. Please get in contact and we'll happily add yours.</p>": "",
"This server has been developed as part of the project <1>ADAM : Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität in Düsseldorf.": "",
"view the Lean game server on Github": "",
"Prerequisites": "",
"Worlds": "",
"Levels": "",
"Language": "",
"Lean Game Server": "",
"Development notes": "",
"Adding new games": "",
"Funding": "",
"Level": "",
"Introduction": "",
"<p>Do you want to delete your saved progress irreversibly?</p><p>(This deletes your proofs and your collected inventory. Saves from other games are not deleted.)</p>": "",
"Delete Progress?": "",
"Delete": "",
"Download & Delete": "",
"Cancel": "",
"Mobile": "",
"Auto": "",
"Desktop": "",
"Layout": "",
"Always visible": "",
"Save my settings (in the browser store)": "",
"<p>Game rules determine if it is allowed to skip levels and if the games runs checks to only allow unlocked tactics and theorems in proofs.</p><1>Note: \"Unlocked\" tactics (or theorems) are determined by two things: The set of minimal tactics needed to solve a level, plus any tactics you unlocked in another level. That means if you unlock <1>simp</1> in a level, you can use it henceforth in any level.</1><p>The options are:</p>": "",
"Game Rules": "",
"levels": "",
"tactics": "",
"regular": "",
"relaxed": "",
"none": "",
"<p>Select a JSON file with the saved game progress to load your progress.</p><1><0>Warning:</0> This will delete your current game progress! Consider <2>downloading your current progress</2> first!</1>": "",
"Upload Saved Progress": "",
"Load selected file": "",
"Rules": ""
}
+100
View File
@@ -0,0 +1,100 @@
{
"Tactics": "策略",
"Lean Game Server": "Lean游戏服务器",
"<p>Game rules determine if it is allowed to skip levels and if the games runs checks to only allow unlocked tactics and theorems in proofs.</p><1>Note: \"Unlocked\" tactics (or theorems) are determined by two things: The set of minimal tactics needed to solve a level, plus any tactics you unlocked in another level. That means if you unlock <1>simp</1> in a level, you can use it henceforth in any level.</1><p>The options are:</p>": "<p>游戏规则决定是否允许跳过关卡,以及游戏是否允许在证明中使用未解锁的策略和定理。<p><1>注意:“解锁”的策略(或定理)由两个因素决定:解决关卡所需的最小策略集合,加上你在另一个关卡中解锁的任何策略。这意味着,如果你在某个关卡中解锁了<1>simp</1>,那么你可以在任何关卡中使用它。</1><p>选项是:</p>",
"Game Rules": "游戏规则",
"levels": "关卡",
"tactics": "策略",
"regular": "标准",
"relaxed": "休闲",
"none": "自由",
"Rules": "规则",
"Intro": "介绍",
"Game Introduction": "游戏介绍",
"World selection": "世界选择",
"Start": "开始",
"Inventory": "定理清单",
"next level": "下一关",
"Next": "下一关",
"back to world selection": "返回世界选择",
"Leave World": "离开世界",
"previous level": "上一关",
"Previous": "上一关",
"Editor mode is enforced!": "编辑器模式开启!",
"Editor mode": "编辑器模式",
"Typewriter mode": "打字机模式",
"Preferences": "偏好设置",
"Game Info & Credits": "游戏信息和荣誉",
"Game Info": "游戏信息",
"Clear Progress": "清除进度",
"Erase": "删除",
"Download Progress": "下载进度",
"Download": "下载",
"Load Progress from JSON": "从 JSON 加载进度",
"Upload": "上传",
"Home": "首页",
"back to games selection": "返回游戏选择",
"close inventory": "关闭定理清单面板",
"show inventory": "打开定理清单面板",
"World": "世界",
"Show more help!": "显示更多帮助!",
"Goal": "目标",
"Current Goal": "当前目标",
"Objects": "对象",
"Assumptions": "假设",
"Further Goals": "Further Goals",
"No Goals": "无目标",
"Loading goal…": "加载目标。。。",
"Click somewhere in the Lean file to enable the infoview.": "单击Lean文件中的某处以启用信息视图。",
"Waiting for Lean server to start…": "等待 Lean 服务器启动。。。",
"Level completed! 🎉": "完成关卡!🎉",
"Level completed with warnings 🎭": "关卡完成(带有警告) 🎭",
"Retry proof from here": "从这里重新试着证明",
"Active Goal": "当前目标",
"Crashed! Go to editor mode and fix your proof! Last server response:": "程序崩溃!请转到编辑器模式,修复您的证明!最后一次服务器响应:",
"Line": "行",
"Character": "字符",
"Loading messages…": "正在加载信息。。。",
"Execute": "执行",
"Definitions": "定义",
"Theorems": "定理",
"Theorem": "定理",
"Not unlocked yet": "尚未解锁",
"Not available in this level": "本关卡不提供",
"A repository of learning games for the proof assistant <1>Lean</1> <i>(Lean 4)</i> and its mathematical library <5>mathlib</5>": "一个用于<1>Lean</1> <i>(Lean 4)</i>及其数学库<5>mathlib</5>的学习游戏库",
"No Games loaded. Use <1>http://localhost:3000/#/g/local/FOLDER</1> to open a game directly from a local folder.": "没有加载游戏。使用<1>http://localhost:3000/#/g/local/FOLDER</1>直接从本地文件夹打开游戏。",
"<p>As this server runs lean on our university machines, it has a limited capacity. Our current estimate is about 70 simultaneous games. We hope to address and test this limitation better in the future.</p><1>Most aspects of the games and the infrastructure are still in development. Feel free to file a <1>GitHub Issue</1> about any problems you experience!</1>": "<p>由于这个服务器在我们大学的机器上运行Lean,它的容量是有限的。我们当前的估计是大约70个同时进行的游戏。我们希望将来能更好地解决和测试这个限制。</p><1>游戏和基础设施的大多数方面仍在开发中。遇到任何问题,请随时提交<1>GitHub Issue</1>!</1>",
"<0>If you are considering writing your own game, you should use the <1>GameSkeleton Github Repo</1> as a template and read <3>How to Create a Game</3>.</0><1>You can directly load your games into the server and play it using the correct URL. The <1>instructions above</1> 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.</1><p>Featured games on this page are added manually. Please get in contact and we'll happily add yours.</p>": "<0>如果您想编写自己的游戏,您应该使用<1>GameSkeleton Github Repo</1>作为模板,并阅读<3>如何创建游戏</3>。</0><1>您可以直接将游戏加载到服务器上并使用正确的URL进行游戏。上面的<1>说明</1>也解释了如何将游戏加载到服务器的详细步骤。如果您有任何疑问,请您与我们联系。</1><p>本页面上的游戏是手动添加的。请与我们联系,我们很乐意添加您的游戏。</p>",
"This server has been developed as part of the project <1>ADAM : Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität in Düsseldorf.": "这个服务器是作为杜塞尔多夫海因里希·海涅大学项目<1>ADAM:预见数学的数字时代</1>的一部分而开发的。",
"Prerequisites": "前置条件",
"Worlds": "世界(Worlds)",
"Levels": "关卡",
"Language": "语言",
"Development notes": "开发信息",
"Adding new games": "添加新游戏",
"Funding": "赞助",
"<p>Do you want to delete your saved progress irreversibly?</p><p>(This deletes your proofs and your collected inventory. Saves from other games are not deleted.)</p>": "<p>您是否想要不可逆转地删除您的游戏进度?</p><p>(这将删除您的证明和您收集的定理和策略。其他游戏的进度不会被删除。)</p>",
"Delete Progress?": "删除进度?",
"Delete": "删除",
"Download & Delete": "下载和删除",
"Cancel": "取消",
"Layout": "布局",
"Always visible": "始终可见",
"Save my settings (in the browser store)": "保存我的设置(在浏览器商店中)",
"<p>Select a JSON file with the saved game progress to load your progress.</p><1><0>Warning:</0> This will delete your current game progress! Consider <2>downloading your current progress</2> first!</1>": "<p>选择一个包含已保存游戏进度的JSON文件来加载您的进度。</p><1><0>警告:</0>这将删除您当前的游戏进度!首先考虑<2>下载您当前的进度</2>!</1>",
"Upload Saved Progress": "上传保存的进度",
"Load selected file": "加载所选文件",
"Mobile": "移动端",
"Auto": "自动",
"Desktop": "桌面端",
"Level": "关卡",
"Introduction": "介绍",
"Retry": "重试",
"Failed command": "命令失败",
"view the Lean game server on Github": "",
"Impressum": "版权声明",
"Privacy Policy": "隐私政策",
"<0>Impressum</0><1><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></1><2><strong>Legal form:</strong><br/>The Heinrich Heine University Düsseldorf is a corporation under public law. It is legally represented by the Rector Prof. Dr. Anja Steinbeck. The responsible supervisory authority is the Ministry of Culture and Science of North Rhine-Westphalia, Völklinger Straße 49, 40221 Düsseldorf.</2><3><strong>VAT identification number:</strong><br/>according to §27a Sales Tax Act<br/>DE 811222416</3><4><0>Impressum HHU</0></4>": "",
"<0>Progress saving</0><1>The game stores your progress in your local browser storage. If you delete it, your progress will be lost!<br/>Warning: In most browsers, deleting cookies will also clear the local storage (or \"local site data\"). Make sure to download your game progress first!</1><2>Development</2><3>The game engine has been created by <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>. On a prototype by <strong>Patrick Massot</strong>.</3><4>The source code of this Lean game engine is <1>available on Github</1>. If you experience any problems, please file an <3>Issue on Github</3> or get directly in contact.</4><5>Funding</5><6>The game engine has been developed as part of the project <1>ADAM: Anticipating the Digital Age of Mathematics</1> at Heinrich-Heine-Universität Düsseldorf. It is funded by the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.</6>": "",
"<0>Privacy Policy</0><p>Our server collects metadata (such as IP address, browser, operating system) and the data that the user enters into the editor. The data is used to compute the Lean output and display it to the user. The information will be stored as long as the user stays on our website and will be deleted immediately afterwards. We keep logs to improve our software, but the contained data is anonymized.</p><p>We do not use cookies, but your game progress is stored in the browser as site data. Your game progress is not saved on the server; if you delete your browser storage, it is completely gone.</p><p>Our server is located in Germany.</p><4><strong>Contact:</strong><br/>Marcus Zibrowius, Jon Eugster<br/>Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br/>Universitätsstr. 1<br/>40225 Düsseldorf<br/>Germany<br/>+49 211 81-14690<br/><14>Contact Details</14></4>": ""
}
+84 -22
View File
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Outlet, useParams } from "react-router-dom"; import { Outlet, useParams, useSearchParams } from "react-router-dom";
import { Suspense, useEffect, useState } from 'react';
import '@fontsource/roboto/300.css'; import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css'; import '@fontsource/roboto/400.css';
@@ -8,40 +9,101 @@ import '@fontsource/roboto/700.css';
import './css/reset.css'; import './css/reset.css';
import './css/app.css'; import './css/app.css';
import { MobileContext } from './components/infoview/context'; import { GameIdContext, PageContext, PreferencesContext} from './state/context';
import { useMobile } from './hooks'; import UsePreferences from "./state/hooks/use_preferences"
import { AUTO_SWITCH_THRESHOLD, getWindowDimensions} from './state/preferences'; import { Navigation } from './components/navigation';
import { useSelector } from 'react-redux';
export const GameIdContext = React.createContext<string>(undefined); import { changeTypewriterMode, selectReadIntro, selectTypewriterMode } from './state/progress';
import { useAppDispatch } from './hooks';
import { Popup, PopupContext } from './components/popup/popup';
import { useGetGameInfoQuery } from './state/api';
import lean4gameConfig from './config.json'
import { useTranslation } from 'react-i18next';
function App() { function App() {
const { mobile, setMobile, lockMobile, setLockMobile } = useMobile(); let { t, i18n } = useTranslation()
const params = useParams() const params = useParams()
const gameId = "g/" + params.owner + "/" + params.repo const gameId = (params.owner && params.repo) ? "g/" + params.owner + "/" + params.repo : null
const worldId = params.worldId
const levelId = parseInt(params.levelId)
const automaticallyAdjustLayout = () => { const {mobile, layout, isSavePreferences, language, setLayout, setIsSavePreferences, setLanguage} = UsePreferences()
const {width} = getWindowDimensions()
setMobile(width < AUTO_SWITCH_THRESHOLD)
}
React.useEffect(()=>{ const dispatch = useAppDispatch()
if (!lockMobile){ const typewriterMode = useSelector(selectTypewriterMode(gameId))
void automaticallyAdjustLayout() const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode}))
window.addEventListener('resize', automaticallyAdjustLayout) const [lockEditorMode, setLockEditorMode] = useState(false)
const [typewriterInput, setTypewriterInput] = useState("")
const [page, setPage] = useState(0)
const [popupContent, setPopupContent] = useState(null)
const gameInfo = useGetGameInfoQuery({game: gameId})
const [searchParams, setSearchParams] = useSearchParams()
return () => { const readIntro = useSelector(selectReadIntro(gameId, worldId))
window.removeEventListener('resize', automaticallyAdjustLayout)
// mobile only: game intro should only be shown once and skipped afterwards
useEffect(() => {
if (worldId) {
console.log('setting page to 1')
setPage(1)
} else {
if (readIntro && page == 0) {
console.log('setting page to 1')
setPage(1)
} else {
// setPage(0)
} }
} }
}, [lockMobile]) }, [worldId, levelId])
// option to pass language as `?lang=de` in the URL
useEffect(() => {
let urlLang = searchParams.get("lang")
let availableLangs = gameInfo.data?.tile?.languages
if (gameId) {
if (availableLangs?.includes(urlLang)) {
setLanguage(urlLang)
// Delete the search param as we processed it.
searchParams.delete('lang')
setSearchParams(searchParams)
}
} else {
if (urlLang in lean4gameConfig.newLanguages) {
setLanguage(urlLang)
// Delete the search param as we processed it.
searchParams.delete('lang')
setSearchParams(searchParams)
}
}
}, [gameId, gameInfo.data?.tile?.languages])
// set the correct language
useEffect(() => {
let availableLangs = gameInfo.data?.tile?.languages
if (gameId && availableLangs?.length > 0 && !(availableLangs.includes(language))) {
// if the game is not available in the preferred language, display it in the original
// language
console.debug(`using default language: ${availableLangs[0]}`)
i18n.changeLanguage(availableLangs[0])
} else {
console.debug(`using language: ${language}`)
i18n.changeLanguage(language)
}
}, [gameId, gameInfo.data?.tile?.languages, language])
return ( return (
<div className="app"> <div className="app">
<GameIdContext.Provider value={gameId}> <GameIdContext.Provider value={{gameId, worldId, levelId}}>
<MobileContext.Provider value={{mobile, setMobile, lockMobile, setLockMobile}}> <PreferencesContext.Provider value={{mobile, layout, isSavePreferences, language, setLayout, setIsSavePreferences, setLanguage}}>
<PopupContext.Provider value={{popupContent, setPopupContent}}>
<PageContext.Provider value={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput, lockEditorMode, setLockEditorMode, page, setPage}}>
<Navigation />
<Outlet /> <Outlet />
</MobileContext.Provider> { popupContent && <Popup /> }
</PageContext.Provider>
</PopupContext.Provider>
</PreferencesContext.Provider>
</GameIdContext.Provider> </GameIdContext.Provider>
</div> </div>
) )
-260
View File
@@ -1,260 +0,0 @@
/**
* @file contains the navigation bars of the app.
*/
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, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from "../app"
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
import { GameInfo, useGetGameInfoQuery } from '../state/api'
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
import { useAppDispatch, useAppSelector } from '../hooks'
import { Button } from './button'
import { downloadProgress } from './popup/erase'
/** navigation buttons for mobile welcome page to switch between intro/tree/inventory. */
function MobileNavButtons({pageNumber, setPageNumber}:
{ pageNumber: number,
setPageNumber: any}) {
const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
// if `prevText` or `prevIcon` is set, show a button to go back
let prevText = {0: null, 1: "Intro", 2: null}[pageNumber]
let prevIcon = {0: null, 1: null, 2: faBookOpen}[pageNumber]
let prevTitle = {0: null, 1: "Game Introduction", 2: "World selection"}[pageNumber]
// if `nextText` or `nextIcon` is set, show a button to go forward
let nextText = {0: "Start", 1: null, 2: null}[pageNumber]
let nextIcon = {0: null, 1: faBook, 2: null}[pageNumber]
let nextTitle = {0: "World selection", 1: "Inventory", 2: null}[pageNumber]
return <>
{(prevText || prevIcon) &&
<Button className="btn btn-inverted toggle-width" to={pageNumber == 0 ? "/" : ""}
inverted="true" title={prevTitle}
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
{prevText && `${prevText}`}
</Button>
}
{(nextText || nextIcon) &&
<Button className="btn btn-inverted toggle-width" to="" inverted="true"
title={nextTitle} onClick={() => {
console.log(`page number: ${pageNumber}`)
setPageNumber(pageNumber+1);
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
{nextText && `${nextText}`}
{nextIcon && <FontAwesomeIcon icon={nextIcon} />}
</Button>
}
</>
}
/** button to toggle dropdown menu. */
function MenuButton({navOpen, setNavOpen}) {
return <Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}}>
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
</Button>
}
/** button to go one level futher.
* for the last level, this button turns into a button going back to the welcome page.
*/
function NextButton({worldSize, difficulty, completed, setNavOpen}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
return (levelId < worldSize ?
<Button inverted="true"
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
disabled={difficulty >= 2 && !(completed || levelId == 0)}
onClick={() => setNavOpen(false)}>
<FontAwesomeIcon icon={faArrowRight} />&nbsp;{levelId ? "Next" : "Start"}
</Button>
:
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button>
)
}
/** button to go one level back.
* only renders if the current level id is > 0.
*/
function PreviousButton({setNavOpen}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
return (levelId > 0 && <>
<Button disabled={levelId <= 0} inverted="true"
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
title="previous level"
onClick={() => setNavOpen(false)}>
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;Previous
</Button>
</>)
}
/** button to toggle between editor and typewriter */
function InputModeButton({setNavOpen, isDropdown}) {
const {levelId} = React.useContext(WorldLevelIdContext)
const {typewriterMode, setTypewriterMode, lockInputMode} = React.useContext(InputModeContext)
/** toggle input mode if allowed */
function toggleInputMode(ev: React.MouseEvent) {
if (!lockInputMode){
setTypewriterMode(!typewriterMode)
setNavOpen(false)
}
}
return <Button
className={`btn btn-inverted ${isDropdown? '' : 'toggle-width'}`} disabled={levelId <= 0 || lockInputMode}
inverted="true" to=""
onClick={(ev) => toggleInputMode(ev)}
title={lockInputMode ? "Editor mode is enforced!" : typewriterMode ? "Editor mode" : "Typewriter mode"}>
<FontAwesomeIcon icon={typewriterMode ? faCode : faTerminal} />
{isDropdown && (typewriterMode ? <>&nbsp;Editor mode</> : <>&nbsp;Typewriter mode</>)}
</Button>
}
/** button to toggle iimpressum popup */
function ImpressumButton({setNavOpen, toggleImpressum, isDropdown}) {
return <Button className="btn btn-inverted toggle-width"
title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />
{isDropdown && <>&nbsp;Info &amp; Impressum</>}
</Button>
}
/** button to go back to welcome page */
function HomeButton({isDropdown}) {
const gameId = React.useContext(GameIdContext)
return <Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />
{isDropdown && <>&nbsp;Home</>}
</Button>
}
/** button in mobile level to toggle inventory.
* only displays a button if `setPageNumber` is set.
*/
function InventoryButton({pageNumber, setPageNumber}) {
return (setPageNumber &&
<Button to="" className="btn btn-inverted toggle-width"
title={pageNumber ? "close inventory" : "show inventory"}
inverted="true" onClick={() => {setPageNumber(pageNumber ? 0 : 1)}}>
<FontAwesomeIcon icon={pageNumber ? faBookOpen : faBook} />
</Button>
)
}
/** the navigation bar on the welcome page */
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,
togglePreferencesPopup: () => void;
}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useAppSelector(selectProgress(gameId))
const {mobile, setMobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
return <div className="app-bar">
<div className='app-bar-left'>
<Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
<span className="app-bar-title"></span>
</div>
<div>
{!mobile && <span className="app-bar-title">{gameInfo?.title}</span>}
</div>
<div className="nav-btns">
{mobile && <MobileNavButtons pageNumber={pageNumber} setPageNumber={setPageNumber} />}
<MenuButton navOpen={navOpen} setNavOpen={setNavOpen} />
</div>
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
<Button title="Game Info & Credits" inverted="true" to="" onClick={() => {toggleInfo(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Game Info
</Button>
<Button title="Clear Progress" inverted="true" to="" onClick={() => {toggleEraseMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faEraser} />&nbsp;Erase
</Button>
<Button title="Download Progress" inverted="true" to="" onClick={(ev) => {downloadProgress(gameId, gameProgress, ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faDownload} />&nbsp;Download
</Button>
<Button title="Load Progress from JSON" inverted="true" to="" onClick={() => {toggleUploadMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faUpload} />&nbsp;Upload
</Button>
<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>
}
/** the navigation bar in a level */
export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=undefined, setPageNumber=undefined} : {
isLoading: boolean,
levelTitle: string,
toggleImpressum: any,
pageNumber?: number,
setPageNumber?: any,
}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
const gameInfo = useGetGameInfoQuery({game: gameId})
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const difficulty = useAppSelector(selectDifficulty(gameId))
let worldTitle = gameInfo.data?.worlds.nodes[worldId].title
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
{mobile ?
<>
{/* MOBILE VERSION */}
<div>
<span className="app-bar-title">{levelTitle}</span>
</div>
<div className="nav-btns">
<InventoryButton pageNumber={pageNumber} setPageNumber={setPageNumber}/>
<MenuButton navOpen={navOpen} setNavOpen={setNavOpen}/>
</div>
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
<NextButton worldSize={gameInfo.data?.worldSize[worldId]} difficulty={difficulty} completed={completed} setNavOpen={setNavOpen} />
<PreviousButton setNavOpen={setNavOpen} />
<HomeButton isDropdown={true} />
<InputModeButton setNavOpen={setNavOpen} isDropdown={true}/>
<ImpressumButton setNavOpen={setNavOpen} toggleImpressum={toggleImpressum} isDropdown={true} />
</div>
</> :
<>
{/* DESKTOP VERSION */}
<div className='app-bar-left'>
<HomeButton isDropdown={false} />
<span className="app-bar-title">{worldTitle && `World: ${worldTitle}`}</span>
</div>
<div>
<span className="app-bar-title">{levelTitle}</span>
</div>
<div className="nav-btns">
<PreviousButton setNavOpen={setNavOpen} />
<NextButton worldSize={gameInfo.data?.worldSize[worldId]} difficulty={difficulty} completed={completed} setNavOpen={setNavOpen} />
<InputModeButton setNavOpen={setNavOpen} isDropdown={false}/>
<ImpressumButton setNavOpen={setNavOpen} toggleImpressum={toggleImpressum} isDropdown={false} />
</div>
</>
}
</div>
}
-15
View File
@@ -1,15 +0,0 @@
import * as React from 'react';
import { Link, LinkProps } from "react-router-dom";
export interface ButtonProps extends LinkProps {
disabled?: boolean
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
}
export function Button(props: ButtonProps) {
if (props.disabled) {
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
} else {
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
}
}
+450
View File
@@ -0,0 +1,450 @@
import * as React from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import { useSelector } from 'react-redux'
import { useTranslation } from 'react-i18next'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { changedReadIntro, selectCompleted, selectReadIntro } from '../state/progress'
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
import { useAppDispatch, useAppSelector } from '../hooks'
import { Button, Markdown } from './utils'
import { ChatContext, GameIdContext, PageContext, PreferencesContext, ProofContext } from '../state/context'
import { GameHint, InteractiveGoalsWithHints } from './infoview/rpc_api'
// import { lastStepHasErrors } from './infoview/goals'
// import { AllMessages } from '../../../node_modules/@leanprover/infoview/dist/infoview/messages'
// import { LeanDiagnostic, RpcErrorCode, getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api'
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
// import { InteractiveMessage } from '../../../node_modules/lean4-infoview/src/infoview/traceExplorer'
import '../css/chat.css'
import { faHome } from '@fortawesome/free-solid-svg-icons'
/** Split a string by double newlines and filters out empty segments. */
function splitIntro (intro : string) {
return intro.split(/\n(\s*\n)+/).filter(t => t.trim())
}
/** Helper to check if a step has any hidden hints. */
function hasHiddenHints(step: InteractiveGoalsWithHints): boolean {
return step?.goals[0]?.hints.some((hint) => hint.hidden)
}
/** Button which only appears if the current step has hidden hints that are not shown yet. */
export function MoreHelpButton({selected=null} : {selected?: number}) {
const { t } = useTranslation()
const { proof } = React.useContext(ProofContext)
const { showHelp, setShowHelp } = React.useContext(ChatContext)
let k = 0
// let k = proof?.steps.length ?
// ((selected === null) ? (proof?.steps.length - (lastStepHasErrors(proof) ? 2 : 1)) : selected)
// : 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?.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}>
{t("Show more help!")}
</Button>
}
return <></>
}
/** Placeholder that takes the same space as a button. */
function ButtonPlaceholder() {
return <div className='btn-placeholder'/>
}
/** The buttons at the bottom of chat. */
export function ChatButtons ({counter=undefined, setCounter=()=>{}, introMessages=[]} : {
counter?: number
setCounter?: React.Dispatch<React.SetStateAction<number>>
introMessages?: GameHintWithStep[]
}) {
let { t } = useTranslation()
const { mobile } = useContext(PreferencesContext)
const { gameId, worldId, levelId } = useContext(GameIdContext)
const {setPage} = useContext(PageContext)
const dispatch = useAppDispatch()
const gameInfo = useGetGameInfoQuery({game: gameId})
const { proof } = useContext(ProofContext)
const readIntro = useSelector(selectReadIntro(gameId, worldId))
return <div className="button-row">
{!levelId && (readIntro || (counter >= introMessages.length) ?
((worldId || mobile) &&
<>
<ButtonPlaceholder />
<Button className="btn"
title=""
to={worldId ? `/${gameId}/world/${worldId}/level/1` : ''}
onClick={() => {
if (!worldId) {
console.log('setting `readIntro` to true')
setPage(1)
}
}} >
Start&nbsp;<FontAwesomeIcon icon={faArrowRight}/>
</Button>
</>
)
:
<>
<Button className="btn"
title=""
to=""
onClick={() => {
if (counter + 1 >= introMessages.length) {
dispatch(changedReadIntro({game: gameId, world: worldId, readIntro: true}))
}
setCounter(counter + 1)
}} >
{"Read"}
</Button>
<Button className="btn"
title=""
to=""
onClick={() => {
dispatch(changedReadIntro({game: gameId, world: worldId, readIntro: true}))
setCounter(introMessages.length)
}} >
Skip all
</Button>
</>
)}
{ (worldId && levelId) ? <MoreHelpButton /> : <></> }
{ (worldId && levelId && proof?.completed) ?
(levelId == gameInfo.data?.worldSize[worldId] ?
<Button className="btn"
title=""
to={`/${gameId}`} >
<FontAwesomeIcon icon={faHome} />&nbsp;{t("Home")}
</Button> :
<Button className="btn"
title=""
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} >
{t("Next")}&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>
) : <></> }
</div>
}
/** Insert 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 {
const {gameId} = React.useContext(GameIdContext)
let { t } = useTranslation()
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 t(hint.rawText, {ns: gameId}).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 t(hint.text, {ns: gameId})
}
}
/** Hint kinds. Note that number 1-4 are matching the numbers from `DiagnosticSeverity`
* from the vscode language server protocol.
*/
enum HintKind {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
GameHint = 5,
Conclusion = 7,
}
/** Bundling a hint with the proof-step it comes from. */
type GameHintWithStep = {
hint: GameHint
kind: HintKind
step?: number
}
/** 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.filter((hint) => !hint.hidden), ...hints.filter((hint) => hint.hidden)]
} else {
return [...hints.filter((hint) => !hint.hidden &&
(prevHints.find(x => (x.text == hint.text && x.hidden == hint.hidden)) === undefined)
), ...hints.filter((hint) => hint.hidden)]
}
}
// TODO
// function helper(step, proof, kind, typewriterMode, selectedStep) {
// return (step == proof?.steps?.length - (lastStepHasErrors(proof) ? 2 : 1) ? ' recent' : '') +
// (!(kind == HintKind.Conclusion) && step >= (typewriterMode ? proof?.steps?.length : selectedStep+1) ? ' deleted-hint' : '')
// }
/** A hint as it is displayed in the chat. */
export function Hint({hint, kind, step=null} : GameHintWithStep) {
const { levelId } = useContext(GameIdContext)
const { selectedStep, setSelectedStep } = useContext(ChatContext)
const { proof } = useContext(ProofContext)
const { typewriterMode } = useContext(PageContext)
function toggleSelection () {
if (!levelId) {return}
if (selectedStep !== null && selectedStep == step) {
setSelectedStep(null)
} else if (step < proof?.steps?.length) {
setSelectedStep(step)
}
}
// "Deleted hints" are marked in grey. They are used two-fold:
// In typewriter, deleting parts of the proof stores the removed hints as `deletedChat`
// until the next command is submitted; in editor, moving the cursor through the proof will
// render all hints
return <div className={`message kind-${kind} step-${step}` +
((selectedStep !== null && step == selectedStep) ? ' selected' : '') //+ helper(step, proof, kind, typewriterMode, selectedStep)
} onClick={toggleSelection}>
<Markdown>{getHintText(hint)}</Markdown>
</div>
}
/** A collection of hints. If the `counter` is defined, only the first elements will be
* shown up to the value of the `counter`.
*
* Set `conclusion` to true to trigger different style and disable selecting/deleting.
*/
export function Hints({ hints, counter=undefined } : {
hints: GameHintWithStep[],
counter?: number
}) {
const { showHelp } = useContext(ChatContext)
if (!hints) {
return <></>
}
// NOTE: This builds on the fact that `.slice(0, undefined)` returns the whole array!
// TODO: Should not use index as key.
return <>
{ hints.slice(0, counter).map((hint, j) =>
((!hint.hint.hidden || showHelp.has(hint.step)) &&
<Hint key={`hint-${hint.step}-${j}`} hint={hint.hint} kind={hint.kind} step={hint.step} />
)
)}
{/* { //showHelp.has(hint.step) &&
hints.filter(hint => hint.hint.hidden).map((hint, j) =>
<Hint
key={`hidden-hint-${hint.step}-${j}`}
hint={hint.hint}
step={hint.step}
conclusion={hint.conclusion} />
)} */}
</>
}
/** the panel showing the game's introduction text */
export function ChatPanel ({visible = true}) {
let { t } = useTranslation()
const { mobile } = useContext(PreferencesContext)
const { gameId, worldId, levelId } = useContext(GameIdContext)
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const [counter, setCounter] = useState(1)
const [introText, setIntroText] = useState<Array<GameHintWithStep>>([])
const { chatRef, deletedChat, showHelp, selectedStep } = useContext(ChatContext)
const { proof } = useContext(ProofContext)
const readIntro = useSelector(selectReadIntro(gameId, worldId))
useEffect(() => {
setCounter(1)
}, [gameId, worldId, levelId])
// load and display the correct intro text
useEffect(() => {
if (levelId > 0) {
let introText = t(levelInfo.data?.introduction, {ns : gameId}).trim()
let introHint: GameHintWithStep = {hint: {text: introText, hidden: false, rawText: introText }, kind: HintKind.GameHint, step: 0}
// playable level: show the level's intro
if (levelInfo.data?.introduction) {
setIntroText([introHint])
// messages = messages.concat([introHint])
}
else {
setIntroText([])
}
} else {
if (worldId) {
let introText = t(gameInfo.data?.worlds.nodes[worldId].introduction, {ns: gameId}).trim()
let introHints: GameHintWithStep[] = splitIntro(introText).map( txt => ({hint: {text: txt, hidden: false, rawText: txt }, kind: HintKind.GameHint, step: 0}))
// Level 0: show the world's intro
if (gameInfo.data?.worlds.nodes[worldId].introduction) {
// messages = messages.concat(introHints)
setIntroText(introHints)
} else {
setIntroText([])
}
} else {
let introText = t(gameInfo.data?.introduction, {ns : gameId}).trim()
let introHints: GameHintWithStep[] = splitIntro(introText).map( txt => ({hint: {text: txt, hidden: false, rawText: txt }, kind: HintKind.GameHint, step: 0}))
// world overview: show the game's intro
if (gameInfo.data?.introduction) {
// messages = messages.concat(introHints)
setIntroText(introHints)
} else {
setIntroText([])
}
}
}
}, [gameInfo, levelInfo, gameId, worldId, levelId, proof])
// Scroll the chat
useEffect(() => {
if (levelId > 0) {
if (proof) {
if (proof?.completed) {
// proof currently completed: scroll down
console.debug('scroll chat: down to conclusion')
chatRef.current!.lastElementChild?.scrollIntoView({block: "center"})
} else {
// proof currently not completed: first message of last step
let lastStep = proof?.steps.length //- (lastStepHasErrors(proof) ? 2 : 1)
console.debug(`scroll chat: first message of step ${lastStep}`)
chatRef.current?.getElementsByClassName(`step-${lastStep}`)[0]?.scrollIntoView({block: "center"})
}
} else {
// no proof available: scroll to top.
console.debug(`scroll chat: top`)
chatRef.current!.scrollTo(0,0)
}
} else {
// introduction: scroll to last message
console.debug('scroll chat: down')
chatRef.current!.lastElementChild?.scrollIntoView({block: "center"})
}
}, [counter, introText, gameId, worldId, levelId])
// Scroll down when new hidden hints are triggered
useEffect(() => {
if (levelId > 0) {
let lastStep = proof?.steps.length //- (lastStepHasErrors(proof) ? 2 : 1)
if (showHelp.has(lastStep)) {
console.debug('scroll chat: down to hidden hints')
// TODO: last element of hidden hints?
chatRef.current!.lastElementChild?.scrollIntoView({block: "center"})
}
}
}, [showHelp, gameId, worldId, levelId])
// Scroll to corresponding messages if selected step changes
useEffect(() => {
if (levelId > 0 && selectedStep !== null) {
console.debug(`scroll chat: first message of selected step ${selectedStep}`)
chatRef.current?.getElementsByClassName(`step-${selectedStep}`)[0]?.scrollIntoView({block: "center"})
// Array.from(chatRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
// elem.scrollIntoView({block: "center"})
// })
}
}, [selectedStep, gameId, worldId, levelId])
/** TODO: What's the magic here? Only needed if diags are displayed in chat. */
function diagToString (diag) {
// Hide "unsolved goals" messages
let message;
if ("append" in diag.message && "text" in diag.message.append[0] &&
diag.message?.append[0].text === "unsolved goals") {
message = diag.message.append[0]
} else {
message = diag.message
}
return message
}
return <div className={`column chat-panel${visible ? '' : ' hidden'}`}>
<div ref={chatRef} className="chat" >
{ gameInfo.error &&
<div className="message error">
Could not find the game!
</div>
}
<Hints hints={introText} counter={readIntro ? undefined : counter}/>
{proof?.steps.map((step, i) => {
let x = [].concat(
filterHints(step.goals[0]?.hints, proof.steps[i-1]?.goals[0]?.hints).map(hint => ({hint: hint, kind: HintKind.GameHint, step: i})),
// // TODO: Uncomment this if you want to see the diags in chat
// step.diags.map(diag => ({hint: diagToString(diag), kind: diag.severity, step: i}))
)
return <Hints key={`hints-step-${i}`} hints={x}/>
})}
{/* <Hints hints={chatMessages}/> */}
{/* {proof?.steps.map((step, i) =>
<Hints hints={step.goals[0]?.hints.map(hint => ({hint: hint, step: i}))}/>
)} */}
{/* <Hints hints={proof?.steps[proof?.steps?.length - 1]?.goals[0].hints.map(hint => ({hint: hint, step: proof?.steps?.length - 1}))} /> */}
{ deletedChat &&
<Hints hints={deletedChat.map(hint => ({hint: hint, kind: HintKind.GameHint, step: proof?.steps?.length}))} />
}
{ completed && levelInfo.data?.conclusion &&
<Hints hints={[
{hint: {text: t("Level completed! 🎉"), rawText: t("Level completed! 🎉"), hidden: false}, kind: HintKind.Conclusion, step: proof?.steps?.length},
{hint: {text: levelInfo.data?.conclusion, rawText: levelInfo.data?.conclusion, hidden: false}, kind: HintKind.GameHint, step: proof?.steps?.length}
]} />
}
{/* {chatMessages.map(((t, i) =>
t.trim() ?
<Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false, rawText: t, varNames: []}}
step={0} />
: <></>
))} */}
</div>
{ <ChatButtons counter={counter} setCounter={setCounter} introMessages={introText}/> }
</div>
}
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileOverview Defines the interface for the communication with the server.
*
*/
import type { Range } from 'vscode-languageserver-protocol'
import type {
CodeWithInfos,
ContextInfo,
FVarId,
InteractiveDiagnostic,
MVarId,
TermInfo
} from '@leanprover/infoview-api'
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[]
fvarIds?: FVarId[]
type: CodeWithInfos
val?: CodeWithInfos
isInstance?: boolean
isType?: boolean
isInserted?: boolean
isRemoved?: boolean
isAssumption?: boolean
}
export interface InteractiveGoalCore {
hyps: InteractiveHypothesisBundle[]
type: CodeWithInfos
ctx?: ContextInfo
}
export interface InteractiveGoal extends InteractiveGoalCore {
userName?: string
goalPrefix?: string
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 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
}
+104
View File
@@ -0,0 +1,104 @@
import * as React from 'react';
import Split from 'react-split'
import { useContext, useEffect, useRef, useState } from 'react'
import { useTranslation } from "react-i18next"
import { GameIdContext, MonacoEditorContext } from '../../state/context';
import { useLoadLevelQuery } from '../../state/api';
import { Markdown } from '../utils';
import * as monaco from 'monaco-editor'
import { LeanMonaco, LeanMonacoEditor, LeanMonacoOptions } from 'lean4monaco'
import '../../css/editor.css'
import { useSelector } from 'react-redux';
import { selectTypewriterMode } from '../../state/progress';
import { TypewriterInterFace } from './Typewriter';
export function Editor() {
let { t } = useTranslation()
const {gameId, worldId, levelId } = useContext(GameIdContext)
const typewriterMode = useSelector(selectTypewriterMode(gameId))
const editorRef = useRef<HTMLDivElement>(null)
const infoviewRef = useRef<HTMLDivElement>(null)
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor>()
const [leanMonaco, setLeanMonaco] = useState<LeanMonaco>()
const [code, setCode] = useState<string>('')
const [options, setOptions] = useState<LeanMonacoOptions>({
// placeholder. gets set below
websocket: {
url: ''
}
})
// Update LeanMonaco options when preferences are loaded or change
useEffect(() => {
var socketUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") +
window.location.host + `/websocket/${gameId}`
console.log(`[LeanGame] socket url: ${socketUrl}`)
var _options: LeanMonacoOptions = {
websocket: {
url: socketUrl,
// @ts-ignore
difficulty: 1,
inventory: []
},
// Restrict monaco's extend (e.g. context menu) to the editor itself
htmlElement: editorRef.current ?? undefined,
vscode: {
/* To add settings here, you can open your settings in VSCode (Ctrl+,), search
* for the desired setting, select "Copy Setting as JSON" from the "More Actions"
* menu next to the selected setting, and paste the copied string here.
*/
// "workbench.colorTheme": preferences.theme,
"editor.tabSize": 2,
// "editor.rulers": [100],
"editor.lightbulb.enabled": "on",
"editor.wordWrap": "on",
"editor.wrappingStrategy": "advanced",
"editor.semanticHighlighting.enabled": true,
"editor.acceptSuggestionOnEnter": "off",
"lean4.input.eagerReplacementEnabled": true,
// "lean4.input.leader": preferences.abbreviationCharacter
}
}
setOptions(_options)
}, [editorRef])
// Setting up the editor and infoview
useEffect(() => {
console.debug('[LeanGame] Restarting Editor!')
var _leanMonaco = new LeanMonaco()
var leanMonacoEditor = new LeanMonacoEditor()
_leanMonaco.setInfoviewElement(infoviewRef.current!)
;(async () => {
await _leanMonaco.start(options)
console.warn('gameId', gameId)
await leanMonacoEditor.start(editorRef.current!, `/${worldId}/L_${levelId}.lean`, code)
setEditor(leanMonacoEditor.editor)
setLeanMonaco(_leanMonaco)
// Keeping the `code` state up-to-date with the changes in the editor
leanMonacoEditor.editor?.onDidChangeModelContent(() => {
setCode(leanMonacoEditor.editor?.getModel()?.getValue()!)
})
})()
return () => {
leanMonacoEditor.dispose()
_leanMonaco.dispose()
}
}, [options, infoviewRef, editorRef, gameId, worldId, levelId])
return <MonacoEditorContext.Provider value={editor}>
<div className="editor-wrapper"><Split direction='vertical' minSize={200} sizes={[50, 50]}
className={`editor-split ${typewriterMode ? 'hidden' : ''}`} >
<div ref={editorRef} id="editor" />
<div ref={infoviewRef} id="infoview" />
</Split>
{typewriterMode && <TypewriterInterFace />}
</div>
</MonacoEditorContext.Provider>
}
@@ -0,0 +1,37 @@
import * as React from 'react';
import { useContext, useEffect, useRef, useState } from 'react'
import { useTranslation } from "react-i18next"
import { GameIdContext } from '../../state/context';
import { useLoadLevelQuery } from '../../state/api';
import { Markdown } from '../utils';
/** The mathematical formulation of the statement, supporting e.g. Latex
* It takes three forms, depending on the precence of name and description:
* - Theorem xyz: description
* - Theorem xyz
* - Exercises: description
*
* If `showLeanStatement` is true, it will additionally display the lean code.
*/
export function ExerciseStatement({ showLeanStatement = false }) {
let { t } = useTranslation()
const {gameId, worldId, levelId } = useContext(GameIdContext)
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
if (!(levelInfo.data?.descrText || levelInfo.data?.descrFormat)) { return <></> }
return <>
<div className="exercise-statement">
{levelInfo.data?.descrText ?
<Markdown>
{(levelInfo.data?.displayName ? `**${t("Theorem")}** \`${levelInfo.data?.displayName}\`: ` : '') + t(levelInfo.data?.descrText, {ns: gameId})}
</Markdown> : levelInfo.data?.displayName &&
<Markdown>
{`**${t("Theorem")}** \`${levelInfo.data?.displayName}\``}
</Markdown>
}
{levelInfo.data?.descrFormat && showLeanStatement &&
<p><code className="lean-code">{levelInfo.data?.descrFormat}</code></p>
}
</div>
</>
}
+443
View File
@@ -0,0 +1,443 @@
import * as React from 'react'
import { useContext } from 'react'
import { useRef, useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight, faHome, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import { DiagnosticSeverity, PublishDiagnosticsParams, DocumentUri } from 'vscode-languageserver-protocol';
import { useTranslation } from 'react-i18next'
import * as monaco from 'monaco-editor'
import { ChatContext, GameIdContext, InputModeContext, MonacoEditorContext, PageContext, PreferencesContext, ProofContext } from '../../state/context';
import { RpcContext, WithRpcSessions, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import '../../css/infoview.css'
import { useGetGameInfoQuery } from '../../state/api'
import { GameHint } from './Defs'
import { MoreHelpButton, filterHints } from '../chat'
import { isLastStepWithErrors, lastStepHasErrors, loadGoals } from '../infoview/goals'
import { getInteractiveDiagsAt, hasInteractiveErrors } from '../infoview/typewriter'
import { Errors } from '../infoview/messages'
import { Button, Markdown } from '../utils'
import { Command, GoalsTabs } from '../infoview/main'
import { CircularProgress } from '@mui/material'
/** The input field */
export function TypewriterInput({disabled}: {disabled?: boolean}) {
let { t } = useTranslation()
/** Reference to the hidden multi-line editor */
// const editor = React.useContext(MonacoEditorContext)
// const model = editor.getModel()
// const uri = model.uri.toString()
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
const [processing, setProcessing] = useState(false)
// const {typewriterInput, setTypewriterInput} = React.useContext(InputModeContext)
const inputRef = useRef()
// // The context storing all information about the current proof
const {proof, setProof, interimDiags, setInterimDiags, setCrashed} = React.useContext(ProofContext)
// // state to store the last batch of deleted messages
// const {setDeletedChat} = React.useContext(DeletedChatContext)
// const rpcSess = React.useContext(RpcContext)
// // Run the command
// const runCommand = React.useCallback(() => {
// if (processing) {return}
// // TODO: Desired logic is to only reset this after a new *error-free* command has been entered
// setDeletedChat([])
// const pos = editor.getPosition()
// if (typewriterInput) {
// setProcessing(true)
// editor.executeEdits("typewriter", [{
// range: monaco.Selection.fromPositions(
// pos,
// editor.getModel().getFullModelRange().getEndPosition()
// ),
// text: typewriterInput.trim() + "\n",
// forceMoveMarkers: false
// }])
// setTypewriterInput('')
// // Load proof after executing edits
// loadGoals(rpcSess, uri, setProof, setCrashed)
// }
// editor.setPosition(pos)
// }, [typewriterInput, editor])
// useEffect(() => {
// if (oneLineEditor && oneLineEditor.getValue() !== typewriterInput) {
// oneLineEditor.setValue(typewriterInput)
// }
// }, [typewriterInput])
// /* Load proof on start/switching to typewriter */
// useEffect(() => {
// loadGoals(rpcSess, uri, setProof, setCrashed)
// }, [])
// /** 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])
// // React when answer from the server comes back
// useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
// if (params.uri == uri) {
// setProcessing(false)
// console.log('Received lean diagnostics')
// console.log(params.diagnostics)
// setInterimDiags(params.diagnostics)
// //loadGoals(rpcSess, uri, setProof)
// // TODO: loadAllGoals()
// if (!hasErrors(params.diagnostics)) {
// //setTypewriterInput("")
// editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
// }
// } else {
// // console.debug(`expected uri: ${uri}, got: ${params.uri}`)
// // console.debug(params)
// }
// // TODO: This is the wrong place apparently. Where do wee need to load them?
// // TODO: instead of loading all goals every time, we could only load the last one
// // 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,
// language: "lean4cmd",
// quickSuggestions: false,
// lightbulb: {
// enabled: true
// },
// unicodeHighlight: {
// ambiguousCharacters: false,
// },
// automaticLayout: true,
// minimap: {
// enabled: false
// },
// lineNumbers: 'off',
// tabSize: 2,
// glyphMargin: false,
// folding: false,
// lineDecorationsWidth: 0,
// lineNumbersMinChars: 0,
// 'semanticHighlighting.enabled': true,
// overviewRulerLanes: 0,
// hideCursorInOverviewRuler: true,
// scrollbar: {
// vertical: 'hidden',
// horizontalScrollbarSize: 3
// },
// overviewRulerBorder: false,
// theme: 'vs-code-theme-converted',
// contextmenu: false
// })
// setOneLineEditor(myEditor)
// const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
// return () => {abbrevRewriter.dispose(); myEditor.dispose()}
// }, [])
// useEffect(() => {
// if (!oneLineEditor) return
// // Ensure that our one-line editor can only have a single line
// const l = oneLineEditor.getModel().onDidChangeContent((e) => {
// const value = oneLineEditor.getValue()
// setTypewriterInput(value)
// const newValue = value.replace(/[\n\r]/g, '')
// if (value != newValue) {
// oneLineEditor.setValue(newValue)
// }
// })
// return () => { l.dispose() }
// }, [oneLineEditor, setTypewriterInput])
// useEffect(() => {
// if (!oneLineEditor) return
// // Run command when pressing enter
// const l = oneLineEditor.onKeyUp((ev) => {
// if (ev.code === "Enter") {
// runCommand()
// }
// })
// return () => { l.dispose() }
// }, [oneLineEditor, runCommand])
// // BUG: Causes `file closed` error
// //TODO: Intention is to run once when loading, does that work?
// useEffect(() => {
// console.debug(`time to update: ${uri} \n ${rpcSess}`)
// console.debug(rpcSess)
// // console.debug('LOAD ALL GOALS')
// // TODO: loadAllGoals()
// }, [rpcSess])
/** Process the entered command */
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
// ev.preventDefault()
// runCommand()
}
// do not display if the proof is completed (with potential warnings still present)
return <div className={`typewriter-cmd${proof?.completedWithWarnings ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}>
<form onSubmit={handleSubmit}>
<div className="typewriter-input-wrapper">
<div ref={inputRef} className="typewriter-input" />
</div>
<button type="submit" disabled={processing} className="btn btn-inverted">
<FontAwesomeIcon icon={faWandMagicSparkles} />&nbsp;{t("Execute")}
</button>
</form>
</div>
}
export function TypewriterInterFace() {
let { t } = useTranslation()
const {gameId, worldId, levelId} = useContext(GameIdContext)
const editor = useContext(MonacoEditorContext)
const model = editor.getModel()
const uri = model.uri.toString()
const gameInfo = useGetGameInfoQuery({game: gameId})
let image: string = gameInfo.data?.worlds.nodes[worldId].image
const [disableInput, setDisableInput] = useState<boolean>(false)
const [loadingProgress, setLoadingProgress] = useState<number>(0)
const { selectedStep, setSelectedStep, setDeletedChat, showHelp, setShowHelp } = useContext(ChatContext)
const {mobile} = useContext(PreferencesContext)
const { proof, setProof, crashed, setCrashed, interimDiags } = useContext(ProofContext)
const { setTypewriterInput } = useContext(PageContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null)
// const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
// const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
const rpcSess = useRpcSessionAtPos({uri: uri, line: 0, character: 0})
/** Delete all proof lines starting from a given line.
* Note that the first line (i.e. deleting everything) is `1`!
*/
function deleteProof(line: number) {
return (ev) => {
let deletedChat: Array<GameHint> = []
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, ...filteredHints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
})
setDeletedChat(deletedChat)
// delete showHelp for deleted steps
setShowHelp(new Set(Array.from(showHelp).filter(i => i < line - 1)))
editor.executeEdits("typewriter", [{
range: monaco.Selection.fromPositions(
{ lineNumber: line, column: 1 },
editor.getModel().getFullModelRange().getEndPosition()
),
text: '',
forceMoveMarkers: false
}])
setSelectedStep(null)
setTypewriterInput(proof?.steps[line].command)
// Reload proof on deleting
loadGoals(rpcSess, uri, setProof, setCrashed)
ev.stopPropagation()
}
}
function toggleSelectStep(line: number) {
return (ev) => {
if (mobile) {return}
if (selectedStep == line) {
setSelectedStep(null)
console.debug(`unselected step`)
} else {
setSelectedStep(line)
console.debug(`step ${line} selected`)
}
}
}
// Scroll to the end of the proof if it is updated.
React.useEffect(() => {
if (proof?.steps.length > 1) {
proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
} else {
proofPanelRef.current?.scrollTo(0,0)
}
// also reenable the commandline when the proof changes
// BUG: If selecting 2nd goal on a intermediate proofstep and then delete proof to there,
// the commandline is not displaying disabled even though it should.
setDisableInput(false)
}, [proof])
// Scroll to element if selection changes
React.useEffect(() => {
if (typeof selectedStep !== 'undefined') {
Array.from(proofPanelRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
elem.scrollIntoView({ block: "center" })
})
}
}, [selectedStep])
// TODO: superfluous, can be replaced with `withErr` from above
let lastStepErrors = proof?.steps.length ? hasInteractiveErrors(getInteractiveDiagsAt(proof, proof?.steps.length)) : false
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 {
console.error(`Unknown loading kind: ${params.kind}`)
}
})
return <div className="typewriter-interface">
<RpcContext.Provider value={rpcSess}>
<div className="content">
{/* <div className='world-image-container empty'>
{image &&
<img className="contain" src={path.join("data", gameId, image)} alt="" />
}
</div> */}
{/* <div className="tmp-pusher">
<div className="world-image-container empty">
</div>
</div> */}
<div className='proof' ref={proofPanelRef}>
{/* <ExerciseStatement /> */}
{crashed ? <div>
<p className="crashed_message">{t("Crashed! Go to editor mode and fix your proof! Last server response:")}</p>
{interimDiags.map(diag => {
const severityClass = diag.severity ? {
[DiagnosticSeverity.Error]: 'error',
[DiagnosticSeverity.Warning]: 'warning',
[DiagnosticSeverity.Information]: 'information',
[DiagnosticSeverity.Hint]: 'hint',
}[diag.severity] : '';
return <div key={diag.message} >
<div className={`${severityClass} ml1 message`}>
<p className="mv2">{t("Line")}&nbsp;{diag.range.start.line}, {t("Character")}&nbsp;{diag.range.start.character}</p>
<pre className="font-code pre-wrap">
{diag.message}as React
</pre>
</div>
</div>
})}
</div> : proof?.steps.length ?
<>
{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' : '')}>
{i > 0 && <>
<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 &&
<Hints key={`hints-${i}`}
hints={filteredHints.map(hint => ({hint: hint, step: i}))} />
} */}
{/* <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 goals={step.goals} 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 && (
<div className="message information">
{proof?.completed ?
<p>Level completed! 🎉</p> :
<p>
<b>no goals left</b><br />
<i>This probably means you solved the level with warnings or Lean encountered a parsing error.</i>
</p>
}
</div>
)} */}
</div>
}
//}
)}
{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;{t("Home")}
</Button>
:
<Button to={`/${gameId}/world/${props.world}/level/${props.level + 1}`}>
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>
} */}
</div>
}
</> : <CircularProgress variant="determinate" value={100*(1 - 1.024 ** (- loadingProgress))} />
// note: since we don't know the total number of files,
// we use a function which strictly monotonely increases towards `100` as `x → ∞`
// The base is chosen at random s.t. we get roughly 91% for `x = 100`.
}
</div>
</div>
{/* <Typewriter disabled={disableInput || !proof?.steps.length}/> */}
<TypewriterInput />
</RpcContext.Provider>
</div>
}
+14 -7
View File
@@ -1,17 +1,24 @@
import * as React from 'react' import * as React from 'react'
import { useRouteError } from "react-router-dom"; import { useRouteError } from "react-router-dom";
import '../css/error_page.css'
/** The fallback error page */
export default function ErrorPage() { export default function ErrorPage() {
const error: any = useRouteError(); const error: any = useRouteError()
console.error(error); console.error(error)
return ( return (
<div id="error-page"> <div id="error-page">
<div className="error-message">
<h1>Oops!</h1> <h1>Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p> <p>Something unexpected happened:</p>
<p> <p><code>({error.status}) {error.statusText || error.message}<br/>{error.data}</code></p>
<i>{error.statusText || error.message}</i> <p>Please create an issue at the <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">lean4game repo</a>.</p>
</p> <div className="thought-bubble" />
<div className="thought-bubble" />
<div className="thought-bubble" />
<div className="thought-bubble" />
</div> </div>
); </div>
)
} }
+14
View File
@@ -0,0 +1,14 @@
import * as React from 'react'
import ReactCountryFlag from 'react-country-flag'
import lean4gameConfig from '../config.json'
/** Displays either a flag or the language-code, depending on the settings.
* The argument `iso` is an ISO-language code.
*/
export const Flag : React.FC<{ iso: string, showTitle?: boolean}> = ({iso, showTitle=false}) => {
let lang = lean4gameConfig.newLanguages[iso]
if (lean4gameConfig.useFlags && lang) {
return <ReactCountryFlag countryCode={lang.flag} title={showTitle ? lang.name : null} />
}
return <span>{iso}</span>
}
+112
View File
@@ -0,0 +1,112 @@
import * as React from 'react'
import { useContext, useEffect, useRef, useState } from 'react'
import Split from 'react-split'
import { useAppDispatch, useAppSelector } from '../hooks'
import { changeTypewriterMode, selectCode, selectSelections, selectTypewriterMode } from '../state/progress'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api'
import { ChatContext, GameIdContext, PageContext, PreferencesContext, ProofContext } from '../state/context'
import { InventoryPanel } from './inventory'
import { WorldTreePanel } from './world_tree'
import i18next from 'i18next'
import { ChatPanel } from './chat'
import { NewLevel } from './level'
import { GameHint, ProofState } from './editor/Defs'
import { useSelector } from 'react-redux'
import { Diagnostic } from 'vscode-languageserver-types'
import '../css/game.css'
import '../css/welcome.css'
import '../css/level.css'
/** main page of the game showing among others the tree of worlds/levels */
function Game() {
const dispatch = useAppDispatch()
const { gameId, worldId, levelId } = React.useContext(GameIdContext)
// Load the namespace of the game
i18next.loadNamespaces(gameId)
const {mobile} = useContext(PreferencesContext)
const {isSavePreferences, language, setIsSavePreferences, setLanguage} = React.useContext(PreferencesContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const inventory = useLoadInventoryOverviewQuery({game: gameId})
const {page, setPage} = useContext(PageContext)
const chatRef = useRef<HTMLDivElement>(null)
// 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>>([])
// A set of row numbers where help is displayed
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
// Select and highlight proof steps and corresponding hints
// TODO: with the new design, there is no difference between the introduction and
// a hint at the beginning of the proof...
const [selectedStep, setSelectedStep] = useState<number>(null)
// The state variables for the `ProofContext`
const [proof, setProof] = useState<ProofState>({steps: [], diagnostics: [], completed: false, completedWithWarnings: false})
const [interimDiags, setInterimDiags] = useState<Array<Diagnostic>>([])
const [isCrashed, setIsCrashed] = useState<Boolean>(false)
const typewriterMode = useSelector(selectTypewriterMode(gameId))
const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode}))
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
// set the window title
useEffect(() => {
if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title
}
}, [gameInfo.data?.title])
// Delete the current proof on changing level
useEffect(() => {
setProof(null)
setSelectedStep(null)
setDeletedChat([])
setShowHelp(new Set())
}, [gameId, worldId, levelId])
return <ChatContext.Provider value={{selectedStep, setSelectedStep, deletedChat, setDeletedChat, showHelp, setShowHelp, chatRef}}>
<ProofContext.Provider value={{proof, setProof, interimDiags, setInterimDiags, crashed: isCrashed, setCrashed: setIsCrashed}}>
{ mobile ?
<div className="app-content mobile">
{<>
<ChatPanel visible={worldId ? (levelId == 0 && page == 1) :(page == 0)} />
{ worldId ?
<NewLevel visible={page == 1} /> :
<WorldTreePanel visible={page == 1} />
}
<InventoryPanel visible={page == 2} />
</>
}
</div>
:
<Split className="app-content" minSize={0} snapOffset={200} sizes={[25, 50, 25]}>
<ChatPanel />
<div className="column">
{/* Note: apparently without this `div` the split panel bugs out. */}
{worldId ?
<NewLevel />
: <WorldTreePanel /> }
</div>
<InventoryPanel />
</Split>
}
</ProofContext.Provider>
</ChatContext.Provider>
}
export default Game
function useLevelEditor(codeviewRef: React.MutableRefObject<HTMLDivElement>, initialCode: any, initialSelections: any, onDidChangeContent: any, onDidChangeSelection: any): { editor: any; infoProvider: any; editorConnection: any } {
throw new Error('Function not implemented.')
}
-67
View File
@@ -1,67 +0,0 @@
import { GameHint } from "./infoview/rpc_api";
import * as React from 'react';
import Markdown from './markdown';
import { ProofStep } from "./infoview/context";
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>
</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>
</div>
}
export function Hints({hints, showHidden, step, selected, toggleSelection, lastLevel} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
// TODO: Should not use index as key.
return <>
{openHints.map((hint, j) => <Hint key={`hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection} lastLevel={lastLevel} />)}
{showHidden && hiddenHints.map((hint, j) => <HiddenHint key={`hidden-hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection} lastLevel={lastLevel} />)}
</>
}
export function DeletedHint({hint} : {hint: GameHint}) {
return <div className="message information deleted-hint">
<Markdown>{hint.text}</Markdown>
</div>
}
export function DeletedHints({hints} : {hints: GameHint[]}) {
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
// TODO: Should not use index as key.
return <>
{openHints.map((hint, i) => <DeletedHint key={`deleted-hint-${i}`} hint={hint} />)}
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
</>
}
/** Filter hints to not show consequtive identical hints twice.
*
* This function takes a `ProofStep[]` and extracts the hints in form of an
* element of type `GameHint[][]` where it removes hints that are identical to hints
* appearing in the previous step. Hidden hints are not filtered.
*
* This effectively means we prevent consequtive identical hints from being shown.
*/
export function filterHints(proof: ProofStep[]): GameHint[][] {
return proof.map((step, i) => {
if (i == 0){
return step.hints
} else {
// TODO: Writing all fields explicitely is somewhat fragile to changes, is there a
// good way to shallow-compare objects?
return step.hints.filter((hint) => hint.hidden ||
(proof[i-1].hints.find((x) => (x.text == hint.text && x.hidden == hint.hidden)) === undefined))
}
})
}
-123
View File
@@ -1,123 +0,0 @@
/**
* @fileOverview This file contains the the react contexts used in the project.
*/
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';
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
}
/** 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
*/
proof: ProofStep[],
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
}>({
proof: [],
setProof: () => {} // TODO: implement me
})
export interface ProofStateProps {
// pos: DocumentPosition;
status: InfoStatus;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
termGoal?: InteractiveTermGoal;
error?: string;
// userWidgets: UserWidgetInstance[];
// rpcSess: RpcSessionAtPos;
// 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 interface IMobileContext {
mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
lockMobile: boolean,
setLockMobile: React.Dispatch<React.SetStateAction<Boolean>>,
}
export const MobileContext = React.createContext<IMobileContext>({
mobile: false,
setMobile: () => {},
lockMobile: false,
setLockMobile: () => {}
})
export const WorldLevelIdContext = React.createContext<{
worldId : string,
levelId: number
}>({
worldId : null,
levelId: 0,
})
/** Context to keep highlight selected proof step and corresponding chat messages. */
export const SelectionContext = React.createContext<{
selectedStep : number,
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
}>({
selectedStep : undefined,
setSelectedStep: () => {}
})
/** Context for deleted Hints that are visible just a bit after they've been deleted */
export const DeletedChatContext = React.createContext<{
deletedChat : GameHint[],
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
showHelp : Set<number>,
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
}>({
deletedChat: undefined,
setDeletedChat: () => {},
showHelp: undefined,
setShowHelp: () => {}
})
export const InputModeContext = React.createContext<{
typewriterMode: boolean,
setTypewriterMode: React.Dispatch<React.SetStateAction<boolean>>,
typewriterInput: string,
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>,
lockInputMode: boolean,
setLockInputMode: React.Dispatch<React.SetStateAction<boolean>>,
}>({
typewriterMode: true,
setTypewriterMode: () => {},
typewriterInput: "",
setTypewriterInput: () => {},
lockInputMode: false,
setLockInputMode: () => {},
});
+100 -56
View File
@@ -4,13 +4,17 @@
* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx * Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx
*/ */
import * as React from 'react' import * as React from 'react'
import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api' // import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api'
import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'; // import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation'; // import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode' // import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips'; // import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
import { InputModeContext } from './context'; import { PageContext } from '../../state/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';
import { useTranslation } from 'react-i18next';
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */ /** Returns true if `h` is inaccessible according to Lean's default name rendering. */
function isInaccessibleName(h: string): boolean { function isInaccessibleName(h: string): boolean {
@@ -39,7 +43,11 @@ function goalToString(g: InteractiveGoal): string {
} }
export function goalsToString(goals: InteractiveGoals): 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 { interface GoalFilterState {
@@ -68,7 +76,7 @@ function getFilteredHypotheses(hyps: InteractiveHypothesisBundle[], filter: Goal
interface HypProps { interface HypProps {
hyp: InteractiveHypothesisBundle hyp: InteractiveHypothesisBundle
mvarId?: MVarId mvarId?: any // MVarId
} }
function Hyp({ hyp: h, mvarId }: HypProps) { function Hyp({ hyp: h, mvarId }: HypProps) {
@@ -125,18 +133,15 @@ interface GoalProps {
filter: GoalFilterState filter: GoalFilterState
showHints?: boolean showHints?: boolean
typewriter: boolean typewriter: boolean
unbundle?: boolean /** unbundle `x y : Nat` into `x : Nat` and `y : Nat` */
} }
interface ProofDisplayProps {
proof: string
}
/** /**
* Displays the hypotheses, target type and optional case label of a goal according to the * Displays the hypotheses, target type and optional case label of a goal according to the
* provided `filter`. */ * provided `filter`. */
export const Goal = React.memo((props: GoalProps) => { export const Goal = React.memo((props: GoalProps) => {
const { goal, filter, showHints, typewriter } = props const { goal, filter, showHints, typewriter, unbundle } = props
let { t } = useTranslation()
// TODO: Apparently `goal` can be `undefined` // TODO: Apparently `goal` can be `undefined`
if (!goal) {return <></>} if (!goal) {return <></>}
@@ -149,8 +154,8 @@ export const Goal = React.memo((props: GoalProps) => {
{ ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} : { ...locs, subexprTemplate: { mvarId: goal.mvarId, loc: { target: '' }}} :
undefined, undefined,
[locs, goal.mvarId]) [locs, goal.mvarId])
const goalLi = <div key={'goal'}> const goalLi = <div key={'goal'} className="goal">
<div className="goal-title">Goal: </div> {/* <div className="goal-title">{t("Goal")}:</div> */}
<LocationsContext.Provider value={goalLocs}> <LocationsContext.Provider value={goalLocs}>
<InteractiveCode fmt={goal.type} /> <InteractiveCode fmt={goal.type} />
</LocationsContext.Provider> </LocationsContext.Provider>
@@ -160,27 +165,42 @@ export const Goal = React.memo((props: GoalProps) => {
// if (props.goal.isInserted) cn += 'b--inserted ' // if (props.goal.isInserted) cn += 'b--inserted '
// if (props.goal.isRemoved) cn += 'b--removed ' // if (props.goal.isRemoved) cn += 'b--removed '
// const hints = <Hints hints={goal.hints} key={goal.mvarId} /> function unbundleHyps (hyps: InteractiveHypothesisBundle[]) : InteractiveHypothesisBundle[] {
const objectHyps = hyps.filter(hyp => !hyp.isAssumption) return hyps.flatMap(hyp => (
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption) unbundle ? hyp.names.map(name => {return {...hyp, names: [name]}}) : [hyp]
const {typewriterMode} = React.useContext(InputModeContext) ))
}
return <div> // const hints = <Hints hints={goal.hints} key={goal.mvarId} />
const objectHyps = unbundleHyps(hyps.filter(hyp => !hyp.isAssumption))
const assumptionHyps = unbundleHyps(hyps.filter(hyp => hyp.isAssumption))
return <>
{/* {goal.userName && <div><strong className="goal-case">case </strong>{goal.userName}</div>} */} {/* {goal.userName && <div><strong className="goal-case">case </strong>{goal.userName}</div>} */}
{filter.reverse && goalLi} {filter.reverse && goalLi}
<div className="hypotheses">
{! typewriter && objectHyps.length > 0 && {! typewriter && objectHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Objects:</div> <div className="hyp-group"><div className="hyp-group-title">{t("Objects")}:</div>
{objectHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> } {objectHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
{!typewriter && assumptionHyps.length > 0 && {!typewriter && assumptionHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div> <div className="hyp-group"><div className="hyp-group-title">{t("Assumptions")}:</div>
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> } {assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
{/* {typewriter && typewriterMode && <Typewriter />} */}
{!filter.reverse && goalLi}
{/* {showHints && hints} */}
</div> </div>
{!filter.reverse && <>
<div className='goal-sign'>
<svg width="100%" height="100%">
<line x1="0%" y1="0%" x2="0%" y2="100%" />
<line x1="0%" y1="50%" x2="100%" y2="50%" />
</svg>
</div>
{goalLi}
</>}
{/* {showHints && hints} */}
</>
}) })
export const MainAssumptions = React.memo((props: GoalProps2) => { export const MainAssumptions = React.memo((props: GoalProps2) => {
let { t } = useTranslation()
const { goals, filter } = props const { goals, filter } = props
const goal = goals[0] const goal = goals[0]
@@ -195,7 +215,7 @@ export const MainAssumptions = React.memo((props: GoalProps2) => {
[locs, goal.mvarId]) [locs, goal.mvarId])
const goalLi = <div key={'goal'}> const goalLi = <div key={'goal'}>
<div className="goal-title">Goal: </div> <div className="goal-title">{t("Goal") + ":"}</div>
<LocationsContext.Provider value={goalLocs}> <LocationsContext.Provider value={goalLocs}>
<InteractiveCode fmt={goal.type} /> <InteractiveCode fmt={goal.type} />
</LocationsContext.Provider> </LocationsContext.Provider>
@@ -205,25 +225,26 @@ export const MainAssumptions = React.memo((props: GoalProps2) => {
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption) const assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
return <div id="main-assumptions"> return <div id="main-assumptions">
<div className="goals-section-title">Current Goal</div> <div className="goals-section-title">{t("Current Goal")}</div>
{filter.reverse && goalLi} {filter.reverse && goalLi}
{ objectHyps.length > 0 && { objectHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Objects:</div> <div className="hyp-group"><div className="hyp-group-title">{t("Objects") + ":"}</div>
{objectHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> } {objectHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
{ assumptionHyps.length > 0 && { assumptionHyps.length > 0 &&
<div className="hyp-group"> <div className="hyp-group">
<div className="hyp-group-title">Assumptions:</div> <div className="hyp-group-title">{t("Assumptions") + ":"}</div>
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)} {assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}
</div> } </div> }
</div> </div>
}) })
export const OtherGoals = React.memo((props: GoalProps2) => { export const OtherGoals = React.memo((props: GoalProps2) => {
let { t } = useTranslation()
const { goals, filter } = props const { goals, filter } = props
return <> return <>
{goals && goals.length > 1 && {goals && goals.length > 1 &&
<div id="other-goals" className="other-goals"> <div id="other-goals" className="other-goals">
<div className="goals-section-title">Further Goals</div> <div className="goals-section-title">{t("Further Goals")}</div>
{goals.slice(1).map((goal, i) => {goals.slice(1).map((goal, i) =>
<details key={i}> <details key={i}>
<summary> <summary>
@@ -235,36 +256,17 @@ export const OtherGoals = React.memo((props: GoalProps2) => {
</> </>
}) })
// TODO: deprecated
export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
const { proof } = props
const steps = proof.match(/.+/g)
return <>
{ steps &&
<div id="current-proof">
<div className="goals-section-title">Proof history</div>
<div className="proof-display-wrapper">
<div className="proof-display">
{steps.map((s) =>
<div>{s}</div>
)}
</div>
</div>
</div>}
</>
})
interface GoalsProps { interface GoalsProps {
goals: InteractiveGoals goals: InteractiveGoalsWithHints
filter: GoalFilterState filter: GoalFilterState
} }
export function Goals({ goals, filter }: GoalsProps) { export function Goals({ goals, filter }: GoalsProps) {
if (goals.goals.length === 0) { if (goals.goals.length === 0) {
return <>No goals</> return <></>
} else { } else {
return <> 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 +278,7 @@ interface FilteredGoalsProps {
* When this is `undefined`, the component will not appear at all but will remember its state * 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 * 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. */ * settings and collapsed state will be as before. */
goals?: InteractiveGoals goals?: InteractiveGoalsWithHints
} }
/** /**
@@ -291,7 +293,7 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
data-id="copy-goal-to-comment" data-id="copy-goal-to-comment"
onClick={e => { onClick={e => {
e.preventDefault(); e.preventDefault();
if (goals) void ec.copyToComment(goalsToString(goals)) if (goals) void ec.copyToComment(goalsWithHintsToString(goals))
}} }}
title="copy state to comment" /> title="copy state to comment" />
@@ -336,3 +338,45 @@ export const FilteredGoals = React.memo(({ headerChildren, goals }: FilteredGoal
</details> </details>
</div> </div>
}) })
export function loadGoals(
rpcSess: RpcSessionAtPos,
uri: string,
setProof: React.Dispatch<React.SetStateAction<ProofState>>,
setCrashed: React.Dispatch<React.SetStateAction<Boolean>>) {
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) => {
if (typeof proof !== 'undefined') {
console.info(`received a proof state!`)
console.log(proof)
setProof(proof)
setCrashed(false)
} else {
console.warn('received undefined proof state!')
setCrashed(true)
// setProof(undefined)
}
}
).catch((error) => {
setCrashed(true)
console.warn(error)
})
}
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)
}
+42 -22
View File
@@ -4,7 +4,7 @@ import * as React from 'react'
import { CircularProgress } from '@mui/material' import { CircularProgress } from '@mui/material'
import type { Location, Diagnostic } from 'vscode-languageserver-protocol' import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError, 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, import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util' mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util'
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts' import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
@@ -13,9 +13,11 @@ import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-i
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation' import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation'
import { AllMessages, lspDiagToInteractive } from './messages' import { AllMessages, lspDiagToInteractive } from './messages'
import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals' // import { goalsToString, Goal, MainAssumptions, OtherGoals } from './goals'
import { InteractiveGoals } from './rpc_api' import { InteractiveTermGoal, InteractiveGoalsWithHints, InteractiveGoals, ProofState } from './rpc_api'
import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context' import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from '../../state/context'
import { useTranslation } from 'react-i18next'
import { GoalsTabs } from './main'
// TODO: All about pinning could probably be removed // TODO: All about pinning could probably be removed
type InfoKind = 'cursor' | 'pin' type InfoKind = 'cursor' | 'pin'
@@ -83,11 +85,12 @@ interface InfoDisplayContentProps extends PausableProps {
error?: string error?: string
userWidgets: UserWidgetInstance[] userWidgets: UserWidgetInstance[]
triggerUpdate: () => Promise<void> triggerUpdate: () => Promise<void>
proof? : string proofString? : string
} }
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => { const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props let { t } = useTranslation()
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proofString} = props
const hasWidget = userWidgets.length > 0 const hasWidget = userWidgets.length > 0
const hasError = !!error const hasError = !!error
@@ -114,34 +117,38 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const goalFilter = { reverse: false, showType: true, showInstance: true, showHiddenAssumption: true, showLetValue: true } 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 */ /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
return <> return <>
{hasError && {hasError &&
<div className='error' key='errors'> <div className='error' key='errors'>
Error updating:{' '}{error}. Error updating:{' '}{error}.
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>{' '}Try again.</a> <a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>{' '}Try again.</a>
</div>} </div>}
<AllMessages /> {/* TODO: Move error messages to Chat instead */}
<LocationsContext.Provider value={locs}> <LocationsContext.Provider value={locs}>
<div className="goals-section"> <div className="goals-section">
{ goals && goals.goals.length > 0 && <> { goals && goals.goals.length > 0 && <>
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} /> <GoalsTabs goals={goals.goals.map(goal => ({goal: goal, hints: []}))} last={false} onClick={() => {}} onGoalChange={() => {}}/>
<OtherGoals filter={goalFilter} goals={goals.goals} /> {/* <MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
<OtherGoals filter={goalFilter} goals={goals.goals} /> */}
</>} </>}
</div> </div>
<div> {/* <div>
{ goals && (goals.goals.length > 0 { goals && (goals.goals.length > 0
? <Goal typewriter={true} filter={goalFilter} key='mainGoal' goal={goals.goals[0]} showHints={true} /> ? <Goal typewriter={true} filter={goalFilter} key='mainGoal' goal={goals.goals[0]} showHints={true} />
: <div className="goals-section-title">No Goals</div> : <div className="goals-section-title">{t("No Goals")}</div>
)} )}
</div> </div> */}
</LocationsContext.Provider> </LocationsContext.Provider>
{userWidgets.map(widget => {/* {userWidgets.map(widget =>
<details key={`widget::${widget.id}::${widget.range?.toString()}`} open> <details key={`widget::${widget.id}::${widget.range?.toString()}`} open>
<summary className='mv2 pointer'>{widget.name}</summary> <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}/> termGoal={termGoal} selectedLocations={selectedLocs} widget={widget}/>
</details> </details>
)} )} */}
{nothingToShow && ( {/* {nothingToShow && (
isPaused ? isPaused ?
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 * / /* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 * /
<span>Updating is paused.{' '} <span>Updating is paused.{' '}
@@ -149,8 +156,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false) }}>resume updating</a> {' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false) }}>resume updating</a>
{' '}to see information. {' '}to see information.
</span> : </span> :
<><CircularProgress /><div>Loading goal...</div></>)} <><CircularProgress /><div>{t("Loading goal…")}</div></>)} */}
<AllMessages />
{/* <LocationsContext.Provider value={locs}> {/* <LocationsContext.Provider value={locs}>
{goals && goals.goals.length > 1 && <div className="goals-section other-goals"> {goals && goals.goals.length > 1 && <div className="goals-section other-goals">
<div className="goals-section-title">Weitere Goals</div> <div className="goals-section-title">Weitere Goals</div>
@@ -166,6 +172,7 @@ interface InfoDisplayProps {
pos: DocumentPosition, pos: DocumentPosition,
status: InfoStatus, status: InfoStatus,
messages: InteractiveDiagnostic[], messages: InteractiveDiagnostic[],
proof?: ProofState,
goals?: InteractiveGoals, goals?: InteractiveGoals,
termGoal?: InteractiveTermGoal, termGoal?: InteractiveTermGoal,
error?: string, error?: string,
@@ -175,7 +182,7 @@ interface InfoDisplayProps {
} }
/** Displays goal state and messages. Can be paused. */ /** 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, // Used to update the paused state *just once* if it is paused,
// but a display update is triggered // but a display update is triggered
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false) const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
@@ -214,7 +221,7 @@ function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable)
{/* <details open> */} {/* <details open> */}
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */} {/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
<div className="vscode-light"> <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> </div>
{/* </details> */} {/* </details> */}
</RpcContext.Provider> </RpcContext.Provider>
@@ -252,7 +259,7 @@ function useIsProcessingAt(p: DocumentPosition): boolean {
function InfoAux(props: InfoProps) { function InfoAux(props: InfoProps) {
const proofContext = React.useContext(ProofContext) const { setProof } = React.useContext(ProofContext)
const config = React.useContext(ConfigContext) const config = React.useContext(ConfigContext)
@@ -290,6 +297,10 @@ function InfoAux(props: InfoProps) {
// with e.g. a new `pos`. // with e.g. a new `pos`.
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'> type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => { const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
const proofReq = rpcSess.call('Game.getProofState', DocumentPosition.toTdpp(pos)).catch((error) => {
console.warn(error)
})
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos)) const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos))
const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos)) const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound) const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
@@ -308,6 +319,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'updating', status: 'updating',
messages: lspDiagsHere.map(lspDiagToInteractive), messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: undefined, error: undefined,
@@ -318,11 +330,12 @@ function InfoAux(props: InfoProps) {
// NB: it is important to await await reqs at once, otherwise // NB: it is important to await await reqs at once, otherwise
// if both throw then one exception becomes unhandled. // if both throw then one exception becomes unhandled.
Promise.all([goalsReq, termGoalReq, widgetsReq, messagesReq]).then( Promise.all([proofReq, goalsReq, termGoalReq, widgetsReq, messagesReq]).then(
([goals, termGoal, userWidgets, messages]) => resolve({ ([proof, goals, termGoal, userWidgets, messages]) => resolve({
pos, pos,
status: 'ready', status: 'ready',
messages, messages,
proof : proof as any,
goals: goals as any, goals: goals as any,
termGoal, termGoal,
error: undefined, error: undefined,
@@ -353,6 +366,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'error', status: 'error',
messages: lspDiagsHere.map(lspDiagToInteractive), messages: lspDiagsHere.map(lspDiagToInteractive),
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: `Error fetching goals: ${errorString}`, error: `Error fetching goals: ${errorString}`,
@@ -389,6 +403,7 @@ function InfoAux(props: InfoProps) {
pos, pos,
status: 'updating', status: 'updating',
messages: [], messages: [],
proof: undefined,
goals: undefined, goals: undefined,
termGoal: undefined, termGoal: undefined,
error: undefined, error: undefined,
@@ -412,6 +427,11 @@ function InfoAux(props: InfoProps) {
// hintContext.setHints(state.value.goals.goals[0].hints) // hintContext.setHints(state.value.goals.goals[0].hints)
// } // }
setDisplayProps({ ...state.value, triggerUpdate }) 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') { } else if (state.state === 'rejected' && state.error !== 'retry') {
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception. // The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
console.warn('Unreachable code reached with error: ', state.error) console.warn('Unreachable code reached with error: ', state.error)
+3 -1
View File
@@ -6,9 +6,11 @@ import { DidChangeTextDocumentParams, DidCloseTextDocumentParams, TextDocumentCo
import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'; import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { DocumentPosition, Keyed, PositionHelpers, useClientNotificationEffect, useClientNotificationState, useEvent, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util'; import { DocumentPosition, Keyed, PositionHelpers, useClientNotificationEffect, useClientNotificationState, useEvent, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { Info, InfoProps } from './info'; import { Info, InfoProps } from './info';
import { useTranslation } from 'react-i18next';
/** Manages and displays pinned infos, as well as info for the current location. */ /** Manages and displays pinned infos, as well as info for the current location. */
export function Infos() { export function Infos() {
let { t } = useTranslation()
const ec = React.useContext(EditorContext); const ec = React.useContext(EditorContext);
// Update pins when the document changes. In particular, when edits are made // Update pins when the document changes. In particular, when edits are made
@@ -126,6 +128,6 @@ export function Infos() {
return <div> return <div>
{infoProps.map (ps => <Info {...ps} />)} {infoProps.map (ps => <Info {...ps} />)}
{!curPos && <p>Click somewhere in the Lean file to enable the infoview.</p> } {!curPos && <p>{t("Click somewhere in the Lean file to enable the infoview.")}</p> }
</div>; </div>;
} }
+202 -124
View File
@@ -18,33 +18,37 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDeleteLeft, faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons' import { faDeleteLeft, faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { GameIdContext } from '../../app';
import { useAppDispatch, useAppSelector } from '../../hooks'; import { useAppDispatch, useAppSelector } from '../../hooks';
import { LevelInfo } from '../../state/api'; import { LevelInfo, useGetGameInfoQuery, useLoadLevelQuery } from '../../state/api';
import { changedInventory, levelCompleted, selectCode, selectCompleted, selectInventory } from '../../state/progress'; import { changedInventory, levelCompleted, selectCode, selectCompleted, selectInventory } from '../../state/progress';
import Markdown from '../markdown';
import { Infos } from './infos'; import { Infos } from './infos';
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages'; import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
import { Goal } from './goals'; import { Goal, isLastStepWithErrors, lastStepHasErrors, loadGoals } from './goals';
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context'; import { ChatContext, PageContext, PreferencesContext, MonacoEditorContext, ProofContext, GameIdContext } from '../../state/context';
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter'; import { Typewriter, getInteractiveDiagsAt, hasErrors, hasInteractiveErrors } from './typewriter';
import { InteractiveDiagnostic } from '@leanprover/infoview/*'; import { InteractiveDiagnostic } from '@leanprover/infoview/*';
import { Button } from '../button';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api'; import { GameHint, InteractiveGoalWithHints, InteractiveGoalsWithHints, ProofState } from './rpc_api';
import { store } from '../../state/store'; import { store } from '../../state/store';
import { Hints, filterHints } from '../hints'; import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { DiagnosticSeverity } from 'vscode-languageclient';
import { useTranslation } from 'react-i18next';
import path from 'path';
import { useContext } from 'react';
import { Hints, MoreHelpButton, filterHints } from '../chat';
import { Button, Markdown } from '../utils'
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is /** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start. * always present, or the monaco editor cannot start.
*/ */
export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize }) { export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize }) {
const ec = React.useContext(EditorContext) const ec = React.useContext(EditorContext)
const { typewriterMode } = React.useContext(InputModeContext) const { typewriterMode, lockEditorMode } = React.useContext(PageContext)
return <> return <>
<div className={typewriterMode ? 'hidden' : ''}> <div className={(typewriterMode && !lockEditorMode) ? 'hidden' : ''}>
<ExerciseStatement data={level} showLeanStatement={true} /> {/* <ExerciseStatement showLeanStatement={true} /> */}
<div ref={codeviewRef} className={'codeview'}></div> <div ref={codeviewRef} className={'codeview'}></div>
</div> </div>
{ec ? {ec ?
@@ -58,22 +62,21 @@ export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize })
/** The part of the two editors that needs the editor connection first */ /** The part of the two editors that needs the editor connection first */
function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: string, levelId: number, level: LevelInfo, worldSize: number }) { function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: string, levelId: number, level: LevelInfo, worldSize: number }) {
const ec = React.useContext(EditorContext) const ec = React.useContext(EditorContext)
const gameId = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const { typewriterMode } = React.useContext(InputModeContext) const { typewriterMode, lockEditorMode } = React.useContext(PageContext)
const {proof, setProof} = React.useContext(ProofContext)
// Mark level as completed when server gives notification
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
useServerNotificationEffect(
'$/game/completed', React.useEffect(() => {
(params: any) => { if (proof?.completed) {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.uri) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId })) dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
// On completion, add the names of all new items to the local storage // On completion, add the names of all new items to the local storage
let newTiles = [ let newTiles = [
...level?.tactics, ...level?.tactics,
...level?.lemmas, ...level?.theorems,
...level?.definitions ...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name) ].filter((tile) => tile.new).map((tile) => tile.name)
@@ -88,9 +91,9 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i) let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
dispatch(changedInventory({ game: gameId, inventory: newInv })) dispatch(changedInventory({ game: gameId, inventory: newInv }))
} }
}, [level] }, [proof, level])
)
/* Set up updates to the global infoview state on editor events. */ /* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
@@ -110,7 +113,7 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
<WithRpcSessions> <WithRpcSessions>
<WithLspDiagnosticsContext> <WithLspDiagnosticsContext>
<ProgressContext.Provider value={allProgress}> <ProgressContext.Provider value={allProgress}>
{typewriterMode ? {(typewriterMode && !lockEditorMode) ?
<TypewriterInterfaceWrapper world={worldId} level={levelId} data={level} worldSize={worldSize}/> <TypewriterInterfaceWrapper world={worldId} level={levelId} data={level} worldSize={worldSize}/>
: :
<Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} data={level} /> <Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} data={level} />
@@ -131,17 +134,24 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
* *
* If `showLeanStatement` is true, it will additionally display the lean code. * If `showLeanStatement` is true, it will additionally display the lean code.
*/ */
function ExerciseStatement({ data, showLeanStatement = false }) { export function ExerciseStatement({ showLeanStatement = false }) {
if (!(data?.descrText || data?.descrFormat)) { return <></> } let { t } = useTranslation()
const {gameId, worldId, levelId } = React.useContext(GameIdContext)
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
if (!(levelInfo.data?.descrText || levelInfo.data?.descrFormat)) { return <></> }
return <> return <>
<div className="exercise-statement"> <div className="exercise-statement">
{data?.descrText && {levelInfo.data?.descrText ?
<Markdown> <Markdown>
{(data?.displayName ? `**Theorem** \`${data?.displayName}\`: ` : '') + data?.descrText} {(levelInfo.data?.displayName ? `**${t("Theorem")}** \`${levelInfo.data?.displayName}\`: ` : '') + t(levelInfo.data?.descrText, {ns: gameId})}
</Markdown> : levelInfo.data?.displayName &&
<Markdown>
{`**${t("Theorem")}** \`${levelInfo.data?.displayName}\``}
</Markdown> </Markdown>
} }
{data?.descrFormat && showLeanStatement && {levelInfo.data?.descrFormat && showLeanStatement &&
<p><code className="lean-code">{data?.descrFormat}</code></p> <p><code className="lean-code">{levelInfo.data?.descrFormat}</code></p>
} }
</div> </div>
</> </>
@@ -150,12 +160,23 @@ function ExerciseStatement({ data, showLeanStatement = false }) {
// TODO: This is only used in `EditorInterface` // TODO: This is only used in `EditorInterface`
// while `TypewriterInterface` has this copy-pasted in. // while `TypewriterInterface` has this copy-pasted in.
export function Main(props: { world: string, level: number, data: LevelInfo}) { export function Main(props: { world: string, level: number, data: LevelInfo}) {
let { t } = useTranslation()
const ec = React.useContext(EditorContext); const ec = React.useContext(EditorContext);
const gameId = React.useContext(GameIdContext) const {gameId, worldId, levelId} = 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, setDeletedChat, showHelp, setShowHelp} = React.useContext(ChatContext)
function toggleSelection(line: number) {
return (ev) => {
console.debug('toggled selection')
if (selectedStep == line) {
setSelectedStep(null)
} else {
setSelectedStep(line)
}
}
}
console.debug(`template: ${props.data?.template}`) console.debug(`template: ${props.data?.template}`)
// React.useEffect (() => { // React.useEffect (() => {
@@ -182,6 +203,19 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); 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( useClientNotificationEffect(
'textDocument/didClose', 'textDocument/didClose',
(params: DidCloseTextDocumentParams) => { (params: DidCloseTextDocumentParams) => {
@@ -201,13 +235,22 @@ export function Main(props: { world: string, level: number, data: LevelInfo}) {
// that we want to persist. // that we want to persist.
let ret let ret
if (!serverVersion) { if (!serverVersion) {
ret = <p>Waiting for Lean server to start...</p> ret = <p>{t("Waiting for Lean server to start…")}</p>
} else if (serverStoppedResult) { } else if (serverStoppedResult) {
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div> ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
} else { } else {
ret = <div className="infoview vscode-light"> ret = <div className="infoview vscode-light">
{completed && <div className="level-completed">Level completed! 🎉</div>} {proof?.completedWithWarnings &&
<div className="level-completed">
{proof?.completed ? t("Level completed! 🎉") : t("Level completed with warnings 🎭")}
</div>
}
<Infos /> <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> </div>
} }
@@ -223,16 +266,27 @@ const goalFilter = {
} }
/** The display of a single entered lean command */ /** The display of a single entered lean command */
function Command({ command, deleteProof }: { command: string, deleteProof: any }) { export function Command({ proof, i, deleteProof }: { proof: ProofState, i: number, deleteProof: any }) {
let {t} = useTranslation()
// The first step will always have an empty command // The first step will always have an empty command
if (!command) { return <></> } 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>{t("Failed command")}</i>: {proof?.steps[i].command}
</div>
} else {
return <div className="command"> return <div className="command">
<div className="command-text">{command}</div> <div className="command-text">{proof?.steps[i].command}</div>
<Button to="" className="undo-button btn btn-inverted" title="Retry proof from here" onClick={deleteProof}> <Button to="" className="undo-button btn btn-inverted" title={t("Retry proof from here")} onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry <FontAwesomeIcon icon={faDeleteLeft} />&nbsp;{t("Retry")}
</Button> </Button>
</div> </div>
} }
}
// const MessageView = React.memo(({uri, diag}: MessageViewProps) => { // const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
// const ec = React.useContext(EditorContext); // const ec = React.useContext(EditorContext);
@@ -257,7 +311,7 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// message = diag.message // message = diag.message
// } // }
// const { typewriterMode } = React.useContext(InputModeContext) // const { typewriterMode } = React.useContext(PageContext)
// return ( // return (
// // <details open> // // <details open>
@@ -285,22 +339,26 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// ) // )
// }, fastIsEqual) // }, fastIsEqual)
/** The tabs of goals that lean ahs after the command of this step has been processed */ /** The tabs of goals that lean has 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 }) { export function GoalsTabs({ goals, last, onClick, onGoalChange=(n)=>{}}: { goals: InteractiveGoalWithHints[], last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) {
let { t } = useTranslation()
const [selectedGoal, setSelectedGoal] = React.useState<number>(0) const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
if (goals.length == 0) {
return <></>
}
return <div className="goal-tabs" onClick={onClick}> return <div className="goal-tabs" onClick={onClick}>
<div className={`tab-bar ${last ? 'current' : ''}`}> <div className={`tab-bar ${last ? 'current' : ''}`}>
{proofStep.goals.map((goal, i) => ( {goals.map((goal, i) => (
// TODO: Should not use index as key. // TODO: Should not use index as key.
<div key={`proof-goal-${i}`} className={`tab ${i == (selectedGoal) ? "active" : ""}`} onClick={(ev) => { onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}> <div key={`proof-goal-${i}`} className={`tab ${i == (selectedGoal) ? "active" : ""}`} onClick={(ev) => { onGoalChange(i); setSelectedGoal(i); ev.stopPropagation() }}>
{i ? `Goal ${i + 1}` : "Active Goal"} {i ? t("Goal") + ` ${i + 1}` : t("Active Goal")}
</div> </div>
))} ))}
</div> </div>
<div className="goal-tab vscode-light"> <div className="goal-tab vscode-light">
<Goal typewriter={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} /> <Goal typewriter={false} filter={goalFilter} goal={goals[selectedGoal]?.goal} unbundle={false} />
</div> </div>
</div> </div>
} }
@@ -308,7 +366,7 @@ function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofSte
// Splitting up Typewriter into two parts is a HACK // Splitting up Typewriter into two parts is a HACK
export function TypewriterInterfaceWrapper(props: { world: string, level: number, data: LevelInfo, worldSize: number }) { export function TypewriterInterfaceWrapper(props: { world: string, level: number, data: LevelInfo, worldSize: number }) {
const ec = React.useContext(EditorContext) const ec = React.useContext(EditorContext)
const gameId = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
useClientNotificationEffect( useClientNotificationEffect(
'textDocument/didClose', 'textDocument/didClose',
@@ -340,22 +398,25 @@ export function TypewriterInterfaceWrapper(props: { world: string, level: number
/** The interface in command line mode */ /** The interface in command line mode */
export function TypewriterInterface({props}) { export function TypewriterInterface({props}) {
let { t } = useTranslation()
const ec = React.useContext(EditorContext) const ec = React.useContext(EditorContext)
const gameId = React.useContext(GameIdContext) const {gameId,worldId, levelId} = React.useContext(GameIdContext)
const editor = React.useContext(MonacoEditorContext) const editor = React.useContext(MonacoEditorContext)
const model = editor.getModel() const model = editor.getModel()
const uri = model.uri.toString() const uri = model.uri.toString()
const gameInfo = useGetGameInfoQuery({game: gameId})
let image: string = gameInfo.data?.worlds.nodes[worldId].image
const [disableInput, setDisableInput] = React.useState<boolean>(false) const [disableInput, setDisableInput] = React.useState<boolean>(false)
const [loadingProgress, setLoadingProgress] = React.useState<number>(0) const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext) const { selectedStep, setSelectedStep, setDeletedChat, showHelp, setShowHelp } = React.useContext(ChatContext)
const {mobile} = React.useContext(MobileContext) const {mobile} = React.useContext(PreferencesContext)
const { proof } = React.useContext(ProofContext) const { proof, setProof, crashed, setCrashed, interimDiags } = React.useContext(ProofContext)
const { setTypewriterInput } = React.useContext(InputModeContext) const { setTypewriterInput } = React.useContext(PageContext)
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null) const proofPanelRef = React.useRef<HTMLDivElement>(null)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
// const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig; // const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
// const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri); // const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
@@ -367,12 +428,17 @@ export function TypewriterInterface({props}) {
function deleteProof(line: number) { function deleteProof(line: number) {
return (ev) => { return (ev) => {
let deletedChat: Array<GameHint> = [] let deletedChat: Array<GameHint> = []
filterHints(proof).slice(line).map((hintsAtStep, 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 // Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...hintsAtStep.filter(hint => (!hint.hidden || showHelp.has(line + i)))] deletedChat = [...deletedChat, ...filteredHints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
}) })
setDeletedChat(deletedChat) setDeletedChat(deletedChat)
// delete showHelp for deleted steps
setShowHelp(new Set(Array.from(showHelp).filter(i => i < line - 1)))
editor.executeEdits("typewriter", [{ editor.executeEdits("typewriter", [{
range: monaco.Selection.fromPositions( range: monaco.Selection.fromPositions(
{ lineNumber: line, column: 1 }, { lineNumber: line, column: 1 },
@@ -381,8 +447,10 @@ export function TypewriterInterface({props}) {
text: '', text: '',
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setSelectedStep(undefined) setSelectedStep(null)
setTypewriterInput(proof[line].command) setTypewriterInput(proof?.steps[line].command)
// Reload proof on deleting
loadGoals(rpcSess, uri, setProof, setCrashed)
ev.stopPropagation() ev.stopPropagation()
} }
} }
@@ -391,7 +459,7 @@ export function TypewriterInterface({props}) {
return (ev) => { return (ev) => {
if (mobile) {return} if (mobile) {return}
if (selectedStep == line) { if (selectedStep == line) {
setSelectedStep(undefined) setSelectedStep(null)
console.debug(`unselected step`) console.debug(`unselected step`)
} else { } else {
setSelectedStep(line) setSelectedStep(line)
@@ -402,7 +470,7 @@ export function TypewriterInterface({props}) {
// Scroll to the end of the proof if it is updated. // Scroll to the end of the proof if it is updated.
React.useEffect(() => { React.useEffect(() => {
if (proof?.length > 1) { if (proof?.steps.length > 1) {
proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0) proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
} else { } else {
proofPanelRef.current?.scrollTo(0,0) proofPanelRef.current?.scrollTo(0,0)
@@ -423,38 +491,8 @@ export function TypewriterInterface({props}) {
} }
}, [selectedStep]) }, [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)
} else {
tmp.add(k - withErr)
}
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
useServerNotificationEffect("$/game/loading", (params : any) => { useServerNotificationEffect("$/game/loading", (params : any) => {
@@ -470,24 +508,57 @@ export function TypewriterInterface({props}) {
return <div className="typewriter-interface"> return <div className="typewriter-interface">
<RpcContext.Provider value={rpcSess}> <RpcContext.Provider value={rpcSess}>
<div className="content"> <div className="content">
<div className="tmp-pusher"> {/* <div className='world-image-container empty'>
{image &&
<img className="contain" src={path.join("data", gameId, image)} alt="" />
}
</div> */}
{/* <div className="tmp-pusher">
<div className="world-image-container empty">
</div> </div>
</div> */}
<div className='proof' ref={proofPanelRef}> <div className='proof' ref={proofPanelRef}>
<ExerciseStatement data={props.data} /> {/* <ExerciseStatement /> */}
{proof.length ? {crashed ? <div>
<> <p className="crashed_message">{t("Crashed! Go to editor mode and fix your proof! Last server response:")}</p>
{proof.map((step, i) => { {interimDiags.map(diag => {
if (i == proof.length - 1 && lastStepErrors) { const severityClass = diag.severity ? {
// if the last command contains an error, we only display the errors but not the [DiagnosticSeverity.Error]: 'error',
// entered command as it is still present in the command line. [DiagnosticSeverity.Warning]: 'warning',
// TODO: Should not use index as key. [DiagnosticSeverity.Information]: 'information',
return <div key={`proof-step-${i}`}> [DiagnosticSeverity.Hint]: 'hint',
<Errors errors={step.errors} typewriterMode={true} /> }[diag.severity] : '';
return <div key={diag.message} >
<div className={`${severityClass} ml1 message`}>
<p className="mv2">{t("Line")}&nbsp;{diag.range.start.line}, {t("Character")}&nbsp;{diag.range.start.character}</p>
<pre className="font-code pre-wrap">
{diag.message}
</pre>
</div> </div>
} else { </div>
})}
</div> : proof?.steps.length ?
<>
{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' : '')}> return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')}>
<Command command={step.command} deleteProof={deleteProof(i)} /> {i > 0 && <>
<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 && {mobile && i == 0 && props.data?.introduction &&
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}> <div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}>
<Markdown>{props.data?.introduction}</Markdown> <Markdown>{props.data?.introduction}</Markdown>
@@ -495,22 +566,20 @@ export function TypewriterInterface({props}) {
} }
{mobile && {mobile &&
<Hints key={`hints-${i}`} <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i} hints={filteredHints.map(hint => ({hint: hint, step: i}))} />
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
} }
<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)) &&
{mobile && i == proof.length - 1 && <GoalsTabs goals={step.goals} 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) => {}}/>
hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) && }
<Button className="btn btn-help" to="" onClick={activateHiddenHints}> {mobile && i == proof?.steps.length - 1 &&
Show more help! <MoreHelpButton />
</Button>
} }
{/* Show a message that there are no goals left */} {/* Show a message that there are no goals left */}
{!step.goals.length && ( {/* {!step.goals.length && (
<div className="message information"> <div className="message information">
{completed ? {proof?.completed ?
<p>Level completed! 🎉</p> : <p>Level completed! 🎉</p> :
<p> <p>
<b>no goals left</b><br /> <b>no goals left</b><br />
@@ -518,15 +587,21 @@ export function TypewriterInterface({props}) {
</p> </p>
} }
</div> </div>
)} )} */}
</div> </div>
} }
})} //}
{mobile && completed && )}
{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"> <div className="button-row mobile">
{props.level >= props.worldSize ? {props.level >= props.worldSize ?
<Button to={`/${gameId}`}> <Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World <FontAwesomeIcon icon={faHome} />&nbsp;{t("Home")}
</Button> </Button>
: :
<Button to={`/${gameId}/world/${props.world}/level/${props.level + 1}`}> <Button to={`/${gameId}/world/${props.world}/level/${props.level + 1}`}>
@@ -535,11 +610,14 @@ export function TypewriterInterface({props}) {
} }
</div> </div>
} }
</> : <CircularProgress variant="determinate" value={loadingProgress} /> </> : <CircularProgress variant="determinate" value={100*(1 - 1.024 ** (- loadingProgress))} />
// note: since we don't know the total number of files,
// we use a function which strictly monotonely increases towards `100` as `x → ∞`
// The base is chosen at random s.t. we get roughly 91% for `x = 100`.
} }
</div> </div>
</div> </div>
<Typewriter hidden={!withErr && proof[proof.length - 1]?.goals.length == 0} disabled={disableInput || !proof.length}/> <Typewriter disabled={disableInput || !proof?.steps.length}/>
</RpcContext.Provider> </RpcContext.Provider>
</div> </div>
} }
+17 -7
View File
@@ -10,7 +10,8 @@ import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/co
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer' import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer'
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions' import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
import { InputModeContext } from './context' import { PageContext } from '../../state/context'
import { useTranslation } from 'react-i18next'
interface MessageViewProps { interface MessageViewProps {
uri: DocumentUri; uri: DocumentUri;
@@ -79,7 +80,7 @@ const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
message = diag.message message = diag.message
} }
const { typewriterMode } = React.useContext(InputModeContext) const { typewriterMode, lockEditorMode } = React.useContext(PageContext)
return ( return (
// <details open> // <details open>
@@ -98,7 +99,7 @@ const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
// </span> // </span>
// </summary> // </summary>
<div className={severityClass + ' ml1 message'}> <div className={severityClass + ' ml1 message'}>
{!typewriterMode && <p className="mv2">{title}</p>} {!(typewriterMode && !lockEditorMode) && <p className="mv2">{title}</p>}
<pre className="font-code pre-wrap"> <pre className="font-code pre-wrap">
<InteractiveMessage fmt={message} /> <InteractiveMessage fmt={message} />
</pre> </pre>
@@ -194,17 +195,26 @@ export function AllMessages() {
</a> </a>
</span> </span>
</summary> */} </summary> */}
<AllMessagesBody uri={curPos.uri} key={curPos.uri} messages={iDiags0} /> <AllMessagesBody uri={curPos.uri} key={curPos.uri} messages={iDiags0} curPos={curPos} />
{/* </Details> */} {/* </Details> */}
</RpcContext.Provider> </RpcContext.Provider>
) )
} }
/** We factor out the body of {@link AllMessages} which lazily fetches its contents only when expanded. */ /** 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[]>}) {
let { t } = useTranslation()
const [msgs, setMsgs] = React.useState<InteractiveDiagnostic[] | undefined>(undefined) const [msgs, setMsgs] = React.useState<InteractiveDiagnostic[] | undefined>(undefined)
React.useEffect(() => { void messages().then(setMsgs) }, [messages]) React.useEffect(() => { void messages().then(
if (msgs === undefined) return <div>Loading messages...</div> 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>{t("Loading messages…")}</div>
else return <MessagesList uri={uri} messages={msgs}/> else return <MessagesList uri={uri} messages={msgs}/>
} }
-48
View File
@@ -1,48 +0,0 @@
/**
* @fileOverview Defines the interface for the communication with the server.
*
* 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;
}
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;
}
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;
hints: GameHint[];
}
export interface InteractiveGoals {
goals: InteractiveGoal[];
}
+86 -127
View File
@@ -3,23 +3,31 @@ import { useRef, useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { Registry } from 'monaco-textmate' // peer dependency // import { Registry } from 'monaco-textmate' // peer dependency
import { wireTmGrammars } from 'monaco-editor-textmate' // 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 { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'; // import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'; // import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json' // import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json' // import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json' // import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
import languageConfig from 'lean4/language-configuration.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 { Diagnostic } from 'vscode-languageserver-types';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util'; import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { RpcContext } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'; import { RpcContext } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context' import { ChatContext, PageContext, MonacoEditorContext, ProofContext, GameIdContext } from '../../state/context'
import { goalsToString } from './goals' import { goalsToString, lastStepHasErrors, loadGoals } from './goals'
import { GameHint, InteractiveGoals } from './rpc_api' import { GameHint, ProofState } from './rpc_api'
import { useTranslation } from 'react-i18next'
import { InputAbbreviationRewriter } from '@leanprover/unicode-input-component'
import ContentEditable from 'react-contenteditable'
export interface GameDiagnosticsParams {
uri: DocumentUri;
diagnostics: Diagnostic[];
}
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */ /* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
@@ -29,6 +37,12 @@ monaco.languages.register({
extensions: ['.leancmd'] extensions: ['.leancmd']
}) })
// register Monaco languages // TODO: JE. I dont understand why I suddenly had to add this when it worked without before.
monaco.languages.register({
id: 'lean4',
extensions: ['.lean']
})
// map of monaco "language id's" to TextMate scopeNames // map of monaco "language id's" to TextMate scopeNames
const grammars = new Map() const grammars = new Map()
grammars.set('lean4', 'source.lean') grammars.set('lean4', 'source.lean')
@@ -64,124 +78,31 @@ config.autoClosingPairs = config.autoClosingPairs.map(
monaco.languages.setLanguageConfiguration('lean4cmd', config); monaco.languages.setLanguageConfiguration('lean4cmd', config);
/** The input field */ /** The input field */
export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boolean}) { export function Typewriter({disabled}: {disabled?: boolean}) {
let { t } = useTranslation()
/** Reference to the hidden multi-line editor */ /** Reference to the hidden multi-line editor */
const editor = React.useContext(MonacoEditorContext) const editor = React.useContext(MonacoEditorContext)
const model = editor.getModel() const model = editor?.getModel()
const uri = model.uri.toString() const uri = model?.uri.toString()
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null) const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
const [processing, setProcessing] = useState(false) const [processing, setProcessing] = useState(false)
const {typewriterInput, setTypewriterInput} = React.useContext(InputModeContext) const {typewriterInput, setTypewriterInput} = React.useContext(PageContext)
const inputRef = useRef() const inputRef = useRef()
// The context storing all information about the current proof // The context storing all information about the current proof
const {proof, setProof} = React.useContext(ProofContext) const {proof, setProof, interimDiags, setInterimDiags, setCrashed} = React.useContext(ProofContext)
const {gameId, worldId, levelId} = React.useContext(GameIdContext)
// state to store the last batch of deleted messages // state to store the last batch of deleted messages
const {setDeletedChat} = React.useContext(DeletedChatContext) const {setDeletedChat} = React.useContext(ChatContext)
const rpcSess = React.useContext(RpcContext) const rpcSess = React.useContext(RpcContext)
/** Load all goals an messages of the current proof (line-by-line) and save
* the retrieved information into context (`ProofContext`)
*/
const loadAllGoals = React.useCallback(() => {
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")})
)
}
// 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[] = []
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.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
// with no goals there will be no hints.
let hints : GameHint[] = goals.goals.length ? goals.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.goals,
// 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])
// Run the command // Run the command
const runCommand = React.useCallback(() => { const runCommand = React.useCallback(() => {
if (processing) {return} if (processing) {return}
@@ -189,21 +110,23 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// TODO: Desired logic is to only reset this after a new *error-free* command has been entered // TODO: Desired logic is to only reset this after a new *error-free* command has been entered
setDeletedChat([]) setDeletedChat([])
const pos = editor.getPosition() const pos = editor?.getPosition()
if (typewriterInput) { if (typewriterInput) {
setProcessing(true) setProcessing(true)
editor.executeEdits("typewriter", [{ editor?.executeEdits("typewriter", [{
range: monaco.Selection.fromPositions( range: monaco.Selection.fromPositions(
pos, pos,
editor.getModel().getFullModelRange().getEndPosition() editor?.getModel()?.getFullModelRange()?.getEndPosition()
), ),
text: typewriterInput.trim() + "\n", text: typewriterInput.trim() + "\n",
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setTypewriterInput('') setTypewriterInput('')
// Load proof after executing edits
loadGoals(rpcSess, uri, setProof, setCrashed)
} }
editor.setPosition(pos) editor?.setPosition(pos)
}, [typewriterInput, editor]) }, [typewriterInput, editor])
useEffect(() => { useEffect(() => {
@@ -212,9 +135,16 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
} }
}, [typewriterInput]) }, [typewriterInput])
/* Load proof on start/switching to typewriter */
useEffect(() => { useEffect(() => {
if (proof.length && hasInteractiveErrors(proof[proof.length - 1].errors)) { setProof(null)
setTypewriterInput(proof[proof.length - 1].command) loadGoals(rpcSess, uri, setProof, setCrashed)
}, [gameId, worldId, levelId])
/** 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]) }, [proof])
@@ -222,10 +152,17 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (params.uri == uri) { if (params.uri == uri) {
setProcessing(false) setProcessing(false)
loadAllGoals()
console.log('Received lean diagnostics')
console.log(params.diagnostics)
setInterimDiags(params.diagnostics)
//loadGoals(rpcSess, uri, setProof)
// TODO: loadAllGoals()
if (!hasErrors(params.diagnostics)) { if (!hasErrors(params.diagnostics)) {
//setTypewriterInput("") //setTypewriterInput("")
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition()) editor?.setPosition(editor?.getModel()?.getFullModelRange()?.getEndPosition())
} }
} else { } else {
// console.debug(`expected uri: ${uri}, got: ${params.uri}`) // console.debug(`expected uri: ${uri}, got: ${params.uri}`)
@@ -236,6 +173,14 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// loadAllGoals() // loadAllGoals()
}, [uri]); }, [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(() => { useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, { const myEditor = monaco.editor.create(inputRef.current!, {
value: typewriterInput, value: typewriterInput,
@@ -266,6 +211,7 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
}, },
overviewRulerBorder: false, overviewRulerBorder: false,
theme: 'vs-code-theme-converted', theme: 'vs-code-theme-converted',
fontFamily: "JuliaMono",
contextmenu: false contextmenu: false
}) })
@@ -294,7 +240,7 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
if (!oneLineEditor) return if (!oneLineEditor) return
// Run command when pressing enter // Run command when pressing enter
const l = oneLineEditor.onKeyUp((ev) => { const l = oneLineEditor.onKeyUp((ev) => {
if (ev.code === "Enter") { if (ev.code === "Enter" || ev.code === "NumpadEnter") {
runCommand() runCommand()
} }
}) })
@@ -306,7 +252,8 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
useEffect(() => { useEffect(() => {
console.debug(`time to update: ${uri} \n ${rpcSess}`) console.debug(`time to update: ${uri} \n ${rpcSess}`)
console.debug(rpcSess) console.debug(rpcSess)
loadAllGoals() // console.debug('LOAD ALL GOALS')
// TODO: loadAllGoals()
}, [rpcSess]) }, [rpcSess])
/** Process the entered command */ /** Process the entered command */
@@ -315,13 +262,14 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
runCommand() 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}> <form onSubmit={handleSubmit}>
<div className="typewriter-input-wrapper"> <div className="typewriter-input-wrapper">
<div ref={inputRef} className="typewriter-input" /> <div ref={inputRef} className="typewriter-input" />
</div> </div>
<button type="submit" disabled={processing} className="btn btn-inverted"> <button type="submit" disabled={processing} className="btn btn-inverted">
<FontAwesomeIcon icon={faWandMagicSparkles} /> Execute <FontAwesomeIcon icon={faWandMagicSparkles} />&nbsp;{t("Execute")}
</button> </button>
</form> </form>
</div> </div>
@@ -343,3 +291,14 @@ export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning (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)
}
}
+225 -120
View File
@@ -1,114 +1,68 @@
import * as React from 'react'; import * as React from 'react'
import { useState, useEffect } from 'react'; import { useState, useEffect, createContext, useContext } from 'react';
import '../css/inventory.css' import '../css/inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan, faCheck } from '@fortawesome/free-solid-svg-icons' import { faLock, faBan, faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'
import { faClipboard } from '@fortawesome/free-regular-svg-icons' import { faClipboard } from '@fortawesome/free-regular-svg-icons'
import { GameIdContext } from '../app'; import { useLoadDocQuery, InventoryTile, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api';
import Markdown from './markdown'; import { changedInventory, selectDifficulty, selectInventory } from '../state/progress';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
import { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store'; import { store } from '../state/store';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { t } from 'i18next';
import { NavButton } from './navigation';
import { LoadingIcon, Markdown } from './utils';
import { GameIdContext } from '../state/context';
import { useAppDispatch } from '../hooks';
export function Inventory({levelInfo, openDoc, lemmaTab, setLemmaTab, enableAll=false} :
{ /** Context which manages the inventory */
levelInfo: LevelInfo|InventoryOverview, const InventoryContext = createContext<{
openDoc: (props: {name: string, type: string}) => void, theoremTab: string,
lemmaTab: any, setTheoremTab: React.Dispatch<React.SetStateAction<string>>,
setLemmaTab: any, categoryTab: "tactic"|"theorem"|"definition",
enableAll?: boolean, setCategoryTab: React.Dispatch<React.SetStateAction<"tactic"|"theorem"|"definition">>,
docTile: InventoryTile,
setDocTile: React.Dispatch<React.SetStateAction<InventoryTile>>
}>({
theoremTab: null,
setTheoremTab: () => {},
categoryTab: "tactic",
setCategoryTab: () => {},
docTile: null,
setDocTile: () => {}
})
/**
*/
function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc } :
{ item: InventoryTile,
name: any,
displayName: any,
locked: any,
disabled: any,
newly: any,
showDoc: any
}) { }) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : <></>
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
// Note: This is somewhat a hack as the statement of theorems comes currently in the form
// `Namespace.statement_name (x y : Nat) : some type`
const title = locked ? t("Not unlocked yet") :
disabled ? t("Not available in this level") : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
return ( const { gameId, worldId, levelId } = React.useContext(GameIdContext)
<div className="inventory">
{/* TODO: Click on Tactic: show info
TODO: click on paste icon -> paste into command line */}
<h2>Tactics</h2>
{levelInfo?.tactics &&
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
}
<h2>Definitions</h2>
{levelInfo?.definitions &&
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
}
<h2>Theorems</h2>
{levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} level={levelInfo} enableAll={enableAll} tab={lemmaTab} setTab={setLemmaTab}/>
}
</div>
)
}
function InventoryList({items, docType, openDoc, tab=null, setTab=undefined, level=undefined, enableAll=false} :
{
items: InventoryTile[],
docType: string,
openDoc(props: {name: string, type: string}): void,
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
// been loaded. Is there a better way to observe this?
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const categorySet = new Set<string>() // local state to show checkmark after pressing the copy button
for (let item of items) {
categorySet.add(item.category)
}
const categories = Array.from(categorySet).sort()
// 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
// modi)
let inv: string[] = selectInventory(gameId)(store.getState())
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
return <>
{categories.length > 1 &&
<div className="tab-bar">
{categories.map((cat) =>
<div key={`category-${cat}`} className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`}
onClick={() => { setTab(cat) }}>{cat}</div>)}
</div>}
<div className="inventory-list">
{[...modifiedItems].sort(
// For lemas, sort entries `available > disabled > locked`
// otherwise alphabetically
(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} />
})
}
</div>
</>
}
function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
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" : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const handleClick = () => { const handleClick = () => {
if (enableAll || !locked) { // if ((difficulty == 0) || !locked) {
showDoc() showDoc()
} // }
} }
const copyItemName = (ev) => { const copyItemName = (ev) => {
@@ -120,7 +74,10 @@ function InventoryItem({item, name, displayName, locked, disabled, newly, showDo
ev.stopPropagation() ev.stopPropagation()
} }
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}> return <div className={`item ${className}` +
`${(difficulty == 0) ? ' enabled' : ''}` +
`${item.world == worldId && item.level == levelId - 1 ? ' recent' : ''}` +
`${item.world == worldId && item.level < levelId ? ' current-world' : ''}` } onClick={handleClick} title={title}>
{icon} {displayName} {icon} {displayName}
<div className="copy-button" onClick={copyItemName}> <div className="copy-button" onClick={copyItemName}>
{copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />} {copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />}
@@ -128,42 +85,190 @@ return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClic
</div> </div>
} }
export function Documentation({name, type, handleClose}) {
const gameId = React.useContext(GameIdContext) function InventoryList({ items, tab=null, setTab=()=>{} } :
const doc = useLoadDocQuery({game: gameId, type: type, name: name}) {
items: InventoryTile[],
tab?: string,
setTab?: React.Dispatch<React.SetStateAction<string>>
}) {
const { gameId, worldId, levelId } = React.useContext(GameIdContext)
const { setDocTile, categoryTab, setCategoryTab } = useContext(InventoryContext)
const difficulty = useSelector(selectDifficulty(gameId))
const inventory: string[] = selectInventory(gameId)(store.getState())
const [categories, setCategories] = useState<Array<string>>([])
const [modifiedItems, setModifiedItems] = useState<Array<InventoryTile>>([])
const [currentWorldItems, setCurrentWorldItems] = useState<Array<InventoryTile>>([])
useEffect(() => {
const categorySet = new Set<string>()
if (!items) {return}
for (let item of items) {
categorySet.add(item.category)
}
setCategories(Array.from(categorySet).sort())
// 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
// modi)
let _modifiedItems : InventoryTile[] = items?.map(tile => inventory.includes(tile.name) ? {...tile, locked: false} : tile)
setModifiedItems(_modifiedItems)
// Item(s) proved in the preceeding level
setCurrentWorldItems(_modifiedItems.filter(x => x.world == worldId && x.level < levelId))
// setRecentItems(_modifiedItems.filter(x => x.world == worldId && x.level == levelId - 1))
}, []) // TODO: had `items, inventory`
return <>
{ categories.length > 1 &&
<div className="tab-bar">
{categories.map((cat) => {
let hasNew = modifiedItems.filter(item => item.new && (cat == item.category)).length > 0
return <div key={`category-${cat}`} className={`tab${cat == (tab ?? categories[0]) ? " active": ""}${hasNew ? ' new': ''}${currentWorldItems.map(x => x.category).includes(cat) ? ' recent': ''}`}
onClick={() => { setTab(cat) }}>{cat}</div>})}
</div>}
<div className="inventory-list">
{[...modifiedItems].sort(
// alternative approach:
// // For theorems, sort entries `available > disabled > locked`
// // otherwise alphabetically
// (x, y) => +(categoryTab == "theorem") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName)
// sort alphabetically
(x, y) => 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={() => {setDocTile(item)}}
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
disabled={item.disabled}
newly={item.new} />
})
}
</div>
</>
}
/** The `Inventory` shows all items present in the game sorted by item type. */
export function Inventory () {
const { t } = useTranslation()
const { gameId, worldId, levelId } = React.useContext(GameIdContext)
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
let { theoremTab, setTheoremTab, categoryTab, setCategoryTab } = useContext(InventoryContext)
/** Helper function to find if a list of tiles comprises any new elements. */
function containsNew(tiles: InventoryTile[]) {
return tiles?.filter(item => item.new).length > 0
}
return (
<div className="inventory">
{ levelInfo.data ? <>
<div className="tab-bar major">
<div className={`tab${(categoryTab == "theorem") ? " active": ""}${containsNew(levelInfo.data?.theorems) ? " new" : ""}`} onClick={() => { setCategoryTab("theorem") }}>{t("Theorems")}</div>
<div className={`tab${(categoryTab == "tactic") ? " active": ""}${containsNew(levelInfo.data?.tactics) ? " new" : ""}`} onClick={() => { setCategoryTab("tactic") }}>{t("Tactics")}</div>
<div className={`tab${(categoryTab == "definition") ? " active": ""}${containsNew(levelInfo.data?.definitions) ? " new" : ""}`} onClick={() => { setCategoryTab("definition") }}>{t("Definitions")}</div>
</div>
{ (categoryTab == "theorem") &&
<InventoryList items={levelInfo.data?.theorems} tab={theoremTab} setTab={setTheoremTab} />
}
{ (categoryTab == "tactic") &&
<InventoryList items={levelInfo.data?.tactics} />
}
{ (categoryTab == "definition") &&
<InventoryList items={levelInfo.data?.definitions} />
}
</> : <LoadingIcon /> }
</div>
)
}
/** The `documentation` */
export function Documentation() {
const dispatch = useAppDispatch()
const { gameId } = useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
// const docEntry = useLoadDocQuery({game: gameId, type: type, name: name})
let { docTile, setDocTile } = useContext(InventoryContext)
const docEntry = useLoadDocQuery({game: gameId, name: docTile.name})
let inv: string[] = selectInventory(gameId)(store.getState())
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() { setDocTile(null) }
return <div className="documentation"> return <div className="documentation">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div> <NavButton
<h1 className="doc">{doc.data?.displayName}</h1> icon={faXmark}
<p><code>{doc.data?.statement}</code></p> onClick={closeInventoryDoc}
{/* <code>docstring: {doc.data?.docstring}</code> */} inverted={true} />
<Markdown>{doc.data?.content}</Markdown> { difficulty == 1 && docTile.locked &&
<NavButton
icon={faLock}
title={t("Unlock this item!")}
onClick={() => {
console.log(`Adding '${docTile.name}' to the inventory.`)
dispatch(changedInventory({ game: gameId, inventory: [...inv, docTile.name] }))
closeInventoryDoc() // note: closing seems better than keeping it open without the lock disappearing
}}
className="lock"
inverted={true} />
}
<h1 className="doc">{docTile.displayName}</h1>
<p><code>{docEntry.data?.statement}</code></p>
<Markdown>{t(docEntry.data?.content, {ns: gameId})}</Markdown>
{/* TODO: The condition below should be updated so that the section
is displayed whenever it's non-empty. */}
{docTile.proven && <>
<h2>Further details</h2>
<ul>
{docTile.proven && <li>Proven in: <a href={`#/${gameId}/world/${docTile.world}/level/${docTile.level}`}>{docTile.world} level {docTile.level}</a></li>}
</ul>
</>
}
</div> </div>
} }
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */ /** The panel showing the user's inventory with tactics, definitions, and theorems */
export function InventoryPanel({levelInfo, visible = true}) { export function InventoryPanel({visible = true}) {
const gameId = React.useContext(GameIdContext) const {gameId, worldId, levelId} = React.useContext(GameIdContext)
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const [lemmaTab, setLemmaTab] = useState(levelInfo?.lemmaTab) const inventory = useLoadInventoryOverviewQuery({game: gameId})
const [theoremTab, setTheoremTab] = useState<string>(null)
const [categoryTab, setCategoryTab] = useState<"tactic"|"theorem"|"definition">('tactic')
// The inventory is overlayed by the doc entry of a clicked item // The inventory is overlayed by the doc entry of a clicked item
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) const [docTile, setDocTile] = useState<InventoryTile>(null)
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() {setInventoryDoc(null)}
useEffect(() => { useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading. // If the level specifies `TheoremTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch. // `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (levelInfo?.lemmaTab) { if (levelInfo?.data?.theoremTab) {
setLemmaTab(levelInfo?.lemmaTab) setTheoremTab(levelInfo?.data?.theoremTab)
}}, [levelInfo]) }}, [levelInfo])
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}> return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
{inventoryDoc ? <InventoryContext.Provider value={{theoremTab, setTheoremTab, categoryTab, setCategoryTab, docTile, setDocTile }}>
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/> {docTile ?
<Documentation />
: :
<Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true} lemmaTab={lemmaTab} setLemmaTab={setLemmaTab}/> <Inventory />
} }
</InventoryContext.Provider>
</div> </div>
} }
// HERE: next up: locked items should not be disabled!
+58 -99
View File
@@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { Trans, useTranslation } from 'react-i18next';
import '@fontsource/roboto/300.css'; import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css'; import '@fontsource/roboto/400.css';
@@ -9,33 +10,19 @@ import '@fontsource/roboto/700.css';
import '../css/landing_page.css' import '../css/landing_page.css'
import bgImage from '../assets/bg.jpg' import bgImage from '../assets/bg.jpg'
import Markdown from './markdown';
import {PrivacyPolicyPopup} from './popup/privacy_policy'
import { GameTile, useGetGameInfoQuery } from '../state/api' import { GameTile, useGetGameInfoQuery } from '../state/api'
import path from 'path'; import path from 'path';
const flag = { import ReactCountryFlag from 'react-country-flag';
'Dutch': '🇳🇱', import lean4gameConfig from '../config.json'
'English': '🇬🇧', import i18next from 'i18next';
'French': '🇫🇷', import { useContext } from 'react';
'German': '🇩🇪', import { PopupContext } from './popup/popup';
'Italian': '🇮🇹', import { Flag } from './flag';
'Spanish': '🇪🇸', import { Markdown } from './utils';
}
function GithubIcon({url='https://github.com'}) {
return <div className="github-link">
<a title="view the Lean game server on Github" href={url}>
<svg height="24" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="24" className="">
<path fill="#fff" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
</a>
</div>
}
function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) { function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) {
let { t } = useTranslation()
let navigate = useNavigate(); let navigate = useNavigate();
const routeChange = () =>{ const routeChange = () =>{
navigate(gameId); navigate(gameId);
@@ -47,29 +34,34 @@ function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) {
return <div className="game" onClick={routeChange}> return <div className="game" onClick={routeChange}>
<div className="wrapper"> <div className="wrapper">
<div className="title">{data.title}</div> <div className="title">{t(data.title, { ns: gameId })}</div>
<div className="short-description">{data.short} <div className="short-description">{t(data.short, { ns: gameId })}
</div> </div>
{ data.image ? <img className="image" src={path.join("data", gameId, data.image)} alt="" /> : <div className="image"/> } { 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 className="long description"><Markdown>{t(data.long, { ns: gameId })}</Markdown></div>
</div> </div>
<table className="info"> <table className="info">
<tbody> <tbody>
<tr> <tr>
<td title="consider playing these games first.">Prerequisites</td> <td title="consider playing these games first.">{t("Prerequisites")}</td>
<td><Markdown>{data.prerequisites.join(', ')}</Markdown></td> <td><Markdown>{t(data.prerequisites.join(', '), { ns: gameId })}</Markdown></td>
</tr> </tr>
<tr> <tr>
<td>Worlds</td> <td>{t("Worlds")}</td>
<td>{data.worlds}</td> <td>{data.worlds}</td>
</tr> </tr>
<tr> <tr>
<td>Levels</td> <td>{t("Levels")}</td>
<td>{data.levels}</td> <td>{data.levels}</td>
</tr> </tr>
<tr> <tr className="languages">
<td>Language</td> <td>{t("Language")}</td>
<td title={`in ${data.languages.join(', ')}`}>{data.languages.map((lan) => flag[lan]).join(', ')}</td>
<td>
{data.languages.map((lang) => (
<Flag key={lang} iso={lang} showTitle={true} />
))}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -80,84 +72,43 @@ function Tile({gameId, data}: {gameId: string, data: GameTile|undefined}) {
function LandingPage() { function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setPopupContent } = useContext(PopupContext)
const [impressum, setImpressum] = React.useState(false); const { t, i18n } = useTranslation()
const openImpressum = () => setImpressum(true);
const closeImpressum = () => setImpressum(false);
// const [allGames, setAllGames] = React.useState([]) // Load the namespaces of all games
// const [allTiles, setAllTiles] = React.useState([]) // TODO: should `allGames` contain game-ids starting with `g/`?
i18next.loadNamespaces(lean4gameConfig.allGames.map(id => `g/${id}`))
// const getTiles=()=>{ let allTiles = lean4gameConfig.allGames.map((gameId) => {
// fetch('featured_games.json', { let q = useGetGameInfoQuery({game: `g/${gameId}`})
// headers : { return q.data?.tile
// '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",
]
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
return <div className="landing-page"> return <div className="landing-page">
<header style={{backgroundImage: `url(${bgImage})`}}> <header style={{backgroundImage: `url(${bgImage})`}}>
<nav>
<GithubIcon url="https://github.com/leanprover-community/lean4game"/>
</nav>
<div id="main-title"> <div id="main-title">
<h1>Lean Game Server</h1> <h1>{t("Lean Game Server")}</h1>
<p> <p>
<Trans>
A repository of learning games for the A repository of learning games for the
proof assistant <a target="_blank" href="https://leanprover-community.github.io/">Lean</a> <i>(Lean 4)</i> and proof assistant <a target="_blank" href="https://leanprover-community.github.io/">Lean</a> <i>(Lean 4)</i> and
its mathematical library <a target="_blank" href="https://github.com/leanprover-community/mathlib4">mathlib</a> its mathematical library <a target="_blank" href="https://github.com/leanprover-community/mathlib4">mathlib</a>
</Trans>
</p> </p>
</div> </div>
</header> </header>
<React.Suspense>
<div className="game-list"> <div className="game-list">
{allTiles.length == 0 ?
<p>No Games loaded. Use <a>http://localhost:3000/#/g/local/FOLDER</a> to open a {allTiles.filter(x => x != null).length == 0 ?
<p>
<Trans>
No Games loaded. Use <a>http://localhost:3000/#/g/local/FOLDER</a> to open a
game directly from a local folder. game directly from a local folder.
</Trans>
</p> </p>
: allGames.map((id, i) => ( : lean4gameConfig.allGames.map((id, i) => (
<Tile <Tile
key={id} key={id}
gameId={`g/${id}`} gameId={`g/${id}`}
@@ -166,9 +117,11 @@ function LandingPage() {
)) ))
} }
</div> </div>
</React.Suspense>
<section> <section>
<div className="wrapper"> <div className="wrapper">
<h2>Development notes</h2> <h2>{t("Development notes")}</h2>
<Trans>
<p> <p>
As this server runs lean on our university machines, it has a limited capacity. As this server runs lean on our university machines, it has a limited capacity.
Our current estimate is about 70 simultaneous games. Our current estimate is about 70 simultaneous games.
@@ -179,11 +132,13 @@ function LandingPage() {
file a <a target="_blank" href="https://github.com/leanprover-community/lean4game/issues">GitHub Issue</a> about file a <a target="_blank" href="https://github.com/leanprover-community/lean4game/issues">GitHub Issue</a> about
any problems you experience! any problems you experience!
</p> </p>
</Trans>
</div> </div>
</section> </section>
<section> <section>
<div className="wrapper"> <div className="wrapper">
<h2>Adding new games</h2> <h2>{t("Adding new games")}</h2>
<Trans>
<p> <p>
If you are considering writing your own game, you should use If you are considering writing your own game, you should use
the <a target="_blank" href="https://github.com/hhu-adam/GameSkeleton">GameSkeleton Github Repo</a> as the <a target="_blank" href="https://github.com/hhu-adam/GameSkeleton">GameSkeleton Github Repo</a> as
@@ -198,23 +153,27 @@ function LandingPage() {
</p> </p>
<p> <p>
Featured games on this page are added manually. Featured games on this page are added manually.
Please get in contact and we-ll happily add yours. Please get in contact and we'll happily add yours.
</p> </p>
</Trans>
</div> </div>
</section> </section>
<section> <section>
<div className="wrapper"> <div className="wrapper">
<h2>Funding</h2> <h2>{t("Funding")}</h2>
<p> <p>
<Trans>
This server has been developed as part of the This server has been developed as part of the
project <a target="_blank" href="https://hhu-adam.github.io">ADAM : Anticipating the Digital Age of Mathematics</a> at project <a target="_blank" href="https://hhu-adam.github.io">ADAM : Anticipating the Digital Age of Mathematics</a> at
Heinrich-Heine-Universität in Düsseldorf. Heinrich-Heine-Universität in Düsseldorf.
</Trans>
</p> </p>
</div> </div>
</section> </section>
<footer> <footer>
<a className="link" onClick={openImpressum}>Impressum</a> {/* Do not translate "Impressum", it's needed for German GDPR */}
{impressum? <PrivacyPolicyPopup handleClose={closeImpressum} />: null} <a className="link" onClick={() => {setPopupContent("impressum")}}>{t("Impressum")}</a>
<a className="link" onClick={() => {setPopupContent("privacy")}}>{t("Privacy Policy")}</a>
</footer> </footer>
</div> </div>
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
import * as React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css' // `rehype-katex` does not import the CSS for you
import gfm from "remark-gfm";
function Markdown(props) {
const newProps = {
...props,
remarkPlugins: [...props.remarkPlugins ?? [], remarkMath, gfm],
rehypePlugins: [...props.remarkPlugins ?? [], rehypeKatex],
};
return (
<ReactMarkdown {...newProps} className="markdown" />
);
}
export default Markdown
+1 -1
View File
@@ -5,7 +5,7 @@ import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css'; import '@fontsource/roboto/700.css';
import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material'; import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material';
import Markdown from './markdown'; import { Markdown } from './utils';
function Message({ isOpen, content, close }) { function Message({ isOpen, content, close }) {
+363
View File
@@ -0,0 +1,363 @@
import * as React from 'react'
import { createContext, useContext, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
faCircleInfo, faTerminal, faGear, IconDefinition, faShield } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext, PageContext, PreferencesContext } from "../state/context"
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
import { downloadProgress } from './popup/erase'
import { useTranslation } from 'react-i18next'
import '../css/navigation.css'
import { PopupContext } from './popup/popup'
import { useSelector } from 'react-redux'
import { selectCompleted, selectDifficulty, selectProgress, selectReadIntro } from '../state/progress'
import lean4gameConfig from '../config.json'
import { Flag } from './flag'
import { useAppSelector } from '../hooks'
/** SVG github icon */
function GithubIcon () {
return <svg className="svg-inline--fa" height="24" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="24" >
<path fill="#fff" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
}
/** A button to appear in the navigation (both, top bar or dropdown). */
export const NavButton: React.FC<{
icon?: IconDefinition
iconElement?: JSX.Element
text?: string
onClick?: React.MouseEventHandler<HTMLAnchorElement>
title?: string
href?: string
inverted?: boolean
disabled?: boolean
className?: string
}> = ({icon, iconElement, text, onClick=()=>{}, title, href=null, inverted=false, disabled=false, className=''}) => {
return <a className={`${className} nav-button btn${inverted?' btn-inverted':''}${disabled?' btn-disabled':''}`} onClick={disabled?null:onClick} href={disabled?null:href} title={title}>
{iconElement ?? (icon && <FontAwesomeIcon icon={icon} />)}{text && <>&nbsp;{text}</>}
</a>
}
/** Context which manages the dropdown navigation */
const NavigationContext = createContext<{
navOpen: boolean,
setNavOpen: React.Dispatch<React.SetStateAction<boolean>>
}>({navOpen: false, setNavOpen: () => {}})
/** Content of the navigation during game selection. */
function NavigationLandingPage () {
return <div className="nav-content">
<div className="nav-title-left"></div>
<div className="nav-title-middle"></div>
<div className="nav-title-right"></div>
</div>
}
/** Content of the navigation on Desktop during world selection. */
function DesktopNavigationOverview () {
const { t } = useTranslation()
const { gameId } = useContext(GameIdContext)
const { setPopupContent } = useContext(PopupContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
return <div className="nav-content">
<div className="nav-title-left">
<NavButton
text={t("Rules")}
onClick={() => {setPopupContent("rules")}}
inverted={true} />
</div>
<div className="nav-title-middle">
<span className="nav-title">{t(gameInfo.data?.title, {ns: gameId})}</span>
</div>
<div className="nav-title-right"></div>
</div>
}
/** Content of the navigation on Mobile during world selection. */
function MobileNavigationOverview () {
const { t } = useTranslation()
const {page, setPage} = useContext(PageContext)
const { setPopupContent } = useContext(PopupContext)
const { gameId, worldId } = useContext(GameIdContext)
const readIntro = useSelector(selectReadIntro(gameId, worldId))
return <div className="nav-content">
<div className="nav-title-left">
<NavButton
text={t("Rules")}
onClick={() => {setPopupContent("rules")}}
inverted={true} />
</div>
<div className="nav-title-middle">
<span className="nav-title">
</span>
</div>
<div className="nav-title-right">
{page > 0 &&
<NavButton
text={page == 1 ? t("Intro") : null}
icon={page == 1 ? null : faBookOpen}
onClick={() => setPage(page - 1)}
inverted={true} />
}
{ page < 2 &&
<NavButton
text={(page==0) ? t("Start") : null}
icon={(page==0) ? null : faBook}
onClick={() => setPage(page+1)}
disabled={!readIntro}
inverted={true} />
}
</div>
</div>
}
/** Content of the navigation on Desktop in a level. */
function DesktopNavigationLevel () {
const { t } = useTranslation()
const { gameId, worldId, levelId } = useContext(GameIdContext)
const { typewriterMode, setTypewriterMode, lockEditorMode } = useContext(PageContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const readIntro = useSelector(selectReadIntro(gameId, worldId))
const worldTitle = gameInfo.data?.worlds.nodes[worldId]?.title
const levelTitle = ((levelId == 0) ?
t("Introduction") :
(
t("Level") +
` ${levelId}` +
(gameInfo.data?.worldSize[worldId] ? ` / ${gameInfo.data?.worldSize[worldId]}` : '') +
(levelInfo.data?.title ? ` : ${t(levelInfo?.data?.title, {ns: gameId})}` : '')
)
)
return <div className="nav-content">
<div className="nav-title-left">
<span className="nav-title">{worldTitle ? `${t(worldTitle, {ns: gameId})}` : '' /* ${t("World")}: */ }
</span>
</div>
<div className="nav-title-middle">
<span className="nav-title">
{ levelTitle
}
</span>
</div>
<div className="nav-title-right" >
{ levelId > 0 &&
<NavButton
icon={faArrowLeft}
text={t("Previous")}
inverted={true}
href={`#/${gameId}/world/${worldId}/level/${levelId - 1}`} />
}
{ levelId == gameInfo.data?.worldSize[worldId] ?
<NavButton
icon={faHome}
text={t("Home")}
inverted={true}
disabled={levelId > 0 && difficulty == 2 && !completed}
href={`#/${gameId}`} /> :
<NavButton
icon={faArrowRight}
text={levelId == 0 ? t("Start") : t("Next")} inverted={true}
disabled={levelId == 0 ? !readIntro : (difficulty == 2 && !completed)}
href={`#/${gameId}/world/${worldId}/level/${levelId + 1}`} />
}
</div>
</div>
}
/** Content of the navigation on Mobile in a level. */
function MobileNavigationLevel () {
const { t } = useTranslation()
const {gameId, worldId, levelId} = useContext(GameIdContext)
const {page, setPage} = useContext(PageContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
let title = worldId ?
` ${levelId} / ${gameInfo.data?.worldSize[worldId]}`+ (levelInfo?.data?.title && ` : ${t(levelInfo?.data?.title, {ns: gameId})}`)
:
''
return <div className="nav-content">
<div className="nav-title-left"></div>
<div className="nav-title-middle">
<span className="nav-title">
{title}
</span>
</div>
<div className="nav-title-right">
<NavButton
icon={(page == 1) ? faBook : faBookOpen}
onClick={() => setPage((page == 1) ? 2 : 1)}
inverted={true} />
</div>
</div>
}
/** The skeleton of the navigation which is the same across all layouts. */
export function Navigation () {
const { t, i18n } = useTranslation()
const { gameId, worldId, levelId } = useContext(GameIdContext)
const { mobile, language, setLanguage } = useContext(PreferencesContext)
const { setPopupContent } = useContext(PopupContext)
const { typewriterMode, setTypewriterMode, lockEditorMode } = useContext(PageContext)
const gameProgress = useSelector(selectProgress(gameId))
const gameInfo = useGetGameInfoQuery({game: gameId})
const levelInfo = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const readIntro = useSelector(selectReadIntro(gameId, worldId))
const [navOpen, setNavOpen] = useState(false)
const [langNavOpen, setLangNavOpen] = useState(false)
function toggleNav () {setNavOpen(!navOpen); setLangNavOpen(false)}
function toggleLangNav () {setLangNavOpen(!langNavOpen); setNavOpen(false)}
/** toggle input mode if allowed */
function toggleInputMode(ev: React.MouseEvent) {
if (!lockEditorMode) {
setTypewriterMode(!typewriterMode)
console.log('test')
}
}
return <nav>
<NavigationContext.Provider value={{navOpen, setNavOpen}}>
{ gameId && <>
<NavButton
icon={worldId ? faHome : faGlobe}
title={worldId ? t("home") : t("back to games selection")}
href={worldId ? `#/${gameId}` : `#`} />
</>}
{ gameId ?
worldId ?
(mobile ? <MobileNavigationLevel /> : <DesktopNavigationLevel />) :
(mobile ? <MobileNavigationOverview /> : <DesktopNavigationOverview />) :
<NavigationLandingPage />
}
{ !gameId &&
<NavButton
iconElement={<GithubIcon />}
title={t("view the Lean game server on Github")}
href='https://github.com/leanprover-community/lean4game' />
}
{(!gameId || gameInfo.data?.tile?.languages.length > 1) &&
// Language button only visible if the game exists in `>1` languages
<NavButton
iconElement={langNavOpen ? null : <Flag iso={i18n.language} />}
icon={langNavOpen ? faXmark : null}
title={langNavOpen ? t('close language menu') : t('open language menu')}
onClick={toggleLangNav}
/>
}
<NavButton
icon={navOpen ? faXmark : faBars}
title={navOpen ? t('close menu') : t('open menu')}
onClick={toggleNav} />
{ langNavOpen &&
<div className='dropdown' onClick={toggleLangNav} >
{gameId && gameInfo.data?.tile?.languages ?
// Show all languages the game is available in
gameInfo.data?.tile?.languages.map(iso =>
<NavButton
key={`lang-selection-${iso}`}
iconElement={<Flag iso={iso} />}
text={lean4gameConfig.newLanguages[iso]?.name}
onClick={() => {setLanguage(iso)}}
inverted={true} />) :
// Show all languages the interface is available in (e.g. landing page)
Object.entries(lean4gameConfig.newLanguages).map(([iso, val]) =>
<NavButton
key={`lang-selection-${iso}`}
iconElement={<Flag iso={iso} />}
text={lean4gameConfig.newLanguages[iso]?.name}
onClick={() => {setLanguage(iso)}}
inverted={true} />)
}
</div>
}
{ navOpen &&
<div className='dropdown' onClick={toggleNav} >
{ gameId && <>
{ mobile && (levelId == gameInfo.data?.worldSize[worldId] ?
<NavButton
icon={faHome}
text={t("Home")}
inverted={true}
disabled={levelId > 0 && difficulty == 2 && !completed}
href={`#/${gameId}`} /> :
<NavButton
icon={faArrowRight}
text={levelId == 0 ? t("Start") : t("Next")} inverted={true}
disabled={levelId == 0 ? !readIntro : (difficulty == 2 && !completed)}
href={`#/${gameId}/world/${worldId}/level/${levelId + 1}`} />
)}
{mobile && levelId > 0 &&
<NavButton
icon={faArrowLeft}
text={t("Previous")}
inverted={true}
href={`#/${gameId}/world/${worldId}/level/${levelId - 1}`} />
}
{ mobile && levelId > 0 &&
<NavButton
icon={(typewriterMode && !lockEditorMode) ? faCode : faTerminal}
inverted={true}
text={typewriterMode ? t("Editor Mode") : t("Typewriter Mode")}
disabled={levelId == 0 || lockEditorMode}
onClick={(ev) => toggleInputMode(ev)}
title={lockEditorMode ? t("Editor mode is enforced!") : typewriterMode ? t("Editor mode") : t("Typewriter mode")} />
}
<NavButton
icon={faCircleInfo}
text={t("Game Info")}
onClick={() => {setPopupContent("info")}}
inverted={true} />
<NavButton
icon={faEraser}
text={t("Erase")}
onClick={() => {setPopupContent("erase")}}
inverted={true} />
<NavButton
icon={faDownload}
text={t("Download")}
onClick={() => {downloadProgress(gameId, gameProgress)}}
inverted={true} />
<NavButton
icon={faUpload}
text={t("Upload")}
onClick={() => {setPopupContent("upload")}}
inverted={true} />
</>}
<NavButton
icon={faCircleInfo}
text={t("Impressum")}
onClick={() => {setPopupContent("impressum")}}
inverted={true} />
<NavButton
icon={faShield}
text={t("Privacy Policy")}
onClick={() => {setPopupContent("privacy")}}
inverted={true} />
<NavButton
icon={faGear}
text={t("Preferences")}
onClick={() => {setPopupContent("preferences")}}
inverted={true} />
</div>
}
</NavigationContext.Provider>
</nav>
}
+52 -27
View File
@@ -1,17 +1,18 @@
/**
* @fileOverview
*/
import * as React from 'react' import * as React from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { GameIdContext } from '../../app'
import { useAppDispatch } from '../../hooks' import { useAppDispatch } from '../../hooks'
import { deleteProgress, selectProgress } from '../../state/progress' import { deleteLevelProgress, deleteProgress, deleteWorldProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree' import { downloadFile } from '../world_tree'
import { Button } from '../button' import { Button } from '../utils'
import { Trans, useTranslation } from 'react-i18next'
import { useContext } from 'react'
import { PopupContext } from './popup'
import { GameIdContext, PageContext } from '../../state/context'
/** download the current progress (i.e. what's saved in the browser store) */ /** download the current progress (i.e. what's saved in the browser store) */
export function downloadProgress(gameId: string, gameProgress: any, ev: React.MouseEvent) { export function downloadProgress(gameId: string, gameProgress) {
ev.preventDefault()
// ev.preventDefault()
downloadFile({ downloadFile({
data: JSON.stringify(gameProgress, null, 2), data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`, fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
@@ -24,36 +25,60 @@ export function downloadProgress(gameId: string, gameProgress: any, ev: React.Mo
* `handleClose` is the function to close it again because it's open/closed state is * `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element. * controlled by the containing element.
*/ */
export function ErasePopup ({handleClose}) { export function ErasePopup () {
const gameId = React.useContext(GameIdContext) let { t } = useTranslation()
const { gameId, worldId, levelId } = React.useContext(GameIdContext)
const { setPage } = useContext(PageContext)
const gameProgress = useSelector(selectProgress(gameId)) const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { setPopupContent } = useContext(PopupContext)
const eraseProgress = () => { const eraseProgress = (ev) => {
dispatch(deleteProgress({game: gameId})) dispatch(deleteProgress({game: gameId}))
handleClose() setPopupContent(null)
setPage(0)
// ev.preventDefault() // TODO: this is a hack to prevent the buttons below from opening a link
}
function eraseLevel (ev) {
dispatch(deleteLevelProgress({game: gameId, world: worldId, level: levelId}))
setPopupContent(null)
ev.preventDefault()
}
function eraseWorld (ev) {
dispatch(deleteWorldProgress({game: gameId, world: worldId}))
setPopupContent(null)
ev.preventDefault()
} }
const downloadAndErase = (ev) => { const downloadAndErase = (ev) => {
downloadProgress(gameId, gameProgress, ev) downloadProgress(gameId, gameProgress)
eraseProgress() eraseProgress(ev)
} }
return <div className="modal-wrapper"> return <>
<div className="modal-backdrop" onClick={handleClose} /> <h2>{t("Delete Progress?")}</h2>
<div className="modal"> <Trans>
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Delete Progress?</h2>
<p>Do you want to delete your saved progress irreversibly?</p> <p>Do you want to delete your saved progress irreversibly?</p>
</Trans>
<div className='settings-buttons'>
<Button onClick={levelId && eraseLevel} to="" disabled={!levelId} >{t("Delete this Level")}</Button>
<Button onClick={worldId && eraseWorld} to="" disabled={!worldId} >{t("Delete this World")}</Button>
<Button onClick={eraseProgress} to={`/${gameId}/`}>{t("Delete Everything")}</Button>
</div>
<Trans>
<p> <p>
(This deletes your proofs and your collected inventory. Deleting everything will delete all your proofs and your collected inventory! It's recommended
Saves from other games are not deleted.) to download your progress first.
</p> </p>
<p>
<Button onClick={eraseProgress} to="">Delete</Button> (Saves from other games are not deleted.)
<Button onClick={downloadAndErase} to="">Download & Delete</Button> </p>
<Button onClick={handleClose} to="">Cancel</Button> </Trans>
</div> <div className='settings-buttons'>
<Button onClick={downloadAndErase} to={`/${gameId}/`}>{t("Download & Delete everything")}</Button>
<Button onClick={(ev) => {setPopupContent(null); ev.preventDefault()}} to="">{t("Cancel")}</Button>
</div> </div>
</>
} }
-23
View File
@@ -1,23 +0,0 @@
/**
* @fileOverview
*/
import * as React from 'react'
import { Typography } from '@mui/material'
import Markdown from '../markdown'
/** Pop-up that is displaying the Game Info.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function InfoPopup ({info, handleClose}: {info: string, handleClose: () => void}) {
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="welcome-text">
<Markdown>{info}</Markdown>
</Typography>
</div>
</div>
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next';
/** Pop-up that is displayed when opening the privacy policy. */
export function ImpressumPopup () {
let {t, i18n} = useTranslation()
function content (lng = i18n.language) {
const tt = i18n.getFixedT(lng);
return <Trans t={tt} >
<h2>Impressum</h2>
<p>
<strong>Contact:</strong><br />
Marcus Zibrowius, Jon Eugster<br />
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
Universitätsstr. 1<br />
40225 Düsseldorf<br />
Germany<br />
+49 211 81-14690<br />
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team">Contact Details</a>
</p>
<p>
<strong>Legal form:</strong><br />
The Heinrich Heine University Düsseldorf is a corporation under public law. It is legally represented by the Rector Prof. Dr. Anja Steinbeck. The responsible supervisory authority is the Ministry of Culture and Science of North Rhine-Westphalia, Völklinger Straße 49, 40221 Düsseldorf.
</p>
<p>
<strong>VAT identification number:</strong><br />
according to §27a Sales Tax Act<br />
DE 811222416
</p>
<p><a href="https://www.hhu.de/impressum" target="_blank">Impressum HHU</a></p>
</Trans>
}
return <>
{i18n.language != 'en' && <>
<p><i>(English version below)</i></p>
{content()}
<hr />
</>}
{content('en')}
</>
}
+56
View File
@@ -0,0 +1,56 @@
import * as React from 'react'
import { Typography } from '@mui/material'
import { Markdown } from '../utils'
import { Trans, useTranslation } from 'react-i18next'
import { useGetGameInfoQuery } from '../../state/api'
import { GameIdContext } from '../../state/context'
/** Pop-up that is displaying the Game Info.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function InfoPopup () {
let { t } = useTranslation()
const {gameId} = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
return <>
<Typography variant="body1" component="div" className="welcome-text">
<Markdown>{t(gameInfo.data?.info, {ns: gameId})}</Markdown>
<hr />
<Trans>
<h2>Progress saving</h2>
<p>
The game stores your progress in your local browser storage. If you delete it, your progress will be lost!<br />
Warning: In most browsers, deleting cookies will also clear the local storage (or "local site data").
Make sure to download your game progress first!
</p>
<h2>Accessibility</h2>
<p>
If you experience any accessibilty barriers, please get in contact with us!
We are dedicated to address such barriers to the best of our abilities.
</p>
<h2>Development</h2>
<p>The game engine has been created by <strong>Alexander Bentkamp</strong>, <strong>Jon Eugster</strong>.
On a prototype by <strong>Patrick Massot</strong>.
</p>
<p>
The source code of this Lean game engine
is <a href="https://github.com/leanprover-community/lean4game" target="_blank">available on Github</a>.
If you experience any problems, please
file an <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">Issue on Github</a> or
get directly in contact.
</p>
<h2>Funding</h2>
<p>
The game engine has been developed as part of the
project <a href="https://hhu-adam.github.io/" target="_blank">ADAM: Anticipating the Digital
Age of Mathematics</a> at
Heinrich-Heine-Universität Düsseldorf. It is funded by
the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.
</p>
</Trans>
</Typography>
</>
}
+58
View File
@@ -0,0 +1,58 @@
import * as React from 'react'
import { useContext } from 'react'
import { PrivacyPolicyPopup } from './privacy'
import { ImpressumPopup } from './impressum'
import { InfoPopup } from './info'
import { ErasePopup } from './erase'
import { PreferencesPopup } from './preferences'
import { UploadPopup } from './upload'
import { RulesPopup } from './rules'
import '../../css/popup.css'
import { NavButton } from '../navigation'
import { faXmark } from '@fortawesome/free-solid-svg-icons'
/** The context which manages if a popup is shown.
* If `popupContent` is `null`, the popup is closed.
*/
export const PopupContext = React.createContext<{
popupContent: string,
setPopupContent: React.Dispatch<React.SetStateAction<string>>
}>({
popupContent: null,
setPopupContent: () => {}
})
/** To create a new Popup, one needs to add its content as `React.JSX.Element` here
* and then call `setPopupConent(key)` at the place where to popup should be opened.
*
* TODO: The drawback of this design is that there is no check for key missmatches.
* How could that be achieved?
*/
export const Popups = {
"erase": <ErasePopup />,
"impressum": <ImpressumPopup />,
"info": <InfoPopup />,
"preferences": <PreferencesPopup />,
"privacy": <PrivacyPolicyPopup />,
"rules": <RulesPopup />,
"upload": <UploadPopup />,
}
/** The skeleton for the popups. */
export function Popup () {
const {popupContent, setPopupContent} = useContext(PopupContext)
function closePopup() {
setPopupContent(null)
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={closePopup} />
<div className="modal">
{/* <NavButton icon={faXmark}
onClick={closePopup}
inverted={true} /> */}
<div className="codicon codicon-close modal-close" onClick={closePopup}></div>
{Popups[popupContent]}
</div>
</div>
}
+100 -32
View File
@@ -1,55 +1,123 @@
import * as React from 'react' import * as React from 'react'
import { Input, Typography } from '@mui/material' import { Input, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material'
import Markdown from '../markdown' import { Markdown } from '../utils'
import Switch from '@mui/material/Switch'; import { Switch, Button, ButtonGroup } from '@mui/material';
import Box from '@mui/material/Box';
import Slider from '@mui/material/Slider';
import lean4gameConfig from '../../config.json'
import FormControlLabel from '@mui/material/FormControlLabel'; import FormControlLabel from '@mui/material/FormControlLabel';
import { IMobileContext } from "../infoview/context" import { IPreferencesContext, PreferencesContext } from "../../state/context"
import ReactCountryFlag from 'react-country-flag';
import { useTranslation } from 'react-i18next';
interface PreferencesPopupProps extends IMobileContext{ export function PreferencesPopup () {
handleClose: () => void let { t } = useTranslation()
const {layout, isSavePreferences, language, setLayout, setIsSavePreferences, setLanguage} = React.useContext(PreferencesContext)
const marks = [
{
value: 0,
label: t('Mobile'),
key: "mobile"
},
{
value: 1,
label: t('Auto'),
key: "auto"
},
{
value: 2,
label: t('Desktop'),
key: "desktop"
},
];
const handlerChangeLayout = (_: Event, value: number) => {
setLayout(marks[value].key as IPreferencesContext["layout"])
} }
export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile, handleClose }: PreferencesPopupProps) { const handlerChangeLanguage = (ev: SelectChangeEvent<string>) => {
return <div className="modal-wrapper"> setLanguage(ev.target.value as IPreferencesContext["language"])
<div className="modal-backdrop" onClick={handleClose} /> }
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div> return <>
<Typography variant="body1" component="div" className="settings"> <Typography variant="body1" component="div" className="settings">
<div className='preferences-category'> <div className='preferences-category'>
<div className='category-title'> <div className='category-title'>
<h3>Mobile layout</h3> <h3>{t("Language")}</h3>
</div> </div>
<div className='preferences-item first leave-left-gap'>
<FormControlLabel
control={
<Box sx={{ width: 300 }}>
<Select
value={language}
label={t("Language")}
onChange={handlerChangeLanguage}>
{lean4gameConfig.languages.map(lang => {
return <MenuItem key={`menu-item-lang-${lang.iso}`} value={lang.iso}>
{lean4gameConfig.useFlags && <ReactCountryFlag countryCode={lang.flag}/>}
&nbsp;
{lang.name}
</MenuItem>
})}
</Select>
</Box>
}
label=""
/>
<p>
If a game does not exist in the language selected, this setting has no effect
and the game's default language is used.
</p>
</div>
</div>
<div className='preferences-category'>
<div className='category-title'>
<h3>{t("Layout")}</h3>
</div>
<div className='preferences-item first leave-left-gap'>
<FormControlLabel
control={
<Box sx={{ width: 300 }}>
<Slider
aria-label={t("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'> <div className='preferences-item'>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={mobile} checked={isSavePreferences}
onChange={() => setMobile(!mobile)} onChange={() => setIsSavePreferences(!isSavePreferences)}
name="checked" name="checked"
color="primary" color="primary"
/> />
} }
label="Enable" label={t("Save my settings (in the browser store)")}
labelPlacement="start" labelPlacement="end"
/>
</div>
<div className='preferences-item'>
<FormControlLabel
control={
<Switch
checked={!lockMobile}
onChange={() => setLockMobile(!lockMobile)}
name="checked"
color="primary"
/>
}
label="Auto"
labelPlacement="start"
/> />
</div> </div>
</div> </div>
</Typography> </Typography>
</div> </>
</div>
} }
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next';
/** Pop-up that is displayed when opening the privacy policy.
*
* Note: Do not translate the Impressum!
*/
export function PrivacyPolicyPopup () {
let {t, i18n} = useTranslation()
function content (lng = i18n.language) {
const tt = i18n.getFixedT(lng);
return <Trans t={tt} >
<h2>Privacy Policy</h2>
<p>
Our server collects metadata (such as IP address, browser, operating system)
and the data that the user enters into the editor. The data is used to
compute the Lean output and display it to the user. The information will be stored
as long as the user stays on our website and will be deleted immediately afterwards.
We keep logs to improve our software, but the contained data is anonymized.
</p>
<p>
We do not use cookies, but your game progress is stored in the browser
as site data. Your game progress is not saved on the server; if you delete
your browser storage, it is completely gone.
</p>
<p>Our server is located in Germany.</p>
<p>
<strong>Contact:</strong><br />
Marcus Zibrowius, Jon Eugster<br />
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
Universitätsstr. 1<br />
40225 Düsseldorf<br />
Germany<br />
+49 211 81-14690<br />
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team">Contact Details</a>
</p>
</Trans>
}
return <>
{i18n.language != 'en' && <>
<p><i>(English version below)</i></p>
{content()}
<hr />
</>}
{content('en')}
</>
}
@@ -1,75 +0,0 @@
/**
* @fileOverview The impressum/privacy policy
*/
import { faShield } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as React from 'react'
/** Pop-up that is displayed when opening the privacy policy.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
return <div className="privacy-policy modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Privacy Policy &amp; Impressum</h2>
<p>
Our server collects metadata (such as IP address, browser, operating system)
and the data that the user enters into the editor. The data is used to
compute the Lean output and display it to the user. The information will be stored
as long as the user stays on our website and will be deleted immediately afterwards.
We keep logs to improve our software, but the contained data is anonymized.
</p>
<p>
We do not use cookies, but your game progress is stored in the browser
as site data. Your game progress is not saved on the server; if you delete
your browser storage, it is completely gone.
</p>
<p>Our server is located in Germany.</p>
<p>
<strong>Contact information:</strong><br />
Alexander Bentkamp, Jon Eugster<br />
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
Universitätsstr. 1<br />
40225 Düsseldorf<br />
Germany<br />
+49 211 81-12173<br />
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
</p>
<h2>Development &amp; Funding</h2>
<p>
The source code of this Lean game engine
is <a href="https://github.com/leanprover-community/lean4game" target="_blank">available on Github</a>.
If you experience any problems, please
file an <a href="https://github.com/leanprover-community/lean4game/issues" target="_blank">Issue on Github</a> or
get directly in contact.
</p>
<p>
The game engine has been developed as part of the
project <a href="https://hhu-adam.github.io/" target="_blank">ADAM: Anticipating the Digital
Age of Mathematics</a> at
Heinrich-Heine-Universität Düsseldorf. It is funded by
the <i>Stiftung Innovation in der Hochschullehre</i> as part of project <i>Freiraum 2022</i>.
</p>
</div>
</div>
}
export const PrivacyPolicy: React.FC = () => {
const [open, setOpen] = React.useState(false)
const handleOpen = () => setOpen(true)
const handleClose = () => setOpen(false)
return (
<>
<div className="privacy" onClick={handleOpen} title="Privacy Policy &amp; Impressum">
<FontAwesomeIcon icon={faShield} />
<p className="p1">legal</p>
<p className="p2">notes</p>
</div>
{open ? <PrivacyPolicyPopup handleClose={handleClose} /> : null}
</>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { Box, Slider } from '@mui/material'
import * as React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { changedDifficulty, selectDifficulty } from '../../state/progress'
import { useSelector } from 'react-redux'
import { useContext } from 'react'
import { useAppDispatch } from '../../hooks'
import { GameIdContext } from '../../state/context'
/** Pop-up that is displayed when opening the help explaining the game rules.
*
*/
export function RulesPopup () {
const { t } = useTranslation()
const { gameId } = useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const dispatch = useAppDispatch()
function label(x : number) {
return x == 0 ? t("none") : x == 1 ? t("relaxed") : t("regular")
}
return <>
<h2>{t("Game Rules")}</h2>
{/* <span className="difficulty-label">{t("Rules")}
<FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/>
</span> */}
<Box className="slider-wrapper">
<Slider
orientation="horizontal"
title={t("Game Rules")}
min={0} max={2}
aria-label={t("Game Rules")}
value={difficulty}
marks={[
{value: 0, label: label(0)},
{value: 1, label: label(1)},
{value: 2, label: label(2)}
]}
valueLabelFormat={label}
getAriaValueText={label}
valueLabelDisplay="off"
onChange={(ev, val: number) => {
dispatch(changedDifficulty({game: gameId, difficulty: val}))
}}
/>
</Box>
<Trans>
<p>
Game rules determine if it is allowed to skip levels and if the games runs checks to only
allow unlocked tactics and theorems in proofs.
</p>
<p>
Note: "Unlocked" tactics (or theorems) are determined by two things: The set of minimal
tactics needed to solve a level, plus any tactics you unlocked in another level. That means
if you unlock <code>simp</code> in a level, you can use it henceforth in any level.
</p>
<p>The options are:</p>
</Trans>
<table>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">{t("levels")}</th>
<th scope="col">{t("tactics")}</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">{t("regular")}</th>
<td>🔐</td>
<td>🔐</td>
</tr>
<tr>
<th scope="row">{t("relaxed")}</th>
<td>🔓</td>
<td>🔐</td>
</tr>
<tr>
<th scope="row">{t("none")}</th>
<td>🔓</td>
<td>🔓</td>
</tr>
</tbody>
</table>
</>
}
@@ -1,55 +0,0 @@
/**
* @fileOverview
*/
import * as React from 'react'
/** Pop-up that is displayed when opening the help explaining the game rules.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function RulesHelpPopup ({handleClose}: {handleClose: () => void}) {
return <div className="privacy-policy modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Game Rules</h2>
<p>
Game rules determine if it is allowed to skip levels and if the games runs checks to only
allow unlocked tactics and theorems in proofs.
</p>
<p>
Note: "Unlocked" tactics (or theorems) are determined by two things: The set of minimal
tactics needed to solve a level, plus any tactics you unlocked in another level. That means
if you unlock <code>simp</code> in a level, you can use it henceforth in any level.
</p>
<p>The options are:</p>
<table>
<thead>
<tr>
<th scope="col"></th>
<th scope="col">levels</th>
<th scope="col">tactics</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">regular</th>
<td>🔐</td>
<td>🔐</td>
</tr>
<tr>
<th scope="row">relaxed</th>
<td>🔓</td>
<td>🔐</td>
</tr>
<tr>
<th scope="row">none</th>
<td>🔓</td>
<td>🔓</td>
</tr>
</tbody>
</table>
</div>
</div>
}
+20 -15
View File
@@ -3,23 +3,30 @@
*/ */
import * as React from 'react' import * as React from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { GameIdContext } from '../../app'
import { useAppDispatch } from '../../hooks' import { useAppDispatch } from '../../hooks'
import { GameProgressState, loadProgress, selectProgress } from '../../state/progress' import { GameProgressState, loadProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree' import { downloadFile } from '../world_tree'
import { Button } from '../button' import { Button } from '../utils'
import { Trans, useTranslation } from 'react-i18next'
import { PopupContext } from './popup'
import { useContext } from 'react'
import { GameIdContext } from '../../state/context'
/** Pop-up that is displaying the Game Info. /** Pop-up that is displaying the Game Info.
* *
* `handleClose` is the function to close it again because it's open/closed state is * `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element. * controlled by the containing element.
*/ */
export function UploadPopup ({handleClose}) { export function UploadPopup () {
let { t } = useTranslation()
const [file, setFile] = React.useState<File>(); const [file, setFile] = React.useState<File>();
const gameId = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const gameProgress = useSelector(selectProgress(gameId)) const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { setPopupContent } = useContext(PopupContext)
const handleFileChange = (e) => { const handleFileChange = (e) => {
if (e.target.files) { if (e.target.files) {
setFile(e.target.files[0]) setFile(e.target.files[0])
@@ -36,7 +43,7 @@ export function UploadPopup ({handleClose}) {
console.debug("Json Data", data) console.debug("Json Data", data)
dispatch(loadProgress({game: gameId, data: data})) dispatch(loadProgress({game: gameId, data: data}))
} }
handleClose() setPopupContent(null) // close the popup
} }
/** Download the current progress (i.e. what's saved in the browser store) */ /** Download the current progress (i.e. what's saved in the browser store) */
@@ -50,21 +57,19 @@ export function UploadPopup ({handleClose}) {
} }
return <div className="modal-wrapper"> return <>
<div className="modal-backdrop" onClick={handleClose} /> <h2>{t("Upload Saved Progress")}</h2>
<div className="modal"> <Trans>
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Upload Saved Progress</h2>
<p>Select a JSON file with the saved game progress to load your progress.</p> <p>Select a JSON file with the saved game progress to load your progress.</p>
<p><b>Warning:</b> This will delete your current game progress! <p><b>Warning:</b> This will delete your current game progress!
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p> Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!
</p>
</Trans>
<p> <p>
<input type="file" onChange={handleFileChange}/> <input type="file" onChange={handleFileChange}/>
</p> </p>
<Button to="" onClick={uploadProgress}>Load selected file</Button> <Button to="" onClick={uploadProgress}>{t("Load selected file")}</Button>
</div> </>
</div>
} }
+43
View File
@@ -0,0 +1,43 @@
import * as React from 'react'
import { Link, LinkProps } from "react-router-dom"
import { Box, CircularProgress } from "@mui/material"
import ReactMarkdown from 'react-markdown'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import rehypeRaw from 'rehype-raw'
import 'katex/dist/katex.min.css' // `rehype-katex` does not import the CSS for you
import gfm from "remark-gfm"
/** Simple loading icon */
export function LoadingIcon () {
return <Box display="flex" alignItems="center" justifyContent="center" sx={{ flex: 1, height: "calc(100vh - 64px)" }}>
<CircularProgress />
</Box>
}
export interface ButtonProps extends LinkProps {
disabled?: boolean
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
}
/** Our own button class */
export function Button(props: ButtonProps) {
if (props.disabled) {
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
} else {
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
}
}
/** Spiced-up markdown */
export function Markdown(props) {
const newProps = {
...props,
remarkPlugins: [...props.remarkPlugins ?? [], remarkMath, gfm],
rehypePlugins: [...props.remarkPlugins ?? [], rehypeKatex, rehypeRaw],
};
return (
<ReactMarkdown {...newProps} className="markdown" />
);
}
-141
View File
@@ -1,141 +0,0 @@
import * as React from 'react'
import { useEffect } from 'react'
import Split from 'react-split'
import { Box, CircularProgress } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'
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 { 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'
import { WelcomeAppBar } from './app_bar'
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 gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
// TODO: I left the setup for splitting up the introduction in place, but if it's not needed
// then this can be simplified.
// let text: Array<string> = introduction.split(/\n(\s*\n)+/)
let text: Array<string> = introduction ? [introduction] : []
return <div className="column chat-panel">
<div className="chat">
{text?.map(((t, i) =>
t.trim() ?
<Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}}
step={0} selected={null} toggleSelection={undefined} />
: <></>
))}
</div>
{mobile &&
<div className="button-row">
<Button className="btn" to=""
title="" onClick={() => {
setPageNumber(1);
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))
}}>
Start&nbsp;<FontAwesomeIcon icon={faArrowRight}/>
</Button>
</div>
}
</div>
}
/** main page of the game showing among others the tree of worlds/levels */
function Welcome() {
const gameId = React.useContext(GameIdContext)
const {mobile, setMobile, lockMobile, setLockMobile} = React.useContext(MobileContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId})
// For mobile only
const openedIntro = useAppSelector(selectOpenedIntro(gameId))
const [pageNumber, setPageNumber] = React.useState(openedIntro ? 1 : 0)
// pop-ups
const [eraseMenu, setEraseMenu] = React.useState(false)
const [impressum, setImpressum] = React.useState(false)
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(() => {
if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title
}
}, [gameInfo.data?.title])
return gameInfo.isLoading ?
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
<CircularProgress />
</Box>
: <>
<WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum}
toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu}
toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/>
<div className="app-content">
{ mobile ?
<div className="welcome mobile">
{(pageNumber == 0 ?
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} />
: pageNumber == 1 ?
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
:
<InventoryPanel levelInfo={inventory?.data} />
)}
</div>
:
<Split className="welcome" minSize={0} snapOffset={200} sizes={[25, 50, 25]}>
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize}
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<InventoryPanel levelInfo={inventory?.data} />
</Split>
}
</div>
{impressum ? <PrivacyPolicyPopup handleClose={closeImpressum} /> : null}
{rulesHelp ? <RulesHelpPopup handleClose={closeRulesHelp} /> : null}
{eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null}
{uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null}
{info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null}
{preferencesPopup ? <PreferencesPopup mobile={mobile} setMobile={setMobile} lockMobile={lockMobile} setLockMobile={setLockMobile} handleClose={closePreferencesPopup}/> : null}
</>
}
export default Welcome
+30 -70
View File
@@ -3,19 +3,18 @@
*/ */
import * as React from 'react' import * as React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useStore, useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { Slider } from '@mui/material'
import cytoscape, { LayoutOptions } from 'cytoscape' import cytoscape, { LayoutOptions } from 'cytoscape'
import klay from 'cytoscape-klay' import klay from 'cytoscape-klay'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app' import { selectDifficulty, selectCompleted } from '../state/progress'
import { useAppDispatch, useMobile } from '../hooks'
import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
import { store } from '../state/store' import { store } from '../state/store'
import '../css/world_tree.css' import '../css/world_tree.css'
import { GameIdContext } from '../state/context'
import { useTranslation } from 'react-i18next'
import { useGetGameInfoQuery } from '../state/api'
import { LoadingIcon } from './utils'
// Settings for the world tree // Settings for the world tree
cytoscape.use( klay ) cytoscape.use( klay )
@@ -63,7 +62,7 @@ export function LevelIcon({ world, level, position, completed, unlocked, worldSi
// Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2) // Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2)
let R = 1.1 * r / Math.sin(beta/2) let R = 1.1 * r / Math.sin(beta/2)
const gameId = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const levelDisabled = (difficulty >= 2 && !(unlocked || completed)) const levelDisabled = (difficulty >= 2 && !(unlocked || completed))
@@ -111,6 +110,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
difficulty: number, difficulty: number,
worldSize: number worldSize: number
}) { }) {
const { t } = useTranslation()
// See level icons. Match radius computed there minus `1.2*r` // See level icons. Match radius computed there minus `1.2*r`
const N = Math.max(worldSize, NMIN) const N = Math.max(worldSize, NMIN)
@@ -134,7 +134,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
nextLevel = 0 nextLevel = 0
} }
let playable = difficulty <= 1 || completed || unlocked let playable = difficulty <= 1 || completed || unlocked
const gameId = React.useContext(GameIdContext) const {gameId} = React.useContext(GameIdContext)
return <Link return <Link
to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''} to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''}
@@ -149,7 +149,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}> width={1.42*R} height={1.42*R} transform={"translate("+ -.71*R +","+ -.71*R +")"}>
<div className={unlocked && !completed ? "playable-world" : ''}> <div className={unlocked && !completed ? "playable-world" : ''}>
<p className="world-title" style={{fontSize: fontSize + "px"}}> <p className="world-title" style={{fontSize: fontSize + "px"}}>
{title ? title : world} {title ? t(title, {ns: gameId}) : world}
</p> </p>
</div> </div>
</foreignObject> </foreignObject>
@@ -160,7 +160,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
> >
<div className='world-label' style={{backgroundColor: completed ? darkgreen : unlocked ? darkblue : darkgrey}}> <div className='world-label' style={{backgroundColor: completed ? darkgreen : unlocked ? darkblue : darkgrey}}>
<p className='world-title' style={{fontSize: MINFONT + "px"}}> <p className='world-title' style={{fontSize: MINFONT + "px"}}>
{title ? title : world} {title ? t(title, {ns: gameId}) : world}
</p> </p>
</div> </div>
</foreignObject>} </foreignObject>}
@@ -192,46 +192,6 @@ export const downloadFile = ({ data, fileName, fileType } :
a.remove() a.remove()
} }
/** The menu that is shown next to the world selection graph */
export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const dispatch = useAppDispatch()
const { mobile } = useMobile()
function label(x : number) {
return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular'
}
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))}/>
</span>
<Slider
orientation="vertical"
title="Game Rules"
min={0} max={2}
aria-label="Game Rules"
value={difficulty}
marks={[
{value: 0, label: label(0)},
{value: 1, label: label(1)},
{value: 2, label: label(2)}
]}
valueLabelFormat={label}
getAriaValueText={label}
valueLabelDisplay="off"
onChange={(ev, val: number) => {
dispatch(changedDifficulty({game: gameId, difficulty: val}))
}}
></Slider>
</div>
</nav>
}
export function computeWorldLayout(worlds) { export function computeWorldLayout(worlds) {
let elements = [] let elements = []
for (let id in worlds.nodes) { for (let id in worlds.nodes) {
@@ -267,15 +227,14 @@ export function computeWorldLayout(worlds) {
} }
export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}: export function WorldTreePanel ({visible = true}) {
{ worlds: any, const {gameId} = React.useContext(GameIdContext)
worldSize: any,
rulesHelp: boolean,
setRulesHelp: any,
}) {
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []} const gameInfo = useGetGameInfoQuery({game: gameId})
const {nodes, bounds}: any = gameInfo.data?.worlds ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
// scroll to playable world // scroll to playable world
React.useEffect(() => { React.useEffect(() => {
@@ -288,7 +247,7 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
console.debug(`scrolling to ${elem.textContent}`) console.debug(`scrolling to ${elem.textContent}`)
elem.scrollIntoView({block: "center"}) elem.scrollIntoView({block: "center"})
} }
}, [worlds, worldSize]) }, [gameInfo])
let svgElements = [] let svgElements = []
@@ -297,18 +256,18 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
// Indices `1, …, n` indicate if the corresponding level is completed // Indices `1, …, n` indicate if the corresponding level is completed
var completed = {} var completed = {}
if (worlds && worldSize) { if (gameInfo.data?.worlds && gameInfo.data?.worldSize) {
// Fill `completed` with the level data. // Fill `completed` with the level data.
for (let worldId in nodes) { for (let worldId in nodes) {
completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => { completed[worldId] = Array.from({ length: gameInfo.data?.worldSize[worldId] + 1 }, (_, i) => {
// index `0` starts off as `true` but can be set to `false` by any edge with non-completed source // index `0` starts off as `true` but can be set to `false` by any edge with non-completed source
return i == 0 || selectCompleted(gameId, worldId, i)(store.getState()) return i == 0 || selectCompleted(gameId, worldId, i)(store.getState())
}) })
} }
// draw all connecting paths // draw all connecting paths
for (let i in worlds.edges) { for (let i in gameInfo.data?.worlds.edges) {
const edge = worlds.edges[i] const edge = gameInfo.data?.worlds.edges[i]
let sourceCompleted = completed[edge[0]].slice(1).every(Boolean) let sourceCompleted = completed[edge[0]].slice(1).every(Boolean)
// if the origin world is not completed, mark the target world as non-playable // if the origin world is not completed, mark the target world as non-playable
if (!sourceCompleted) {completed[edge[1]][0] = false} if (!sourceCompleted) {completed[edge[1]][0] = false}
@@ -328,11 +287,11 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
completedLevels={completed[worldId]} completedLevels={completed[worldId]}
difficulty={difficulty} difficulty={difficulty}
key={`${gameId}-${worldId}`} key={`${gameId}-${worldId}`}
worldSize={worldSize[worldId]} worldSize={gameInfo.data?.worldSize[worldId]}
/> />
) )
for (let i = 1; i <= worldSize[worldId]; i++) { for (let i = 1; i <= gameInfo.data?.worldSize[worldId]; i++) {
svgElements.push( svgElements.push(
<LevelIcon <LevelIcon
world={worldId} world={worldId}
@@ -341,7 +300,7 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
completed={completed[worldId][i]} completed={completed[worldId][i]}
unlocked={completed[worldId][i-1]} unlocked={completed[worldId][i-1]}
key={`${gameId}-${worldId}-${i}`} key={`${gameId}-${worldId}-${i}`}
worldSize={worldSize[worldId]} worldSize={gameInfo.data?.worldSize[worldId]}
/> />
) )
} }
@@ -355,13 +314,14 @@ export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}:
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
return <div className="column"> return <div className={`${visible ? '' : 'hidden'}`}>
<WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> {/* <WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> */}
{ gameInfo.data ?
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''} width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''} viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection" > className="world-selection" >
{svgElements} {svgElements}
</svg> </svg> : <LoadingIcon/> }
</div> </div>
} }
+41
View File
@@ -0,0 +1,41 @@
{
"allGames": [
"leanprover-community/nng4",
"hhu-adam/Robo",
"djvelleman/stg4",
"trequetrum/lean4game-logic"
],
"languages": [
{
"iso": "en",
"flag": "GB",
"name": "English"
},
{
"iso": "de",
"flag": "DE",
"name": "Deutsch"
},
{
"iso": "zh",
"flag": "CN",
"name": "中文"
}
],
"newLanguages": {
"en": {
"flag": "GB",
"name": "English"
},
"de": {
"flag": "DE",
"name": "Deutsch"
},
"zh": {
"flag": "CN",
"name": "中文"
}
},
"useFlags": false
}
+33 -16
View File
@@ -6,18 +6,25 @@
--clr-light-gray: #ddd; --clr-light-gray: #ddd;
--clr-dark-gray: #aaa; --clr-dark-gray: #aaa;
--clr-darker-gray: #555; --clr-darker-gray: #555;
--ff-primary: Roboto;
} }
/* General styling */ /* General styling */
html, body, #root, .app {
height: 100%;
}
body { body {
font-family: var(--ff-primary); font-family: "Roboto", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.hidden {
display: none !important;
}
.markdown code { .markdown code {
color: rgba(0, 32, 90, 0.87); color: rgba(0, 32, 90, 0.87);
background-color: rgba(223, 227, 234); background-color: rgba(223, 227, 234);
@@ -75,6 +82,7 @@ body {
.btn-disabled, .btn[disabled] { .btn-disabled, .btn[disabled] {
color: var(--clr-dark-gray); color: var(--clr-dark-gray);
background: var(--clr-light-gray); background: var(--clr-light-gray);
cursor: not-allowed;
} }
.btn-inverted.btn-disabled, .btn-inverted.btn[disabled] { .btn-inverted.btn-disabled, .btn-inverted.btn[disabled] {
@@ -88,16 +96,12 @@ em {
/* App Bar */ /* App Bar */
#root {
height: 100%;
}
.app { .app {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
/* TODO: remove */
.app-bar { .app-bar {
flex: 0; flex: 0;
background: var(--clr-primary); background: var(--clr-primary);
@@ -126,14 +130,6 @@ em {
/* margin: 0 1em; */ /* margin: 0 1em; */
} }
.app-content {
height: 100%;
flex: 1;
min-height: 0;
display: flex;
}
.markdown li ul, .markdown li ol { .markdown li ul, .markdown li ol {
margin:0 1.5em; margin:0 1.5em;
} }
@@ -142,6 +138,27 @@ em {
margin:0 1.5em 1.5em 1.5em; margin:0 1.5em 1.5em 1.5em;
} }
.privacy-policy { .modal-wrapper {
z-index: 10; z-index: 10;
} }
.modal hr {
margin-top: 3rem;
margin-bottom: 3rem;
border-color: var(--vscode-breadcrumb-foreground);
}
/* TODO: should not be #root */
#root code {
font-family: "JuliaMono", monospace;
}
@font-face {
font-family: 'JuliaMono';
src: local('JuliaMono'), url('/fonts/JuliaMono-Regular.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto';
src: local('Roboto'), url('/fonts/Roboto-Regular.ttf') format('truetype');
}
+99
View File
@@ -0,0 +1,99 @@
.chat {
flex: 1;
overflow-y: scroll;
margin-left: .5em;
margin-right: .5em;
}
.chat-panel {
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.button-row {
/* width:100%; */
/* margin-left: .5em;
margin-right: .5em; */
min-height: 2.5em;
border-top: 0.1em solid #aaa;
display: flex;
padding-top: .2rem;
margin: .5rem;
}
.button-row > .btn:not(:last-child), .button-row > .btn-placeholder:not(:last-child) {
/* display: block; */
margin-right: .2rem;
}
.button-row .btn-placeholder {
display: inline-block;
flex: 1;
margin: 0;
}
.button-row .btn {
flex: 1;
/* margin-top: 1rem;
margin-bottom: 1rem;
margin-left: .5rem;
margin-right: .5rem; */
text-align: center;
margin: 0;
padding: .5em;
}
.message {
margin: 10px 0;
padding: 5px 10px;
border-radius: 3px 3px 3px 3px;
}
.message.information, .message.info, .message.kind-5, .message.kind-3, .message.kind-4 {
/* color: #059; */
color: #000;
background-color: #DDF6FF;
}
.message.warning, .message.kind-2 {
color: #9F6000;
background-color: #FEEFB3;
}
.message.error, .message.kind-1 {
color: #D8000C;
background-color: #FFBABA;
}
.message.deleted-hint {
background-color: #EEE;
color: #777;
box-shadow: .0em .0em .5em .2em #EEE;
}
.message.success, , .message.kind-6 {
color: #000;
background-color: #E5FFDD;
}
.chat .message {
margin-left: .5em;
margin-right: .5em;
overflow-x: auto;
}
.chat .recent {
box-shadow: .0em .0em .4em .1em #8cbbe9;
}
.chat .selected {
/* border: 3px solid #5191d1; */
box-shadow: .0em .0em .4em .1em var(--clr-primary);
}
+27
View File
@@ -0,0 +1,27 @@
.editor-wrapper {
flex: 1;
position: relative;
}
.editor-wrapper .editor-split {
height: 100%;
}
#editor {
height: 400px;
}
#infoview {
height: 400px;
}
#infoview iframe {
width: 100%;
height: 95%; /* TODO: setting this to 100% makes it a few pixels too high... */
border: unset;
}
/* .typewriter {
background-color: lightpink;
min-height: 400px;
} */
+60
View File
@@ -0,0 +1,60 @@
#error-page {
height: 100vh;
background-image: url("/RoboSurprised.png");
background-repeat: no-repeat;
background-size: contain;
background-position: right bottom;
}
/* these colours are matching the ones of `.messages.error` */
.error-message{
border: 2px solid #D8000C;
background-color: #FFBABA;
border-radius: 1rem;
display: inline-block;
margin: 3rem;
padding-left: 1rem;
padding-right: 1rem;
/* color: #D8000C; */
position: relative;
}
.thought-bubble {
border: 2px solid #D8000C;
background-color: #FFBABA;
position: absolute;
display: block;
}
.thought-bubble:nth-of-type(1) {
bottom: -3.5rem;
left: 1rem;
border-radius: 1.2rem;
width: 2.4rem;
height: 2.4rem;
}
.thought-bubble:nth-of-type(2) {
bottom: -6rem;
left: 2.5rem;
border-radius: 1rem;
width: 2rem;
height: 2rem;
}
.thought-bubble:nth-of-type(3) {
bottom: -7.5rem;
left: 4.5rem;
border-radius: 0.8rem;
width: 1.6rem;
height: 1.6rem;
}
.thought-bubble:nth-of-type(4) {
bottom: -8.2rem;
left: 6.8rem;
border-radius: 0.6rem;
width: 1.4rem;
height: 1.4rem;
}
+37
View File
@@ -0,0 +1,37 @@
.app-content {
height: 100%;
flex: 1;
min-height: 0;
display: flex;
}
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
}
.column {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
scroll-behavior: smooth;
}
.slider .column {
height: 100%;
overflow: auto;
position: relative;
scroll-behavior: smooth;
}
+69 -44
View File
@@ -1,28 +1,4 @@
.message {
margin: 10px 0;
padding: 5px 10px;
border-radius: 3px 3px 3px 3px;
}
.message.information, .message.info {
/* color: #059; */
color: #000;
background-color: #DDF6FF;
}
.message.warning {
color: #9F6000;
background-color: #FEEFB3;
}
.message.error {
color: #D8000C;
background-color: #FFBABA;
}
.message.deleted-hint {
background-color: #eee;
color: #777;
box-shadow: .0em .0em .5em .2em #eee;
}
.hyp-group { .hyp-group {
margin-bottom: 0.3em; margin-bottom: 0.3em;
@@ -41,6 +17,13 @@
.level-completed { .level-completed {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 500; font-weight: 500;
padding-left: .5em;
padding-right: .5em;
padding-top: .2em;
padding-bottom: .2em;
border-radius: .5em;
background-color: #eee;
} }
.typewriter { .typewriter {
@@ -87,13 +70,6 @@
flex-direction: column; flex-direction: column;
} }
.typewriter-interface .proof .MuiCircularProgress-root {
left: 50%;
position: relative;
margin-left: -20px;
margin-bottom: 0.6em;
}
.typewriter .typewriter-input { .typewriter .typewriter-input {
flex: 1; flex: 1;
} }
@@ -115,8 +91,8 @@
flex-direction: row; flex-direction: row;
} }
.goals-section div { .goal-tabs {
flex-grow: 1; width: 100%;
} }
#current-proof, #main-assumptions { #current-proof, #main-assumptions {
@@ -160,22 +136,12 @@
} }
/* Push the goals to the bottom for now, until we insert the proof history above. */
.typewriter-interface .content {
display: flex;
flex-direction: column;
scroll-behavior: smooth;
}
/* TODO this is in the wrong file */ /* TODO this is in the wrong file */
.chat { .chat {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.typewriter-interface .content .tmp-pusher {
flex: 1;
}
.exercise .command { .exercise .command {
background-color: #bbb; background-color: #bbb;
padding: .5em; padding: .5em;
@@ -188,14 +154,73 @@
flex-direction: row; 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 { .exercise .command-text {
flex: 1; flex: 1;
background-color: #fff; background-color: #fff;
font-family: "Droid Sans Mono", "monospace", monospace;
font-size: 14px; font-size: 14px;
font-family: "JuliaMono", monospace;
padding: 0.4em .6em 0; padding: 0.4em .6em 0;
} }
.undo-button { .undo-button {
color: #888; color: #888;
} }
.crashed_message {
color: #D8000C;
font-weight: bold;
padding-left: .5em;
padding-right: .5em;
}
/* Apparently this prevents the layout from jumping around when hovering over expressions. */
.tooltip-arrow {
display: none;
}
.goal-tab {
/* border: 2px dotted darkgreen; */
padding: .2rem;
display: flex;
flex-direction: row;
font-family: "JuliaMono", monospace;
}
.goal-tab .hyp-group-title {
font-family: "Roboto", sans-serif;
}
.goal-tab .goal, .goal-tab .hypotheses {
/* border: 1px solid lightblue; */
display: inline-block;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.goal-sign {
/* border: 1px solid lightblue; */
width: 1rem;
margin-left: .5rem;
margin-right: .5rem;
display: inline-block;
}
.goal-sign line {
stroke: var(--clr-dark-gray);
stroke-width: 2;
}
+59 -2
View File
@@ -10,6 +10,7 @@
.documentation h1 { .documentation h1 {
font-weight: 900; font-weight: 900;
font-family: "JuliaMono", monospace;
} }
.documentation.hidden { .documentation.hidden {
@@ -33,9 +34,15 @@
position: relative; position: relative;
} }
.inventory .item, .inventory .tab-bar:not(.major) .tab {
font-family: "JuliaMono", monospace;
font-size: .9rem;
}
.inventory .item.locked { .inventory .item.locked {
border: solid 1px #ccc; border: solid 1px #ccc;
color: #ccc; color: #ccc;
background-color: #f2f2f2;
} }
.inventory .item.disabled { .inventory .item.disabled {
@@ -46,13 +53,21 @@
background-color: rgb(255, 242, 190); background-color: rgb(255, 242, 190);
} }
.inventory .item:not(.locked), .inventory .item.enabled { .inventory .item.current-world {
background-color: rgb(250, 231, 255);
}
.inventory .item.recent {
background-color: rgb(242, 190, 255);
}
.inventory .item, .inventory .item.enabled {
cursor: pointer; cursor: pointer;
} }
.tab-bar { .tab-bar {
border-bottom: 0.1em solid var(--clr-dark-gray); border-bottom: 0.1em solid var(--clr-dark-gray);
margin-bottom: 0.5em; margin-bottom: 1em;
} }
.tab { .tab {
@@ -67,11 +82,43 @@
display: inline-block; display: inline-block;
} }
.tab-bar.major {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding-top: .5em;
font-weight: bold;
}
.tab-bar.major .tab {
flex: 1;
text-align: center;
font-size: 1.2em;
padding-left: 0;
padding-right: 0;
}
.tab-bar.current .tab.active { .tab-bar.current .tab.active {
color: black; color: black;
border-bottom: 0.3em solid var(--clr-primary); border-bottom: 0.3em solid var(--clr-primary);
} }
.inventory .tab.recent {
background-image: linear-gradient(to bottom, rgba(255,0,0,0), rgb(242, 190, 255));
}
.inventory .tab.recent:not(.active) {
border-bottom: 0.3em solid rgb(242, 190, 255);
}
.inventory .tab.new {
background-image: linear-gradient(to bottom, rgba(255,0,0,0), rgb(255, 242, 190));
}
.inventory .tab.new:not(.active) {
border-bottom: 0.3em solid rgb(255, 242, 190);
}
.tab.active { .tab.active {
color: black; color: black;
border-bottom: 0.3em solid #999; border-bottom: 0.3em solid #999;
@@ -94,3 +141,13 @@
align-items: end; align-items: end;
text-align: end; text-align: end;
} }
.documentation .nav-button {
float: right;
font-size: 1.5rem;
}
.documentation .nav-button.lock {
float: left;
font-size: .7rem;
}
+16 -7
View File
@@ -9,17 +9,13 @@ html {
font-size: 16px; font-size: 16px;
} }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
a { a {
text-decoration: none; text-decoration: none;
} }
@viewport { @viewport {
width: device-width ; width: device-width ;
zoom: 1.0 ; initial-scale: 1.0 ;
} }
.landing-page { .landing-page {
@@ -36,6 +32,13 @@ a {
padding-bottom: 80px; padding-bottom: 80px;
} }
.landing-page-nav {
position: relative;
}
#menu-btn {
background-color: unset;
}
@media screen and (max-width: 440px) { @media screen and (max-width: 440px) {
.game-list { .game-list {
@@ -158,12 +161,12 @@ header {
font-style: italic; font-style: italic;
} }
header nav { /* header nav {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 8px; padding: 8px;
background-color: rgba(0, 133, 162, .7); background-color: rgba(0, 133, 162, .7);
} } */
footer { footer {
background-color: rgba(0, 133, 162, 1); background-color: rgba(0, 133, 162, 1);
@@ -182,6 +185,8 @@ footer .link {
.github-link { .github-link {
height: 24px; /* TODO: why do I need that? s*/ height: 24px; /* TODO: why do I need that? s*/
margin-top: auto;
margin-bottom: auto;
} }
.landing-page > section { .landing-page > section {
@@ -222,3 +227,7 @@ footer .link {
margin-right: calc(50% - 650px); margin-right: calc(50% - 650px);
} }
} }
.info .languages span + span {
margin-left: .5em;
}
+94 -91
View File
@@ -5,31 +5,12 @@
/* display: flex; */ /* display: flex; */
} }
.hidden { /* .inventory-panel, .exercise-panel, .doc-panel, .introduction-panel {
display: none;
}
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
}
.gutter.gutter-horizontal {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
}
.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel {
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: auto; overflow: auto;
position: relative; position: relative;
} } */
.infoview { .infoview {
padding-top: 1em; padding-top: 1em;
@@ -63,11 +44,11 @@
padding-top: 0em; padding-top: 0em;
} }
.exercise { /* .exercise {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
flex-flow: column; flex-flow: column;
} } */
.codeview { .codeview {
flex: 1 1 auto; flex: 1 1 auto;
@@ -193,70 +174,21 @@ td code {
.exercise { .exercise {
height: 100%; height: 100%;
}
.chat {
flex: 1;
overflow-y: scroll;
margin-left: .5em;
margin-right: .5em;
}
.chat-panel {
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.chat-panel .button-row {
/* width:100%; */
margin-left: .5em;
margin-right: .5em;
min-height: 2.5em;
border-top: 0.1em solid #aaa;
}
.chat-panel .btn {
margin-top: 1rem;
margin-bottom: 1rem;
margin-left: .5rem;
margin-right: .5rem;
}
/* .exercise-panel {
display: flex;
flex-flow: column;
height: 100%;
} */
.button-row.mobile {
margin: .5rem;
padding-top: .2rem;
}
.button-row.mobile .btn {
padding: .5em;
border-radius: .2em;
width: 100%; width: 100%;
margin: 0;
text-align: center;
} }
.exercise-content {
.typewriter-interface {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
height: 100%; height: 100%;
z-index: 1;
} }
.typewriter { .typewriter {
flex: 0 1 auto; flex: 0 1 auto;
} }
.typewriter-interface .content { .exercise-content {
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: scroll; overflow-y: scroll;
padding: 0; padding: 0;
@@ -276,16 +208,7 @@ td code {
margin-top: 10px; margin-top: 10px;
} }
.chat .message { .exercise .step.selected .goal-tab {
margin-left: .5em;
margin-right: .5em;
}
.chat .recent {
box-shadow: .0em .0em .4em .1em #8cbbe9;
}
.exercise .step.selected .goal-tabs, .chat .selected {
/* border: 3px solid #5191d1; */ /* border: 3px solid #5191d1; */
box-shadow: .0em .0em .4em .1em var(--clr-primary); box-shadow: .0em .0em .4em .1em var(--clr-primary);
} }
@@ -341,30 +264,110 @@ td code {
justify-content: center; justify-content: center;
} }
.typewriter-interface .content, .world-image-container.empty { .exercise {
background-color: #eee; background-color: #eee;
position: relative;
} }
.world-image-container { .world-image {
height: 100%;
width: 100%;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: absolute;
z-index: 0;
/* min-height: 0px;*/ /* somehow this has a desired affect, but why? */
}
.world-image img.contain {
height: 100%;
width: 100%;
object-fit: contain;
object-position: top center;
pointer-events: none;
}
.world-image.center {
justify-content: center; justify-content: center;
} }
.world-image-container img.contain {
object-fit: contain;
}
.world-image-container img.cover { .world-image-container img.cover {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.typewriter-interface .proof { .proof {
z-index: 2;
background-color: #fff; background-color: #fff;
} }
.exercise-content .tmp-pusher {
flex: 1;
}
.exercise-content {
background: none;
}
.editor-mode .proof {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-mode .tmp-pusher {
height: 0;
}
/* Push the goals to the bottom for now, until we insert the proof history above. */
.exercise-content {
display: flex;
flex-direction: column;
scroll-behavior: smooth;
}
.exercise-content .proof .MuiCircularProgress-root {
left: 50%;
position: relative;
margin-left: -20px;
margin-bottom: 0.6em;
}
.toggle-width { .toggle-width {
min-width: 40px; min-width: 40px;
text-align: center; text-align: center;
} }
/* Fixes https://github.com/leanprover-community/lean4game/issues/202 */
.katex-mathml {
display: none;
}
/* DEBUG */
/* .proof .step {
border: 2px solid rgb(0, 123, 255);
} */
.nav-btns {
height: 2rem;
}
.nav-btns .language-btn {
background: #DDF6FF;
text-align: center;
}
#unicode-input, .lean-abbrev-input {
min-width: 0;
white-space: pre;
flex: 1;
/* padding: 0.4em .6em 0; */
font-size: var(--vscode-editor-font-size);
background-color: white;
border: 2px solid orange;
display: flex;
flex-direction: column;
font-family: JuliaMono, monospace;
}
+97
View File
@@ -0,0 +1,97 @@
nav {
flex: 0;
background: var(--clr-primary);
display: flex;
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;
}
.nav-content {
flex: 1;
display: flex;
}
.nav-content > div {
/* border: 1px solid red; */
text-align: center;
}
.nav-title-left, .nav-title-right {
flex-grow: 0;
flex-shrink: 0;
display: flex;
align-items: center;
}
.nav-title-middle {
flex-grow: 1;
flex-shrink: 1;
margin-left: .5rem;
margin-right: .5rem;
}
.nav-title {
color: white;
font-weight: 500;
font-size: 1.3rem;
display: inline-block;
margin: 0;
/* margin: 0 1em; */
}
/* fix to make toggle buttons work */
.svg-inline--fa {
width: 1em;
}
/* TODO */
.nav-button:not(.btn-inverted) {
font-size: 1.3rem;
}
.dropdown {
z-index: 10;
}
.dropdown {
position: absolute;
display: flex;
flex-direction: column;
right: 0;
top: 100%;
background-color: #fff;
z-index: 5;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: -.1rem .3rem .3rem 0 rgba(0, 0, 0, 0.1);
}
.dropdown .svg-inline--fa {
width: 1.8rem;
}
.btn-right {
float: right;
margin: 0.2rem;
}
.btn-input-mode {
position: absolute;
right: 0.4rem;
z-index: 5;
top: 0.4rem;
margin: 0;
border: 1px solid var(--clr-primary);
background-color: #eee;
font-size: .7rem;
/* box-shadow: .0em .0em .4em .1em var(--clr-primary); */
/* box-shadow: -.1rem .3rem .3rem 0 var(--clr-primary); */
/* border-top-right-radius: 0;
border-bottom-right-radius: 0; */
}
+30
View File
@@ -0,0 +1,30 @@
/* For the "Rules" popup. */
.slider-wrapper {
padding-left: 2rem;
padding-right: 2rem;
padding-top: 1rem;
}
.modal-close {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-foreground);
cursor: pointer;
}
.modal-close:hover {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-focusForeground);
}
.modal .nav-button {
float: right;
font-size: 1.5rem;
}
.settings-buttons .btn {
margin-top: 0.2rem;
margin-bottom: 0.2rem;
}
+12 -16
View File
@@ -14,9 +14,6 @@
width: 100%; width: 100%;
} }
.app-content {
height: 100%
}
.welcome .column { .welcome .column {
height: 100%; height: 100%;
@@ -159,19 +156,6 @@ h5, h6 {
margin: 1em auto; margin: 1em auto;
} }
.modal-close {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-foreground);
cursor: pointer;
}
.modal-close:hover {
float: right;
scale: 2;
color: var(--vscode-breadcrumb-focusForeground);
}
.modal table { .modal table {
width: 100%; width: 100%;
} }
@@ -187,3 +171,15 @@ h5, h6 {
margin-left: 0.3rem; margin-left: 0.3rem;
margin-right: 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;
}
-24
View File
@@ -1,30 +1,6 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './state/store' import type { RootState, AppDispatch } from './state/store'
import { setMobile as setMobileState, setLockMobile as setLockMobileState} from "./state/preferences"
// Use throughout your app instead of plain `useDispatch` and `useSelector` // Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
export const useMobile = () => {
const dispatch = useAppDispatch();
const mobile = useAppSelector((state) => state.preferences.mobile);
const lockMobile = useAppSelector((state) => state.preferences.lockMobile);
const setMobile = (val: boolean) => {
dispatch(setMobileState(val));
};
const setLockMobile = (val: boolean) => {
dispatch(setLockMobileState(val));
};
return {
mobile,
setMobile,
lockMobile,
setLockMobile,
};
};
+35
View File
@@ -0,0 +1,35 @@
import i18n from "i18next";
import Backend from "i18next-http-backend"
import { initReactI18next } from "react-i18next";
i18n
.use(initReactI18next)
.use(Backend)
.init({
ns: ['translation'],
backend: {
// > see https://github.com/i18next/i18next-http-backend
loadPath: function(lngs, namespaces: Array<string>) {
if (namespaces[0].startsWith("g/")) {
return '/i18n/{{ns}}/{{lng}}/Game.json';
} else {
return '/locales/{{lng}}/{{ns}}.json';
}
}
},
// > language to use, more information here:
// > https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
lng: "en",
// we use natural language keys, so we don't need a fallback language.
fallbackLng: false,
// > you can use the i18n.changeLanguage function to change the language manually:
// > https://www.i18next.com/overview/api#changelanguage
// > if you're using a language detector, do not define the lng option
returnEmptyString: false,
interpolation: {
// > react already safes from xss
escapeValue: false
}
});
export default i18n;
+13 -6
View File
@@ -8,14 +8,20 @@ import { createHashRouter, RouterProvider, Route, redirect } from "react-router-
import ErrorPage from './components/error_page' import ErrorPage from './components/error_page'
import Welcome from './components/welcome' import Welcome from './components/welcome'
import LandingPage from './components/landing_page' import LandingPage from './components/landing_page'
import Level from './components/level' import './i18n';
import Game from './components/game'
// If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to // If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to
// `/g/local/game`. This is used for the devcontainer setup // `/g/local/game`. This is used for the devcontainer setup
let single_game = (import.meta.env.VITE_LEAN4GAME_SINGLE == "true") let single_game = (import.meta.env.VITE_LEAN4GAME_SINGLE == "true")
let root_object: RouteObject = single_game ? { // let root_object: RouteObject = single_game ? {
// path: "/",
// loader: () => redirect("/g/local/game")
// }
let landing_page: RouteObject = single_game ? {
path: "/", path: "/",
loader: () => redirect("/g/local/game") loader: () => redirect("/g/local/game")
} : { } : {
@@ -24,7 +30,7 @@ let root_object: RouteObject = single_game ? {
} }
const router = createHashRouter([ const router = createHashRouter([
root_object, // root_object,
{ {
// For backwards compatibility // For backwards compatibility
path: "/game/nng", path: "/game/nng",
@@ -36,17 +42,18 @@ const router = createHashRouter([
loader: () => redirect("/g/leanprover-community/NNG4") loader: () => redirect("/g/leanprover-community/NNG4")
}, },
{ {
path: "/g/:owner/:repo", path: "/",
element: <App />, element: <App />,
errorElement: <ErrorPage />, errorElement: <ErrorPage />,
children: [ children: [
landing_page,
{ {
path: "/g/:owner/:repo", path: "/g/:owner/:repo",
element: <Welcome />, element: <Game />,
}, },
{ {
path: "/g/:owner/:repo/world/:worldId/level/:levelId", path: "/g/:owner/:repo/world/:worldId/level/:levelId",
element: <Level />, element: <Game />,
}, },
], ],
}, },
+16 -7
View File
@@ -36,6 +36,9 @@ export interface InventoryTile {
new: boolean, new: boolean,
hidden: boolean hidden: boolean
altTitle: string, altTitle: string,
world : string|null,
level : number|null,
proven : boolean
} }
export interface LevelInfo { export interface LevelInfo {
@@ -44,11 +47,11 @@ export interface LevelInfo {
conclusion: null|string, conclusion: null|string,
index: number, index: number,
tactics: InventoryTile[], tactics: InventoryTile[],
lemmas: InventoryTile[], theorems: InventoryTile[],
definitions: InventoryTile[], definitions: InventoryTile[],
descrText: null|string, descrText: null|string,
descrFormat: null|string, descrFormat: null|string,
lemmaTab: null|string, theoremTab: null|string,
statementName: null|string, statementName: null|string,
displayName: null|string, displayName: null|string,
template: null|string, template: null|string,
@@ -58,9 +61,9 @@ export interface LevelInfo {
/** Used to display the inventory on the welcome page */ /** Used to display the inventory on the welcome page */
export interface InventoryOverview { export interface InventoryOverview {
tactics: InventoryTile[], tactics: InventoryTile[],
lemmas: InventoryTile[], theorems: InventoryTile[],
definitions: InventoryTile[], definitions: InventoryTile[],
lemmaTab: null, theoremTab: null,
} }
interface Doc { interface Doc {
@@ -81,13 +84,19 @@ export const apiSlice = createApi({
query: ({game}) => `${game}/game.json`, query: ({game}) => `${game}/game.json`,
}), }),
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({ loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
query: ({game, world, level}) => `${game}/level__${world}__${level}.json`, query: ({game, world, level}) => {
if (world && level > 0) {
return `${game}/level__${world}__${level}.json`
} else {
return `${game}/inventory.json`
}
},
}), }),
loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({ loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({
query: ({game}) => `${game}/inventory.json`, query: ({game}) => `${game}/inventory.json`,
}), }),
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({ loadDoc: builder.query<Doc, {game: string, name: string }>({
query: ({game, type, name}) => `${game}/doc__${type}__${name}.json`, query: ({game, name}) => `${game}/doc__${name}.json`,
}), }),
}), }),
}) })
+155
View File
@@ -0,0 +1,155 @@
/**
* @fileOverview This file contains the the react contexts used in the project.
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { InteractiveDiagnostic } from '@leanprover/infoview-api';
import { Diagnostic } from 'vscode-languageserver-types'
import { GameHint, InteractiveGoal, InteractiveTermGoal,InteractiveGoalsWithHints, ProofState } from '../components/infoview/rpc_api';
import { PreferencesState } from './preferences';
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
null as any)
export type InfoStatus = 'updating' | 'error' | 'ready';
export const GameIdContext = React.createContext<{
gameId: string,
worldId: string|null,
levelId: number|null}>({gameId: null, worldId: null, levelId: null});
// /** 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 "" as command
*/
proof: ProofState,
setProof: React.Dispatch<React.SetStateAction<ProofState>>
/** TODO: Workaround to capture a crash of the gameserver. */
interimDiags: Diagnostic[],
setInterimDiags: React.Dispatch<React.SetStateAction<Array<Diagnostic>>>
/** TODO: Workaround to capture a crash of the gameserver. */
crashed: Boolean,
setCrashed: React.Dispatch<React.SetStateAction<Boolean>>
}>({
proof: {steps: [], diagnostics: [], completed: false, completedWithWarnings: false},
setProof: () => {},
interimDiags: [],
setInterimDiags: () => {},
crashed: false,
setCrashed: () => {}
})
// TODO: Do we still need that?
export interface ProofStateProps {
// pos: DocumentPosition;
status: InfoStatus;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoalsWithHints;
termGoal?: InteractiveTermGoal;
error?: string;
// userWidgets: UserWidgetInstance[];
// rpcSess: RpcSessionAtPos;
// 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 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<PreferencesState["isSavePreferences"]>>;
setLanguage: React.Dispatch<React.SetStateAction<PreferencesState["language"]>>;
}
export const PreferencesContext = React.createContext<IPreferencesContext>({
mobile: false,
layout: "auto",
isSavePreferences: false,
language: "en",
setLayout: () => {},
setIsSavePreferences: () => {},
setLanguage: () => {},
})
export const ChatContext = React.createContext<{
selectedStep : number
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
deletedChat : GameHint[]
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
showHelp : Set<number>
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
chatRef: React.MutableRefObject<HTMLDivElement>
}>({
selectedStep : undefined,
setSelectedStep: () => {},
deletedChat: undefined,
setDeletedChat: () => {},
showHelp: undefined,
setShowHelp: () => {},
chatRef: null
})
export const PageContext = React.createContext<{
typewriterMode: boolean,
setTypewriterMode: React.Dispatch<React.SetStateAction<boolean>>,
typewriterInput: string,
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>,
lockEditorMode: boolean,
setLockEditorMode: React.Dispatch<React.SetStateAction<boolean>>,
page: number, /* only for mobile */
setPage: React.Dispatch<React.SetStateAction<number>>,
}>({
typewriterMode: true,
setTypewriterMode: () => {},
typewriterInput: "",
setTypewriterInput: () => {},
lockEditorMode: false,
setLockEditorMode: () => {},
page: 0,
setPage: () => {}
});
export const InputModeContext = React.createContext<{
typewriterMode: boolean,
setTypewriterMode: React.Dispatch<React.SetStateAction<boolean>>,
typewriterInput: string,
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>,
lockEditorMode: boolean,
setLockEditorMode: React.Dispatch<React.SetStateAction<boolean>>,
}>({
typewriterMode: true,
setTypewriterMode: () => {},
typewriterInput: "",
setTypewriterInput: () => {},
lockEditorMode: false,
setLockEditorMode: () => {},
});
+45
View File
@@ -0,0 +1,45 @@
import React, { useState } from "react";
import { useAppDispatch, useAppSelector } from "../../hooks";
import {
PreferencesState,
setLayout as setPreferencesLayout,
setIsSavePreferences as setPreferencesIsSavePreferences,
setLanguage as setLanguagePreferences,
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 language = useAppSelector((state) => state.preferences.language);
const setLanguage = (lang: string) => dispatch(setLanguagePreferences(lang))
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, language, setLayout, setIsSavePreferences, setLanguage}
}
export default UsePreferences;
+9
View File
@@ -57,3 +57,12 @@ export function savePreferences(state: any) {
// Ignore // Ignore
} }
} }
export function removePreferences() {
try {
localStorage.removeItem(PREFERENCES_KEY);
} catch (e) {
// Ignore
}
}
+16 -13
View File
@@ -1,10 +1,11 @@
import { createSlice } from "@reduxjs/toolkit"; import { createSlice } from "@reduxjs/toolkit";
import { loadPreferences } from "./local_storage"; import { loadPreferences, removePreferences, savePreferences } from "./local_storage";
interface PreferencesState { export interface PreferencesState {
mobile: boolean; layout: "mobile" | "auto" | "desktop";
lockMobile: boolean; isSavePreferences: boolean;
language: string;
} }
export function getWindowDimensions() { export function getWindowDimensions() {
@@ -12,26 +13,28 @@ export function getWindowDimensions() {
return {width, height} return {width, height}
} }
const { width } = getWindowDimensions()
export const AUTO_SWITCH_THRESHOLD = 800 export const AUTO_SWITCH_THRESHOLD = 800
const initialState: PreferencesState = loadPreferences() ??{ const initialState: PreferencesState = loadPreferences() ??{
mobile: width < AUTO_SWITCH_THRESHOLD, layout: "auto",
lockMobile: false isSavePreferences: false,
language: "en",
} }
export const preferencesSlice = createSlice({ export const preferencesSlice = createSlice({
name: "preferences", name: "preferences",
initialState, initialState,
reducers: { reducers: {
setMobile: (state, action) => { setLayout: (state, action) => {
state.mobile = action.payload; state.layout = action.payload;
}, },
setLockMobile: (state, action) => { setIsSavePreferences: (state, action) => {
state.lockMobile = action.payload; state.isSavePreferences = action.payload;
},
setLanguage: (state, action) => {
state.language = action.payload;
}, },
}, },
}); });
export const { setMobile, setLockMobile } = preferencesSlice.actions; export const { setLayout, setIsSavePreferences, setLanguage } = preferencesSlice.actions;
+59 -42
View File
@@ -19,13 +19,13 @@ interface LevelProgressState {
help: number[], // A set of rows where hidden hints have been displayed help: number[], // A set of rows where hidden hints have been displayed
} }
interface WorldProgressState { interface WorldProgressState {
[world: string] : {[level: number]: LevelProgressState}, [world: string] : {[level: number]: LevelProgressState, readIntro: boolean},
} }
export interface GameProgressState { export interface GameProgressState {
inventory: string[], inventory: string[],
difficulty: number, difficulty: number,
openedIntro: boolean, readIntro: boolean,
data: WorldProgressState, data: WorldProgressState,
typewriterMode?: boolean typewriterMode?: boolean
} }
@@ -53,22 +53,27 @@ const initalLevelProgressState: LevelProgressState = {code: "", completed: false
/** Add an empty skeleton with progress for the current game */ /** Add an empty skeleton with progress for the current game */
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) { function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
if (!state.games[action.payload.game]) { if (!state.games[action.payload.game.toLowerCase()]) {
state.games[action.payload.game] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY} state.games[action.payload.game.toLowerCase()] = {inventory: [], readIntro: false, data: {}, difficulty: DEFAULT_DIFFICULTY}
} }
if (!state.games[action.payload.game].data) { if (!state.games[action.payload.game.toLowerCase()].data) {
state.games[action.payload.game].data = {} state.games[action.payload.game.toLowerCase()].data = {}
}
}
function addWorldProgress(state: ProgressState, action: PayloadAction<{game: string, world: string}>) {
addGameProgress(state, action)
if (!state.games[action.payload.game.toLowerCase()].data[action.payload.world]) {
state.games[action.payload.game.toLowerCase()].data[action.payload.world] = {readIntro: false}
} }
} }
/** Add an empty skeleton with progress for the current level */ /** Add an empty skeleton with progress for the current level */
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) { function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addGameProgress(state, action) addGameProgress(state, action)
if (!state.games[action.payload.game].data[action.payload.world]) { addWorldProgress(state, action)
state.games[action.payload.game].data[action.payload.world] = {} 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}
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}
} }
} }
@@ -79,58 +84,67 @@ export const progressSlice = createSlice({
/** put edited code in the state and set completed to false */ /** put edited code in the state and set completed to false */
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) { codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
addLevelProgress(state, action) addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level].code = action.payload.code state.games[action.payload.game.toLowerCase()].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].completed = false
}, },
/** TODO: docstring */ /** TODO: docstring */
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) { changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
addLevelProgress(state, action) 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 */ /** mark level as completed */
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) { levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action) 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 */ /** Set the list of rows where help is displayed */
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) { helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
addLevelProgress(state, action) addLevelProgress(state, action)
console.debug(`!setting help to: ${action.payload.help}`) 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 */ /** delete all progress for this game */
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) { 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: {}, readIntro: false, difficulty: DEFAULT_DIFFICULTY}
},
/** delete progress for this world */
deleteWorldProgress(state: ProgressState, action: PayloadAction<{game: string, world: string}>) {
// addWorldProgress(state, action)
state.games[action.payload.game.toLowerCase()].data[action.payload.world] = {readIntro: false}
}, },
/** delete progress for this level */ /** delete progress for this level */
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) { deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action) // 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 */ /** load progress, e.g. from external import */
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) { loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
console.debug(`setting data to:\n ${action.payload.data}`) 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 */ /** set the current inventory */
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) { changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
addGameProgress(state, action) 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 */ /** set the difficulty */
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) { changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
addGameProgress(state, action) 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 */ /** set the difficulty */
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) { changedReadIntro(state: ProgressState, action: PayloadAction<{game: string, world: string, readIntro: boolean}>) {
addGameProgress(state, action) addWorldProgress(state, action)
state.games[action.payload.game].openedIntro = action.payload.openedIntro if (action.payload.world) {
state.games[action.payload.game.toLowerCase()].data[action.payload.world].readIntro = action.payload.readIntro
} else {
state.games[action.payload.game.toLowerCase()].readIntro = action.payload.readIntro
}
}, },
/** set the typewriter mode */ /** set the typewriter mode */
changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) { changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) {
addGameProgress(state, action) addGameProgress(state, action)
state.games[action.payload.game].typewriterMode = action.payload.typewriterMode state.games[action.payload.game.toLowerCase()].typewriterMode = action.payload.typewriterMode
} }
} }
}) })
@@ -138,78 +152,81 @@ export const progressSlice = createSlice({
/** if the level does not exist, return default values */ /** if the level does not exist, return default values */
export function selectLevel(game: string, world: string, level: number) { export function selectLevel(game: string, world: string, level: number) {
return (state) => { return (state) => {
if (!state.progress.games[game]) { return initalLevelProgressState } if (!state.progress.games[game?.toLowerCase()]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world]) { return initalLevelProgressState } if (!state.progress.games[game?.toLowerCase()]?.data[world]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState } if (!state.progress.games[game?.toLowerCase()]?.data[world][level]) { return initalLevelProgressState }
return state.progress.games[game].data[world][level] return state.progress.games[game?.toLowerCase()]?.data[world][level]
} }
} }
/** return the code of the current level */ /** return the code of the current level */
export function selectCode(game: string, world: string, level: number) { export function selectCode(game: string, world: string, level: number) {
return (state) => { return (state) => {
return selectLevel(game, world, level)(state).code return selectLevel(game?.toLowerCase(), world, level)(state).code
} }
} }
/** return the current inventory */ /** return the current inventory */
export function selectInventory(game: string) { export function selectInventory(game: string) {
return (state) => { return (state) => {
if (!state.progress.games[game]) { return [] } if (!state.progress.games[game?.toLowerCase()]) { return [] }
return state.progress.games[game].inventory return state.progress.games[game?.toLowerCase()]?.inventory
} }
} }
/** return the code of the current level */ /** return the code of the current level */
export function selectHelp(game: string, world: string, level: number) { export function selectHelp(game: string, world: string, level: number) {
return (state) => { 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 */ /** return the selections made in the current level */
export function selectSelections(game: string, world: string, level: number) { export function selectSelections(game: string, world: string, level: number) {
return (state) => { return (state) => {
return selectLevel(game, world, level)(state).selections return selectLevel(game?.toLowerCase(), world, level)(state).selections
} }
} }
/** return whether the current level is clompleted */ /** return whether the current level is clompleted */
export function selectCompleted(game: string, world: string, level: number) { export function selectCompleted(game: string, world: string, level: number) {
return (state) => { 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 */ /** return progress for the current game if it exists */
export function selectProgress(game: string) { export function selectProgress(game: string) {
return (state) => { return (state) => {
return state.progress.games[game] ?? null return state.progress.games[game?.toLowerCase()] ?? null
} }
} }
/** return difficulty for the current game if it exists */ /** return difficulty for the current game if it exists */
export function selectDifficulty(game: string) { export function selectDifficulty(game: string) {
return (state) => { 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 */ /** return whether the intro has been read */
export function selectOpenedIntro(game: string) { export function selectReadIntro(game: string, worldId: string) {
return (state) => { return (state) => {
return state.progress.games[game]?.openedIntro if (worldId) {
return state.progress.games[game?.toLowerCase()]?.data[worldId]?.readIntro
}
return state.progress.games[game?.toLowerCase()]?.readIntro
} }
} }
/** return typewriter mode for the current game if it exists */ /** return typewriter mode for the current game if it exists */
export function selectTypewriterMode(game: string) { export function selectTypewriterMode(game: string) {
return (state) => { return (state) => {
return state.progress.games[game]?.typewriterMode ?? true return state.progress.games[game?.toLowerCase()]?.typewriterMode ?? true
} }
} }
/** Export actions to modify the progress */ /** Export actions to modify the progress */
export const { changedSelection, codeEdited, levelCompleted, deleteProgress, export const { changedSelection, codeEdited, levelCompleted, deleteProgress,
deleteLevelProgress, loadProgress, helpEdited, changedInventory, changedOpenedIntro, deleteLevelProgress, deleteWorldProgress, loadProgress, helpEdited, changedInventory, changedReadIntro,
changedDifficulty, changeTypewriterMode} = progressSlice.actions changedDifficulty, changeTypewriterMode} = progressSlice.actions
+4 -2
View File
@@ -8,7 +8,7 @@ import { connection } from '../connection'
import { apiSlice } from './api' import { apiSlice } from './api'
import { progressSlice } from './progress' import { progressSlice } from './progress'
import { preferencesSlice } from "./preferences" import { preferencesSlice } from "./preferences"
import { saveState, savePreferences } from "./local_storage"; import { saveState, savePreferences, removePreferences} from "./local_storage";
export const store = configureStore({ export const store = configureStore({
@@ -29,7 +29,9 @@ export const store = configureStore({
store.subscribe( store.subscribe(
debounce(() => { debounce(() => {
saveState(store.getState()[progressSlice.name]); saveState(store.getState()[progressSlice.name]);
savePreferences(store.getState()[preferencesSlice.name]);
const preferencesState = store.getState()[preferencesSlice.name]
preferencesState.isSavePreferences ? savePreferences(preferencesState) : removePreferences()
}, 800) }, 800)
); );
+3 -3
View File
@@ -8,7 +8,7 @@ relays messages between the lean server and the client. `index.mjs` is the file
be run, which is done for example using `pm2` or by calling `npm run start_server` or be run, which is done for example using `pm2` or by calling `npm run start_server` or
`npm run production`, see more later. `npm run production`, see more later.
The latter, "server", is the lean server which has two jobs. For one it produces the "gameserver" 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 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 is to provide the lean commands which are used when creating a game. These are located in
`Commands.lean`. `Commands.lean`.
@@ -27,7 +27,7 @@ saved to lean env-extensions which the lean server has access to after loading t
For games to be run successfully, it is important that the "gameserver" executable inside For games to be run successfully, it is important that the "gameserver" executable inside
the game's `.lake` folder is actually built. 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`. 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!) (both commands are to be executed in the game's directory!)
## Modifying the server ## Modifying the server
@@ -50,7 +50,7 @@ npm run start_client
npm run production npm run production
``` ```
(in two separate terminals) to test the production modus of the server. This way it will only (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. change once you build and restart the server.
### Modifying the lean server ### Modifying the lean server
+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
+9
View File
@@ -0,0 +1,9 @@
# Client
This document describes features of the frontent/client side.
## URL
You can add `?lang=de` at the end of the URL to specify the language that should be set. This
will change the user's preferred language to the one specified.
+45 -27
View File
@@ -46,6 +46,22 @@ This game has been developed by me.
MakeGame MakeGame
``` ```
### Local notations
**Important**: If you use any local notations in your game, you need to open these
namespaces manually before `MakeGame` in order to see the notation in the inventory.
For example (using mathlib), write
```
open BigOperators
MakeGame
```
in order to see `∑ x in s, f x` instead of `Finset.sum s fun x => f x`.
## 3. Creating a Level ## 3. Creating a Level
In this tutorial we first learn about Levels. A game consists of a tree of worlds, each world has In this tutorial we first learn about Levels. A game consists of a tree of worlds, each world has
@@ -98,7 +114,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. 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` 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 ```text
No world introducing sorry, but required by MyWorld No world introducing sorry, but required by MyWorld
@@ -125,26 +141,32 @@ The player has an inventory with tactics, theorems, and definitions that unlock
```lean ```lean
NewTactic induction simp NewTactic induction simp
NewLemma Nat.zero_mul NewTheorem Nat.zero_mul
NewDefinition Pow NewDefinition Pow
``` ```
**Important:** All commands in this section 6a) expect the `Name` they take as input **Important:** All commands in this section 6a) expect the `Name` they take as input
to be **fully qualified**. For example `NewLemma Nat.zero_mul` and not `NewLemma zero_mul`. to be **fully qualified**. For example `NewTheorem Nat.zero_mul` and not `NewTheorem zero_mul`.
#### Doc entries #### 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 ```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 TacticDoc simp
"some description"
/--
some description
-/
DefinitionDoc Pow as "^" DefinitionDoc Pow as "^"
"some description"
``` ```
(e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it) (e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it)
@@ -156,7 +178,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: You have a few options to disable inventory items that have been unlocked in previous levels:
```lean ```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 have the same syntax as above. The former two disable items for this level, the latter two
@@ -164,7 +186,7 @@ disable all items except the ones specified.
#### Theorem Tab #### 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 #### HiddenTactic
@@ -179,12 +201,11 @@ and only `rw` would show up in the inventory.
### 6. b) Statement ### 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 #### 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 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`. You can but a `Statement` inside namespaces like you would with `theorem`.
#### Doc String / Exercise statement #### Doc String / Exercise statement
@@ -197,25 +218,18 @@ Statement ...
sorry sorry
``` ```
#### Attributes For more details and features, read [Writing Exercises](writing_exercises.md)
You can add attributes as you would for a `theorem`. Most notably, you can make your named exercise a `simp` lemma:
```lean
@[simp]
Statement my_simp_lemma ...
```
### 6. c) Proof ### 6. c) Proof
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax. 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 - `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. 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 - `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. proof and does not need to finish any goals.
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template` - `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 will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
@@ -230,7 +244,7 @@ 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 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. placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands.
Read [More about Hints](doc/hints.md) for how they work and what the options are. Read [More about Hints](hints.md) for how they work and what the options are.
### 6. e) Extra: Images ### 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. You can add images on any layer of the game (i.e. game/world/level). These will be displayed in your game.
@@ -242,11 +256,15 @@ NOTE: At present, only the images for a world are displayed. They appear in the
## 7. Update your game ## 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](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 ## 8. Add translation
To publish your game on the official server, see [Publishing a game](doc/publish_game.md) See [Translating a game](translate.md).
## 9. 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: 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:
+50 -4
View File
@@ -10,7 +10,7 @@ Statement .... := by
... ...
``` ```
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 placing hints outside the sample solution's proof. 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? ## 1. When do hints show?
@@ -19,7 +19,7 @@ sample solutions and the entire context from the sample solutions is present in
player's context. The player's context may contain additional items. player's context. The player's context may contain additional items.
This means if you have multiple identical 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 subgoals, you should only place a single hint in one of them, and it will be displayed in
all of them. all of them.
However, identical (non-hidden) hints which where already present in the step However, identical (non-hidden) hints which where already present in the step
@@ -32,12 +32,12 @@ Hidden hints are not filtered.
You can use `Branch` to place hints You can use `Branch` to place hints
in dead ends or alternative proof strands. 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 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. so that no progress has been made on proofing the goal.
``` ```
Statement .... := by Statement .... := by
Hint "Huse `rw` or `rewrite`." Hint "use `rw` or `rewrite`."
Branch Branch
rewrite [h] rewrite [h]
Hint "now you still need `rfl`" Hint "now you still need `rfl`"
@@ -86,4 +86,50 @@ create new assumptions.
You can add use markdown to format your hints, for example you can use KaTex: `$\\iff$` You can add use markdown to format your hints, for example you can use KaTex: `$\\iff$`
**Escaping**: Generally, if you add text inside quotes `" "` (e.g. in `Hint`) you need to escape
backslashes, but if you provide text inside a doc comment
`/-- -/` (e.g. in the `Statement` description) you do not!
TODO: Write a doc about latex/markdown options available. TODO: Write a doc about latex/markdown options available.
### Commutative diagrams
Here is an example of how to write a commutative diagram in KaTeX:
$$
\begin{CD}
A @>{f}>> B @<{g}<< C \\
@V{h}VV @V{i}VV @V{j}VV \\
D @<{k}<< E @>{l}>> F \\
@A{m}AA @A{n}AA @V{p}VV \\
G @<{q}<< H @>{r}>> I
\end{CD}
$$
```
$$
\\begin{CD}
A @>{f}>> B @<{g}<< C \\\\
@V{h}VV @V{i}VV @V{j}VV \\\\
D @<{k}<< E @>{l}>> F \\\\
@A{m}AA @A{n}AA @V{p}VV \\\\
G @<{q}<< H @>{r}>> I
\\end{CD}
$$
```
See https://www.jmilne.org/not/Mamscd.pdf
### Images
Hints and introductions/conclusions can also contain images.
For remote images, simply add:
```
<img src=\"https://url.com/to/image\"/>
```
Local images can currently only be included with a hack:
Images in the game's `images/` folder will be accessible at `data/g/[user]/[repo]/[image].[png|jpg|…]` and thus can be included as if they were external images.
+2 -2
View File
@@ -1,6 +1,6 @@
# Publishing games # Publishing games
You can publish your game on the official (Lean Game Server)[https://adam.math.hhu.de] in a few simple You can publish your game on the official [Lean Game Server](https://adam.math.hhu.de) in a few simple
steps. steps.
## 1. Upload Game to github ## 1. Upload Game to github
@@ -11,7 +11,7 @@ tab.
## 2. Import the game ## 2. Import the game
You call the URL that's listed under "What's Next?" in the latest action run. Explicitely you call You call the URL that's listed under "What's Next?" in the latest action run. Explicitly you call
the URL of the form the URL of the form
> adam.math.hhu.de/import/trigger/{USER}/{REPOSITORY} > adam.math.hhu.de/import/trigger/{USER}/{REPOSITORY}
+27 -12
View File
@@ -2,6 +2,8 @@
The installation instructions are not yet tested on Mac/Windows. Comments very welcome! 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: There are several options to play a game locally:
1. VSCode Dev Container: needs `docker` installed on your machine 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 * The first start will take a while, ca. 2-15 minutes. After the first
start this should be very quickly. 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/> 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. 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 ## 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. Note: You have to wait until npm started properly, which might take a good while.
@@ -108,6 +100,29 @@ Run the game:
npm start 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. 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 ## Modifying the GameServer
+3 -3
View File
@@ -6,7 +6,7 @@ In order to set up the server to allow imports, one needs to create a
repos will suffice. repos will suffice.
You need to set the environment variables `LEAN4GAME_GITHUB_USER` and `LEAN4GAME_GITHUB_TOKEN` 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 seet these in `ecosystem.config.cjs` if with your user name and access token. For example, you can set these in `ecosystem.config.cjs` if
you're using `pm2` you're using `pm2`
Then people can call: Then people can call:
@@ -22,7 +22,7 @@ where you replace:
> https://{website}/#/g/{owner}/{repo} > https://{website}/#/g/{owner}/{repo}
## data management ## Data management
Everything downloaded remains in the folder `lean4game/games`. Everything downloaded remains in the folder `lean4game/games`.
the subfolder `tmp` contains downloaded artifacts and can be deleted without loss. 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. The other folders should only contain the built lean-games, sorted by owner and repo.
+30
View File
@@ -0,0 +1,30 @@
# Translation
The game server supports internationalisation ("i18n") using [lean-i18n](https://github.com/hhu-adam/lean-i18n) and [i18next](https://www.npmjs.com/package/i18next).
The intended workflow currently is the following:
1. When you call `lake build` in your game, it should automatically create a template file `.i18n/en/Game.pot`. Alternatively you can call `lake exe i18n --template` to recreate it.
2. Open the file `Game.pot` (the "t" stands for "template") with [Poedit](https://poedit.net/) (or a similar software) and translate all strings. Save your work as `.i18n/{language}/Game.po`.
4. Call `lake exe i18n --export-json` to create all Json files `.i18n/{language}/Game.json` which the server needs.
5. Add your translations (i.e. `.po` and `.json`, but not the `.mo` files) and push your results, and [publish the game](publish_game.md).
If you choose the correct language in the "Preferences" of the game, you should see your translations.
## Alternative: avoiding .po
Note: This workflow is subject to change, and it might be that in future the server can directly read `.po` files. Until then, there is also an alternative workflow you might choose:
0. Add `useJson: true` to `.i18n/config.json`
1. `lake build` or `lake exe i18n --template` will now create a Json instead: `.i18n/en/Game.json`.
2. Add translations to a copy of this Json an save it as `.i18n/{language}/Game.json`
## Non-English games
For games written in a language different than English, you should set the correct source language (`sourceLang`) in `.i18n/config.json`. Afterwards, on `lake build` the template should appear under the chosen language, and can be translated (e.g. into English) as described above.
## New languages
The server has a set number of languages one can select.
If your game has been translated to a language not selectable, [open an issue at lean4game](https://github.com/leanprover-community/lean4game/issues) requesting this new language.
Or, even better, create a PR to translate the [server interface](https://github.com/leanprover-community/lean4game/tree/main/client/public/locales) into that new language.
+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".
+56
View File
@@ -0,0 +1,56 @@
# Writing exercises
This page deals in more details with the `Statement` command and all the options you have
to write better exercises/levels.
## Local `let` definitions
If you want to make a local definition/notation which only holds for this exercise (e.g.
a function `f : ℤ → ℤ := fun x ↦ 2 * x`) the recommended way is to use a `let`-statement:
```lean
Statement (a : ℤ) (h : 0 < a) :
let f : ℤ → ℤ := fun x ↦ 2 * x
0 < f a := by
sorry
```
The game automatically `intros` such `let`-statements, such that you and the player will see
the following initial proof state:
```
a: ℤ
h: 0 < a
f: ℤ → ℤ := fun x => 2 * x
⊢ 0 < f a
```
## "Preamble" & non-`Prop`-valued exercises
You can use the following syntax with `(preamble := tac)` where `tac` is a tactic sequence.
```
Statement my_statement (preamble := dsimp) (a : ℤ) (h : 0 < a) :
0 < f a := by
sorry
```
This tactic sequence will be executed before the exercise is handed to the player.
For example, if your exercise is to construct a structure, you could use `preamble` to fill
all data fields correctly, leaving all `Prop`-valued fields of the structure as separate goals
for the player to proof.
Note: `(preamble := tac)` always has to be written between the optional name and the first
hypothesis. Nevertheless, you can use all hypotheses in the tactic sequence, because it is
only evaluated at the beginning of the proof.
## Attributes
You can add attributes as you would for a `theorem`. Most notably, you can make your named exercise a `simp` theorem:
```lean
@[simp]
Statement my_simp_theorem ...
```
+9 -8
View File
@@ -5,11 +5,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lean Game Server</title> <title>Lean Game Server</title>
<style>
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
</style>
</head> </head>
<body> <body>
@@ -23,13 +18,19 @@
</p> </p>
<p> <p>
<strong>Impressum:</strong><br /> <strong>Impressum:</strong><br />
Jon Eugster<br /> Marcus Zibrowius, Jon Eugster<br />
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br /> Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
Universitätsstr. 1<br /> Universitätsstr. 1<br />
40225 Düsseldorf<br /> 40225 Düsseldorf<br />
Germany<br /> Germany<br />
+49 211 81-12173<br /> +49 211 81-14690<br />
<a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a> <a href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team">Contact Details</a>
</p>
<p>
<strong>Datenschutzerklärung:</strong><br />
Without JavaScript this website cannot be used and therefore no personal data is collected
or stored.<br />
Our Server is located in Germany.<br />
</p> </p>
</div> </div>
</noscript> </noscript>
+6139 -9147
View File
File diff suppressed because it is too large Load Diff
+16 -7
View File
@@ -14,7 +14,6 @@
"@fortawesome/free-regular-svg-icons": "^6.5.1", "@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@leanprover/infoview": "^0.4.3",
"@mui/icons-material": "^5.11.0", "@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1", "@mui/material": "^5.11.1",
"@reduxjs/toolkit": "^1.9.1", "@reduxjs/toolkit": "^1.9.1",
@@ -27,23 +26,31 @@
"cytoscape-klay": "^3.1.4", "cytoscape-klay": "^3.1.4",
"debounce": "^1.2.1", "debounce": "^1.2.1",
"express": "^4.18.2", "express": "^4.18.2",
"i18next": "^23.10.1",
"i18next-http-backend": "^2.5.0",
"i18next-scanner-typescript": "^1.2.0",
"ip-anonymize": "^0.1.0",
"lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c", "lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c",
"lean4web": "github:hhu-adam/lean4web#b91645a7b88814675ba9f99817436d0a2ce3a0ec", "lean4monaco": "^1.0.29",
"octokit": "^2.0.14", "memfs": "^4.11.1",
"octokit": "^3.1.2",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"react": "^18.2.0", "react-contenteditable": "^3.3.7",
"react-country-flag": "^3.1.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0",
"react-markdown": "^8.0.4", "react-markdown": "^8.0.4",
"react-native": "^0.72.3",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-split": "^2.0.14", "react-split": "^2.0.14",
"rehype-katex": "^6.0.2", "rehype-katex": "^6.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-math": "^5.1.1", "remark-math": "^5.1.1",
"request": "^2.88.2", "request": "^2.88.2",
"request-progress": "^3.0.0", "request-progress": "^3.0.0",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-node-polyfills": "0.17.0",
"vite-plugin-static-copy": "^0.17.0", "vite-plugin-static-copy": "^0.17.0",
"vite-plugin-svgr": "^4.1.0", "vite-plugin-svgr": "^4.1.0",
"vscode-ws-jsonrpc": "^2.0.1", "vscode-ws-jsonrpc": "^2.0.1",
@@ -51,6 +58,7 @@
"ws": "^8.11.0" "ws": "^8.11.0"
}, },
"devDependencies": { "devDependencies": {
"@codingame/esbuild-import-meta-url-plugin": "https://gitpkg.vercel.app/hhu-adam/lean4monaco/esbuild-import-meta-url-plugin?main",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@redux-devtools/core": "^3.13.1", "@redux-devtools/core": "^3.13.1",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
@@ -58,11 +66,11 @@
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"i18next-scanner": "^4.4.0",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"react-refresh": "^0.14.0", "react-refresh": "^0.14.0",
"style-loader": "^3.3.1", "style-loader": "^3.3.1",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"typescript": "^4.9.4",
"url-loader": "^4.1.1" "url-loader": "^4.1.1"
}, },
"scripts": { "scripts": {
@@ -73,7 +81,8 @@
"preview": "vite preview", "preview": "vite preview",
"build_server": "cd server && lake build", "build_server": "cd server && lake build",
"build_client": "cross-env NODE_ENV=production vite build", "build_client": "cross-env NODE_ENV=production vite build",
"production": "cross-env NODE_ENV=production node relay/index.mjs" "production": "cross-env NODE_ENV=production node relay/index.mjs",
"translate": "npx i18next-scanner --config client/i18next-scanner.config.cjs"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
+16 -7
View File
@@ -1,17 +1,21 @@
#/bin/bash #/bin/bash
# Note: This fails if there is no default toolchain installed # Note: This fails if there is no default toolchain installed
ELAN_HOME=$(lake env printenv ELAN_HOME) LEAN_ROOT="$(cd $1 && lean --print-prefix)"
LEAN_PATH="$(cd $1 && lake env printenv LEAN_PATH)"
# $1 : the game directory # $1 : the game directory
# $2 : the lean4game folder # $2 : the lean4game folder
# $3 : the gameserver executable # $3 : the gameserver executable
# # print commands as they are executed
# set -x
(exec bwrap\ (exec bwrap\
--bind $2 /lean4game \ --ro-bind $2 /lean4game \
--bind $1 /game \ --ro-bind $1 /game \
--bind $ELAN_HOME /elan \ --ro-bind "$LEAN_ROOT" /lean \
--bind /usr /usr \ --ro-bind /usr /usr \
--dev /dev \ --dev /dev \
--proc /proc \ --proc /proc \
--symlink usr/lib /lib\ --symlink usr/lib /lib\
@@ -19,8 +23,9 @@ ELAN_HOME=$(lake env printenv ELAN_HOME)
--symlink usr/bin /bin\ --symlink usr/bin /bin\
--symlink usr/sbin /sbin\ --symlink usr/sbin /sbin\
--clearenv \ --clearenv \
--setenv PATH "/elan/bin:/bin" \ --setenv PATH "/lean/bin" \
--setenv ELAN_HOME "/elan" \ --setenv LAKE "/no" `# tries to invoke git otherwise` \
--setenv LEAN_PATH "$LEAN_PATH" \
--unshare-user \ --unshare-user \
--unshare-pid \ --unshare-pid \
--unshare-net \ --unshare-net \
@@ -30,3 +35,7 @@ ELAN_HOME=$(lake env printenv ELAN_HOME)
--chdir "/game/.lake/packages/GameServer/server/.lake/build/bin/" \ --chdir "/game/.lake/packages/GameServer/server/.lake/build/bin/" \
./gameserver --server /game ./gameserver --server /game
) )
# TODO
# --chdir "/game/.lake/packages/GameServer/server/.lake/build/bin/" \
# ./gameserver --server /game
+115 -64
View File
@@ -1,15 +1,18 @@
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws'
import express from 'express' import express from 'express'
import path from 'path' import * as cp from 'child_process'
import * as cp from 'child_process'; import * as url from 'url'
import * as url from 'url'; import * as rpc from 'vscode-ws-jsonrpc'
import * as rpc from 'vscode-ws-jsonrpc'; import * as path from 'path'
import * as jsonrpcserver from 'vscode-ws-jsonrpc/server'; import * as jsonrpcserver from 'vscode-ws-jsonrpc/server'
import os from 'os'; // import nocache from 'nocache'
import fs from 'fs'; import anonymize from 'ip-anonymize'
import anonymize from 'ip-anonymize'; import os from 'os'
import fs from 'fs'
import http from 'http'
import https from 'https'
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. * Add a game here if the server should keep a queue of pre-loaded games ready at all times.
@@ -19,40 +22,75 @@ import { importTrigger, importStatus } from './import.mjs'
const queueLength = { const queueLength = {
"g/hhu-adam/robo": 2, "g/hhu-adam/robo": 2,
"g/hhu-adam/nng4": 5, "g/hhu-adam/nng4": 5,
"g/djvelleman/stg4": 2, "g/djvelleman/stg4": 0,
"g/trequetrum/lean4game-logic": 0,
} }
const __filename = url.fileURLToPath(import.meta.url); let socketCounter = 0
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const app = express() function logStats() {
console.log(`[${new Date()}] Number of open sockets - ${socketCounter}`)
console.log(`[${new Date()}] Free RAM - ${Math.round(os.freemem() / 1024 / 1024)} / ${Math.round(os.totalmem() / 1024 / 1024)} MB`)
}
const PORT = process.env.PORT || 8080; const __filename = url.fileURLToPath(import.meta.url)
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
var router = express.Router();
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'))) // 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}`));
const wss = new WebSocketServer({ server })
var socketCounter = 0
const environment = process.env.NODE_ENV const environment = process.env.NODE_ENV
const isDevelopment = environment === 'development' const isDevelopment = environment === 'development'
const crtFile = process.env.SSL_CRT_FILE
const keyFile = process.env.SSL_KEY_FILE
const app = express()
var router = express.Router()
// Paths for game import logic
router.get('/import/status/:owner/:repo', importStatus)
router.get('/import/trigger/:owner/:repo', importTrigger)
app.use(express.static(path.join(__dirname, '..', 'client', 'dist'))) // TODO: add a dist folder from inside the game
app.use('/i18n/g/:owner/:repo/:lang/*', (req, res, next) => {
const owner = req.params.owner
const repo = req.params.repo
const lang = req.params.lang
const filename = req.params[0]
req.url = filename
express.static(path.join(getGameDir(owner,repo),".i18n",lang))(req, res, next)
})
app.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)
})
app.use('/', router)
let server
if (crtFile && keyFile) {
var privateKey = fs.readFileSync(keyFile, 'utf8')
var certificate = fs.readFileSync(crtFile, 'utf8')
var credentials = {key: privateKey, cert: certificate}
const PORT = process.env.PORT ?? 443
server = https.createServer(credentials, app).listen(PORT,
() => console.log(`HTTPS on port ${PORT}`))
// redirect http to https
express().get('*', function(req, res) {
res.redirect('https://' + req.headers.host + req.url).listen(80)
})
} else {
const PORT = process.env.PORT ?? 8080
server = app.listen(PORT,
() => console.log(`HTTP on port ${PORT}`))
}
const wss = new WebSocketServer({ server })
/** We keep queues of started Lean Server processes to be ready when a user arrives */ /** We keep queues of started Lean Server processes to be ready when a user arrives */
const queue = {} const queue = {}
@@ -76,7 +114,8 @@ function getGameDir(owner, repo) {
} }
let game_dir = (owner == 'local') ? let game_dir = (owner == 'local') ?
path.join(__dirname, '..', '..', repo) : // note: here we need `repo` to be case sensitive // note: in the local case we need `repo` to be case sensitive
path.join(__dirname, '..', '..', repo) :
path.join(__dirname, '..', 'games', `${owner}`, `${repo.toLowerCase()}`) path.join(__dirname, '..', 'games', `${owner}`, `${repo.toLowerCase()}`)
if(!fs.existsSync(game_dir)) { if(!fs.existsSync(game_dir)) {
@@ -84,16 +123,20 @@ function getGameDir(owner, repo) {
return "" return ""
} }
return game_dir; return game_dir
} }
function startServerProcess(owner, repo) { function startServerProcess(owner, repo) {
let game_dir = getGameDir(owner, repo) let game_dir = getGameDir(owner, repo)
if (!game_dir) return; if (!game_dir) { return }
let serverProcess let serverProcess
if (isDevelopment) { if (isDevelopment) {
console.warn("Running without Bubblewrap container!")
// TODO: use the gameserver
// serverProcess = cp.spawn("lean", ["--server"], { cwd: game_dir })
let args = ["--server", game_dir] let args = ["--server", game_dir]
let binDir = path.join(game_dir, ".lake", "packages", "GameServer", "server", ".lake", "build", "bin") 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 // Note: `cwd` is important to be the `bin` directory as `Watchdog` calls `./gameserver` again
@@ -106,19 +149,24 @@ function startServerProcess(owner, repo) {
{ cwd: path.join(__dirname, "..", "server", ".lake", "build", "bin") }) { cwd: path.join(__dirname, "..", "server", ".lake", "build", "bin") })
} }
} else { } else {
serverProcess = cp.spawn("./bubblewrap.sh", console.info("Running with Bubblewrap container.")
serverProcess = cp.spawn(
"./bubblewrap.sh",
[ game_dir, path.join(__dirname, '..')], [ game_dir, path.join(__dirname, '..')],
{ cwd: __dirname }) { cwd: __dirname }
)
} }
serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`)
)
if (serverProcess.stderr !== null) {
serverProcess.stderr.on('data', data => serverProcess.stderr.on('data', data =>
console.error(`Lean Server: ${data}`) console.error(`Lean Server: ${data}`)
) )
} serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`)
)
serverProcess.on('close', (code, _signal) => {
console.log(`Lean server exited with code ${code}`)
})
return serverProcess return serverProcess
} }
@@ -143,34 +191,32 @@ function fillQueue(tag) {
// } // }
// } // }
const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)$/
wss.addListener("connection", function(ws, req) { wss.addListener("connection", function(ws, req) {
// server expects URL of the form `/websocket/g/{owner}/{repo}`
const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)\/?$/
const reRes = urlRegEx.exec(req.url) const reRes = urlRegEx.exec(req.url)
if (!reRes) { console.error(`Connection refused because of invalid URL: ${req.url}`); return; } if (!reRes) { console.error(`Connection refused because of invalid URL: ${req.url}`); return }
const owner = reRes[1] const owner = reRes[1]
const repo = reRes[2] const repo = reRes[2]
const tag = getTag(owner, repo) const tag = getTag(owner, repo)
const ip = anonymize(req.headers['x-forwarded-for'] || req.socket.remoteAddress)
let ps let ps
if (!queue[tag] || queue[tag].length == 0) { if (!queue[tag] || queue[tag].length == 0) {
ps = startServerProcess(owner, repo) ps = startServerProcess(owner, repo)
} else { } else {
console.info('Got process from the queue') console.info('Got process from the queue')
ps = queue[tag].shift() // Pick the first Lean process; it's likely to be ready immediately ps = queue[tag].shift() // Pick the first Lean process; it's likely to be ready immediately
fillQueue(tag) // TODO
// async () => {
// fillQueue(tag)
// }
} }
if (ps == null) { if (ps == null) {
console.error('server process is undefined/null') console.error('server process is undefined/null')
return return
} }
socketCounter += 1;
const ip = anonymize(req.headers['x-forwarded-for'] || req.socket.remoteAddress)
console.log(`[${new Date()}] Socket opened - ${ip}`)
const socket = { const socket = {
onMessage: (cb) => { ws.on("message", cb) }, onMessage: (cb) => { ws.on("message", cb) },
onError: (cb) => { ws.on("error", cb) }, onError: (cb) => { ws.on("error", cb) },
@@ -182,16 +228,17 @@ wss.addListener("connection", function(ws, req) {
const socketConnection = jsonrpcserver.createConnection(reader, writer, () => ws.close()) const socketConnection = jsonrpcserver.createConnection(reader, writer, () => ws.close())
const serverConnection = jsonrpcserver.createProcessStreamConnection(ps) const serverConnection = jsonrpcserver.createProcessStreamConnection(ps)
socketConnection.forward(serverConnection, message => { socketConnection.forward(serverConnection, message => {
if (isDevelopment) {console.log(`CLIENT: ${JSON.stringify(message)}`)} if (isDevelopment) {
return message; console.log(`CLIENT: ${JSON.stringify(message)}`)
}
return message
}) })
serverConnection.forward(socketConnection, message => { serverConnection.forward(socketConnection, message => {
if (isDevelopment) {console.log(`SERVER: ${JSON.stringify(message)}`)} if (isDevelopment) {
return message; console.log(`SERVER: ${JSON.stringify(message)}`)
}); }
return message
console.log(`[${new Date()}] Number of open sockets - ${socketCounter}`) })
console.log(`[${new Date()}] Free RAM - ${Math.round(os.freemem() / 1024 / 1024)} / ${Math.round(os.totalmem() / 1024 / 1024)} MB`)
ws.on('close', () => { ws.on('close', () => {
console.log(`[${new Date()}] Socket closed - ${ip}`) console.log(`[${new Date()}] Socket closed - ${ip}`)
@@ -200,4 +247,8 @@ wss.addListener("connection", function(ws, req) {
socketConnection.onClose(() => serverConnection.dispose()) socketConnection.onClose(() => serverConnection.dispose())
serverConnection.onClose(() => socketConnection.dispose()) serverConnection.onClose(() => socketConnection.dispose())
console.log(`[${new Date()}] Socket opened - ${ip}`)
socketCounter += 1
logStats()
}) })
+4
View File
@@ -28,3 +28,7 @@ do
#tar -xvzf $f -C games/${OWNER}/${REPO} #tar -xvzf $f -C games/${OWNER}/${REPO}
unzip -q -o $f -d ${OWNER}/${REPO} unzip -q -o $f -d ${OWNER}/${REPO}
done done
# Delete temporary files
rm -f tmp/${OWNER}_${REPO}_${ARTIFACT_ID}.zip
rm -fr tmp/${OWNER}_${REPO}_${ARTIFACT_ID}
+1 -1
View File
@@ -11,7 +11,7 @@ unsafe def main : List String → IO UInt32 := fun args => do
-- TODO: remove this argument -- TODO: remove this argument
if args[0]? == some "--server" then if args[0]? == some "--server" then
MyServer.FileWorker.workerMain {} args GameServer.FileWorker.workerMain {} args
else else
e.putStrLn s!"Expected `--server`" e.putStrLn s!"Expected `--server`"
return 1 return 1
+167 -116
View File
@@ -2,9 +2,15 @@ import GameServer.Helpers
import GameServer.Inventory import GameServer.Inventory
import GameServer.Options import GameServer.Options
import GameServer.SaveData import GameServer.SaveData
import GameServer.Hints
import GameServer.Tactic.LetIntros
-- import GameServer.RpcHandlers -- only needed to collect the translations of "level completed" msgs
import I18n
open Lean Meta Elab Command open Lean Meta Elab Command
open GameServer
set_option autoImplicit false set_option autoImplicit false
/-! # Game metadata -/ /-! # Game metadata -/
@@ -32,16 +38,17 @@ elab "Level" level:num : command => do
/-- Define the title of the current game/world/level. -/ /-- Define the title of the current game/world/level. -/
elab "Title" t:str : command => do elab "Title" t:str : command => do
let title ← t.getString.translate
match ← getCurLayer with match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with title := t.getString} | .Level => modifyCurLevel fun level => pure {level with title := title}
| .World => modifyCurWorld fun world => pure {world with title := t.getString} | .World => modifyCurWorld fun world => pure {world with title := title}
| .Game => modifyCurGame fun game => pure {game with | .Game => modifyCurGame fun game => pure {game with
title := t.getString title := title
tile := {game.tile with title := t.getString}} tile := {game.tile with title := title}}
/-- Define the introduction of the current game/world/level. -/ /-- Define the introduction of the current game/world/level. -/
elab "Introduction" t:str : command => do elab "Introduction" t:str : command => do
let intro := t.getString let intro ← t.getString.translate
match ← getCurLayer with match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with introduction := intro} | .Level => modifyCurLevel fun level => pure {level with introduction := intro}
| .World => modifyCurWorld fun world => pure {world with introduction := intro} | .World => modifyCurWorld fun world => pure {world with introduction := intro}
@@ -49,7 +56,7 @@ elab "Introduction" t:str : command => do
/-- Define the info of the current game. Used for e.g. credits -/ /-- Define the info of the current game. Used for e.g. credits -/
elab "Info" t:str : command => do elab "Info" t:str : command => do
let info:= t.getString let info ← t.getString.translate
match ← getCurLayer with match ← getCurLayer with
| .Level => | .Level =>
logError "Can't use `Info` in a level!" logError "Can't use `Info` in a level!"
@@ -81,7 +88,7 @@ elab "Image" t:str : command => do
/-- Define the conclusion of the current game or current level if some /-- Define the conclusion of the current game or current level if some
building a level. -/ building a level. -/
elab "Conclusion" t:str : command => do elab "Conclusion" t:str : command => do
let conclusion := t.getString let conclusion ← t.getString.translate
match ← getCurLayer with match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with conclusion := conclusion} | .Level => modifyCurLevel fun level => pure {level with conclusion := conclusion}
| .World => modifyCurWorld fun world => pure {world with conclusion := conclusion} | .World => modifyCurWorld fun world => pure {world with conclusion := conclusion}
@@ -94,18 +101,19 @@ elab "Prerequisites" t:str* : command => do
/-- Short caption for the game (1 sentence) -/ /-- Short caption for the game (1 sentence) -/
elab "CaptionShort" t:str : command => do elab "CaptionShort" t:str : command => do
let caption := t.getString let caption ← t.getString.translate
modifyCurGame fun game => pure {game with modifyCurGame fun game => pure {game with
tile := {game.tile with short := caption}} tile := {game.tile with short := caption}}
/-- More detailed description what the game is about (2-4 sentences). -/ /-- More detailed description what the game is about (2-4 sentences). -/
elab "CaptionLong" t:str : command => do elab "CaptionLong" t:str : command => do
let caption := t.getString let caption ← t.getString.translate
modifyCurGame fun game => pure {game with modifyCurGame fun game => pure {game with
tile := {game.tile with long := caption}} tile := {game.tile with long := caption}}
/-- A list of Languages the game is translated to. For example `Languages "German" "English"`. /-- A list of Languages the game is translated to. For example `Languages "de" "en"`.
NOTE: For the time being, only a single language is supported.
The keys are ISO language codes.
-/ -/
elab "Languages" t:str* : command => do elab "Languages" t:str* : command => do
modifyCurGame fun game => pure {game with modifyCurGame fun game => pure {game with
@@ -141,6 +149,7 @@ TacticDoc rw "`rw` stands for rewrite, etc. "
-/ -/
elab doc:docComment ? "TacticDoc" name:ident content:str ? : command => do elab doc:docComment ? "TacticDoc" name:ident content:str ? : command => do
let doc ← parseDocCommentLegacy doc content let doc ← parseDocCommentLegacy doc content
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Tactic type := .Tactic
name := name.getId name := name.getId
@@ -165,8 +174,9 @@ The theorem/definition to have the same fully qualified name as in mathlib.
elab doc:docComment ? "TheoremDoc" name:ident "as" displayName:str "in" category:str content:str ? : elab doc:docComment ? "TheoremDoc" name:ident "as" displayName:str "in" category:str content:str ? :
command => do command => do
let doc ← parseDocCommentLegacy doc content let doc ← parseDocCommentLegacy doc content
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Lemma type := .Theorem
name := name.getId name := name.getId
category := category.getString category := category.getString
displayName := displayName.getString displayName := displayName.getString
@@ -194,6 +204,7 @@ The theorem/definition to have the same fully qualified name as in mathlib.
-/ -/
elab doc:docComment ? "DefinitionDoc" name:ident "as" displayName:str template:str ? : command => do elab doc:docComment ? "DefinitionDoc" name:ident "as" displayName:str template:str ? : command => do
let doc ← parseDocCommentLegacy doc template let doc ← parseDocCommentLegacy doc template
let doc ← doc.translate
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Definition type := .Definition
name := name.getId, name := name.getId,
@@ -225,13 +236,13 @@ elab "NewHiddenTactic" args:ident* : command => do
/-- Declare theorems that are introduced by this level. -/ /-- Declare theorems that are introduced by this level. -/
elab "NewTheorem" args:ident* : command => do elab "NewTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.new) "NewTheorem" checkCommandNotDuplicated ((←getCurLevel).theorems.new) "NewTheorem"
for name in ↑args do for name in ↑args do
try let _decl ← getConstInfo name.getId catch try let _decl ← getConstInfo name.getId catch
| _ => logErrorAt name m!"unknown identifier '{name}'." | _ => logErrorAt name m!"unknown identifier '{name}'."
checkInventoryDoc .Lemma name -- TODO: Add (template := "[mathlib]") checkInventoryDoc .Theorem name -- TODO: Add (template := "[mathlib]")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with new := args.map (·.getId)}} theorems := {level.theorems with new := args.map (·.getId)}}
/-- Declare definitions that are introduced by this level. -/ /-- Declare definitions that are introduced by this level. -/
elab "NewDefinition" args:ident* : command => do elab "NewDefinition" args:ident* : command => do
@@ -251,10 +262,10 @@ elab "DisabledTactic" args:ident* : command => do
/-- Declare theorems that are temporarily disabled in this level. /-- Declare theorems that are temporarily disabled in this level.
This is ignored if `OnlyTheorem` is set. -/ This is ignored if `OnlyTheorem` is set. -/
elab "DisabledTheorem" args:ident* : command => do elab "DisabledTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.disabled) "DisabledTheorem" checkCommandNotDuplicated ((←getCurLevel).theorems.disabled) "DisabledTheorem"
for name in ↑args do checkInventoryDoc .Lemma name for name in ↑args do checkInventoryDoc .Theorem name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with disabled := args.map (·.getId)}} theorems := {level.theorems with disabled := args.map (·.getId)}}
/-- Declare definitions that are temporarily disabled in this level -/ /-- Declare definitions that are temporarily disabled in this level -/
elab "DisabledDefinition" args:ident* : command => do elab "DisabledDefinition" args:ident* : command => do
@@ -272,10 +283,10 @@ elab "OnlyTactic" args:ident* : command => do
/-- Temporarily disable all theorems except the ones declared here -/ /-- Temporarily disable all theorems except the ones declared here -/
elab "OnlyTheorem" args:ident* : command => do elab "OnlyTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.only) "OnlyTheorem" checkCommandNotDuplicated ((←getCurLevel).theorems.only) "OnlyTheorem"
for name in ↑args do checkInventoryDoc .Lemma name for name in ↑args do checkInventoryDoc .Theorem name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with only := args.map (·.getId)}} theorems := {level.theorems with only := args.map (·.getId)}}
/-- Temporarily disable all definitions except the ones declared here. /-- Temporarily disable all definitions except the ones declared here.
This is ignored if `OnlyDefinition` is set. -/ This is ignored if `OnlyDefinition` is set. -/
@@ -285,61 +296,47 @@ elab "OnlyDefinition" args:ident* : command => do
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
definitions := {level.definitions with only := args.map (·.getId)}} definitions := {level.definitions with only := args.map (·.getId)}}
/-- Define which tab of Lemmas is opened by default. Usage: `TheoremTab "Nat"`. /-- Define which tab of Theorems is opened by default. Usage: `TheoremTab "Nat"`.
If omitted, the current tab will remain open. -/ If omitted, the current tab will remain open. -/
elab "TheoremTab" category:str : command => elab "TheoremTab" category:str : command =>
modifyCurLevel fun level => pure {level with lemmaTab := category.getString} modifyCurLevel fun level => pure {level with theoremTab := 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 -/ /-! # Exercise Statement -/
/-- You can write `Statement add_comm (preamble := simp) .... := by` which
will automatically execute the given tactic sequence before the exercise
is handed to the player.
A common example is to use
```
refine { carrier := M, ?.. }
```
in exercises, where the statement is a structure, to fill in all the data fields.
For example in "Show that all matrices with first column zero form a submodule",
you could provide the set of all these matrices as `carrier` and the player will receive
all the `Prop`-valued fields as goals.
-/
syntax preambleArg := atomic(" (preamble := " withoutPosition(tacticSeq) ")")
/-- Define the statement of the current level. -/ /-- Define the statement of the current level. -/
elab doc:docComment ? attrs:Parser.Term.attributes ? elab doc:docComment ? attrs:Parser.Term.attributes ?
"Statement" statementName:ident ? sig:declSig val:declVal : command => do "Statement" statementName:ident ? preamble:preambleArg ? sig:declSig val:declVal : command => do
let lvlIdx ← getCurLevelIdx let lvlIdx ← getCurLevelIdx
let docContent ← parseDocComment doc -- Add an optional tactic sequence that the engine executes before the game starts
let preambleSeq : TSyntax `Lean.Parser.Tactic.tacticSeq ← match preamble with
| some arg => match arg with
| `(preambleArg| (preamble := $tac)) => pure tac
| _ => `(Parser.Tactic.tacticSeq| skip)
| none => `(Parser.Tactic.tacticSeq| skip)
-- Translate the docstring of the `Statement`
let docComment : Option String ← match (← parseDocComment doc) with
| none => pure none
| some d => d.translate
-- Save the messages before evaluation of the proof. -- Save the messages before evaluation of the proof.
let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} }) let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} })
@@ -349,42 +346,45 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
let defaultDeclName : Ident := mkIdent <| (← getCurGame).name ++ (← getCurWorld).name ++ let defaultDeclName : Ident := mkIdent <| (← getCurGame).name ++ (← getCurWorld).name ++
("level" ++ toString lvlIdx : String) ("level" ++ toString lvlIdx : String)
-- Collect all used tactics/lemmas in the sample proof: -- Collect all used tactics/theorems in the sample proof:
let usedInventory ← match val with let usedInventory : UsedInventory ← match val with
| `(Parser.Command.declVal| := $proof:term) => do | `(Parser.Command.declVal| := $proof:term) => do
collectUsedInventory proof collectUsedInventory proof
| _ => throwError "expected `:=`" | _ => throwError "expected `:=`"
-- modify the proof to start with `let_intros` and any specified preamble sequence.
let modifiedVal ← match val with
| `(Parser.Command.declVal| := by $proof) =>
`(Parser.Command.declVal| := by {let_intros; $(⟨preambleSeq⟩); $(⟨proof⟩)})
| _ => panic "Expected `:= by`!"
-- Add theorem to context. -- Add theorem to context.
match statementName with match statementName with
| some name => | some name =>
let env ← getEnv let env ← getEnv
let fullName := (← getCurrNamespace) ++ name.getId let fullName := (← getCurrNamespace) ++ name.getId
if env.contains fullName then if env.contains fullName then
let origType := (env.constants.map₁.find! fullName).type let some orig := env.constants.map₁.find? fullName
| throwError s!"error in \"Statement\": `{fullName}` not found."
let origType := orig.type
-- TODO: Check if `origType` agrees with `sig` and output `logInfo` instead of `logWarning` -- TODO: Check if `origType` agrees with `sig` and output `logInfo` instead of `logWarning`
-- in that case. -- in that case.
logWarningAt name (m!"Environment already contains {fullName}! Only the existing " ++ logWarningAt name (m!"Environment already contains {fullName}! Only the existing " ++
m!"statement will be available in later levels:\n\n{origType}") m!"statement will be available in later levels:\n\n{origType}")
let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $defaultDeclName $sig $val) let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $defaultDeclName $sig $modifiedVal)
elabCommand thmStatement elabCommand thmStatement
-- Check that statement has a docs entry. -- Check that statement has a docs entry.
checkInventoryDoc .Lemma name (name := fullName) (template := docContent) checkInventoryDoc .Theorem name (name := fullName) (template := docComment)
else else
let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $name $sig $val) let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $name $sig $modifiedVal)
elabCommand thmStatement elabCommand thmStatement
-- Check that statement has a docs entry. -- Check that statement has a docs entry.
checkInventoryDoc .Lemma name (name := fullName) (template := docContent) checkInventoryDoc .Theorem name (name := fullName) (template := docComment)
| none => | none =>
let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $defaultDeclName $sig $val) let thmStatement ← `(command| $[$doc]? $[$attrs:attributes]? theorem $defaultDeclName $sig $modifiedVal)
elabCommand thmStatement elabCommand thmStatement
let msgs := (← get).messages let msgs := (← get).messages
let mut hints := #[] let mut hints := #[]
let mut nonHintMsgs := #[] let mut nonHintMsgs := #[]
for msg in msgs.msgs do for msg in msgs.msgs do
@@ -394,14 +394,45 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
.tagged `Hint $ .tagged `Hint $
.nest strict $ .nest strict $
.nest hidden $ .nest hidden $
.nest defeq $
.compose (.ofGoal text) (.ofGoal goal) := msg.data then .compose (.ofGoal text) (.ofGoal goal) := msg.data then
let hint ← liftTermElabM $ withMCtx ctx.mctx $ withLCtx ctx.lctx #[] $ withEnv ctx.env do 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 { return {
goal := ← abstractCtx goal goal := ← abstractCtx goal
text := ← instantiateMVars (mkMVar text) text := text
rawText := rawText
strict := strict == 1 strict := strict == 1
hidden := hidden == 1 hidden := hidden == 1
defeq := defeq == 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 hints := hints.push hint
else else
nonHintMsgs := nonHintMsgs.push msg nonHintMsgs := nonHintMsgs.push msg
@@ -427,8 +458,9 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
modifyCurLevel fun level => pure { level with modifyCurLevel fun level => pure { level with
module := env.header.mainModule module := env.header.mainModule
goal := sig, goal := sig,
preamble := preambleSeq
scope := scope, scope := scope,
descrText := docContent descrText := docComment
statementName := match statementName with statementName := match statementName with
| none => default | none => default
| some name => currNamespace ++ name.getId | some name => currNamespace ++ name.getId
@@ -436,16 +468,20 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
hints := hints hints := hints
tactics := {level.tactics with used := usedInventory.tactics.toArray} tactics := {level.tactics with used := usedInventory.tactics.toArray}
definitions := {level.definitions with used := usedInventory.definitions.toArray} definitions := {level.definitions with used := usedInventory.definitions.toArray}
lemmas := {level.lemmas with used := usedInventory.lemmas.toArray} } theorems := {level.theorems with used := usedInventory.theorems.toArray}
}
/-! # Hints -/ /-! # Hints -/
open GameServer in
/-- A tactic that can be used inside `Statement`s to indicate in which proof states players should /-- 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. see hints. The tactic does not affect the goal state.
-/ -/
elab (name := GameServer.Tactic.Hint) "Hint" args:hintArg* msg:interpolatedStr(term) : tactic => do elab (name := GameServer.Tactic.Hint) "Hint" args:hintArg* msg:interpolatedStr(term) : tactic => do
let mut strict := false let mut strict := false
let mut hidden := false let mut hidden := false
let mut defeq := true
-- remove spaces at the beginning of new lines -- remove spaces at the beginning of new lines
let msg := TSyntax.mk $ msg.raw.setArgs $ ← msg.raw.getArgs.mapM fun m => do let msg := TSyntax.mk $ msg.raw.setArgs $ ← msg.raw.getArgs.mapM fun m => do
@@ -467,6 +503,8 @@ elab (name := GameServer.Tactic.Hint) "Hint" args:hintArg* msg:interpolatedStr(t
| `(hintArg| (strict := false)) => strict := false | `(hintArg| (strict := false)) => strict := false
| `(hintArg| (hidden := true)) => hidden := true | `(hintArg| (hidden := true)) => hidden := true
| `(hintArg| (hidden := false)) => hidden := false | `(hintArg| (hidden := false)) => hidden := false
| `(hintArg| (defeq := true)) => defeq := true
| `(hintArg| (defeq := false)) => defeq := false
| _ => throwUnsupportedSyntax | _ => throwUnsupportedSyntax
let goal ← Tactic.getMainGoal let goal ← Tactic.getMainGoal
@@ -490,6 +528,7 @@ elab (name := GameServer.Tactic.Hint) "Hint" args:hintArg* msg:interpolatedStr(t
.tagged `Hint $ .tagged `Hint $
.nest (if strict then 1 else 0) $ .nest (if strict then 1 else 0) $
.nest (if hidden then 1 else 0) $ .nest (if hidden then 1 else 0) $
.nest (if defeq then 1 else 0) $
.compose (.ofGoal textmvar.mvarId!) (.ofGoal goal) .compose (.ofGoal textmvar.mvarId!) (.ofGoal goal)
/-- This tactic allows us to execute an alternative sequence of tactics, but without affecting the /-- This tactic allows us to execute an alternative sequence of tactics, but without affecting the
@@ -501,7 +540,8 @@ elab (name := GameServer.Tactic.Branch) "Branch" t:tacticSeq : tactic => do
-- Show an info whether the branch proofs all remaining goals. -- Show an info whether the branch proofs all remaining goals.
let gs ← Tactic.getUnsolvedGoals let gs ← Tactic.getUnsolvedGoals
if gs.isEmpty then if gs.isEmpty then
trace[debug] "This branch finishes the proof." -- trace[debug] "This branch finishes the proof."
pure ()
else else
trace[debug] "This branch leaves open goals." trace[debug] "This branch leaves open goals."
@@ -561,7 +601,7 @@ elab "Template" tacs:tacticSeq : tactic => do
-- open Lean Meta Elab Command Tactic Simp -- open Lean Meta Elab Command Tactic Simp
-- def Lean.Meta.SimpTheorems.hasAttribute (d : SimpTheorems) (decl : Name) := -- def Lean.Meta.SimpTheorems.hasAttribute (d : SimpTheorems) (decl : Name) :=
-- d.isLemma (.decl decl) || d.isDeclToUnfold decl -- d.isTheorem (.decl decl) || d.isDeclToUnfold decl
-- def isInSimpset (simpAttr decl: Name) : CoreM Bool := do -- def isInSimpset (simpAttr decl: Name) : CoreM Bool := do
-- let .some simpDecl ←getSimpExtension? simpAttr | return false -- let .some simpDecl ←getSimpExtension? simpAttr | return false
@@ -579,8 +619,8 @@ def Parser.dependency := Parser.sepBy1Indent Parser.ident "→"
/-- Manually add a dependency between two worlds. /-- Manually add a dependency between two worlds.
Normally, the dependencies are computed automatically by the Normally, the dependencies are computed automatically by the
tactics & lemmas used in the example tactics & theorems used in the example
proof and the ones introduced by `NewLemma`/`NewTactic`. proof and the ones introduced by `NewTheorem`/`NewTactic`.
Use the command `Dependency World₁ → World₂` to add a manual edge to the graph, Use the command `Dependency World₁ → World₂` to add a manual edge to the graph,
for example if the only dependency between the worlds is given by for example if the only dependency between the worlds is given by
the narrative. -/ the narrative. -/
@@ -625,10 +665,10 @@ elab "MakeGame" : command => do
s!"[mathlib doc](https://leanprover-community.github.io/mathlib4_docs/find/?pattern={name}#doc)" s!"[mathlib doc](https://leanprover-community.github.io/mathlib4_docs/find/?pattern={name}#doc)"
match item.type with match item.type with
| .Lemma => | .Theorem =>
modifyEnv (inventoryExt.addEntry · { item with modifyEnv (inventoryExt.addEntry · { item with
content := content content := content
-- Add the lemma statement to the doc -- Add the theorem statement to the doc
statement := (← getStatementString name) statement := (← getStatementString name)
}) })
| _ => | _ =>
@@ -653,30 +693,33 @@ elab "MakeGame" : command => do
for (worldId, world) in allWorlds do for (worldId, world) in allWorlds do
let mut usedItems : HashSet Name := {} let mut usedItems : HashSet Name := {}
let mut newItems : HashSet Name := {} let mut newItems : HashSet Name := {}
for inventoryType in #[.Tactic, .Definition, .Lemma] do for inventoryType in #[.Tactic, .Definition, .Theorem] do
for (levelId, level) in world.levels.toArray do for (levelId, level) in world.levels.toArray do
usedItems := usedItems.insertMany (level.getInventory inventoryType).used usedItems := usedItems.insertMany (level.getInventory inventoryType).used
newItems := newItems.insertMany (level.getInventory inventoryType).new newItems := newItems.insertMany (level.getInventory inventoryType).new
hiddenItems := hiddenItems.insertMany (level.getInventory inventoryType).hidden hiddenItems := hiddenItems.insertMany (level.getInventory inventoryType).hidden
-- if the previous level was named, we need to add it as a new lemma -- if the previous level was named, we need to add it as a new theorem
if inventoryType == .Lemma then if inventoryType == .Theorem then
match levelId with match levelId with
| 0 => pure () | 0 => pure ()
| 1 => pure () -- level ids start with 1, so we need to skip 1, too | 1 => pure () -- level ids start with 1, so we need to skip 1, too
| i₀ + 1 => | i₀ + 1 =>
match (world.levels.find! (i₀)).statementName with let some idx := world.levels.find? (i₀) | throwError s!"Level {i₀ + 1} not found for world {worldId}!"
match (idx).statementName with
| .anonymous => pure () | .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!" | .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s => | .str pre s =>
let name := Name.str pre s let name := Name.str pre s
newItems := newItems.insert name newItems := newItems.insert name
if inventoryType == .Lemma then if inventoryType == .Theorem then
-- if the last level was named, we need to add it as a new lemma -- if the last level was named, we need to add it as a new theorem
let i₀ := world.levels.size let i₀ := world.levels.size
match (world.levels.find! (i₀)).statementName with
let some idx := world.levels.find? (i₀) | throwError s!"Level {i₀} not found for world {worldId}!"
match (idx).statementName with
| .anonymous => pure () | .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!" | .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s => | .str pre s =>
@@ -777,42 +820,44 @@ elab "MakeGame" : command => do
let mut allItemsByType : HashMap InventoryType (HashSet Name) := {} let mut allItemsByType : HashMap InventoryType (HashSet Name) := {}
-- Compute which inventory items are available in which level: -- Compute which inventory items are available in which level:
for inventoryType in #[.Tactic, .Definition, .Lemma] do for inventoryType in #[.Tactic, .Definition, .Theorem] do
-- Which items are introduced in which world? -- Which items are introduced in which world?
let mut lemmaStatements : HashMap (Name × Nat) Name := {} let mut theoremStatements : HashMap (Name × Nat) Name := {}
-- TODO: I believe `newItemsInWorld` has way to many elements in it which we iterate over -- TODO: I believe `newItemsInWorld` has way to many elements in it which we iterate over
-- e.g. we iterate over `ring` for `Lemma`s as well, but so far that seems to cause no problems -- e.g. we iterate over `ring` for `Theorem`s as well, but so far that seems to cause no problems
let mut allItems : HashSet Name := {} let mut allItems : HashSet Name := {}
for (worldId, world) in game.worlds.nodes.toArray do for (worldId, world) in game.worlds.nodes.toArray do
let mut newItems : HashSet Name := {} let mut newItems : HashSet Name := {}
for (levelId, level) in world.levels.toArray do for (levelId, level) in world.levels.toArray do
let newLemmas := (level.getInventory inventoryType).new let newTheorems := (level.getInventory inventoryType).new
newItems := newItems.insertMany newLemmas newItems := newItems.insertMany newTheorems
allItems := allItems.insertMany newLemmas allItems := allItems.insertMany newTheorems
if inventoryType == .Lemma then if inventoryType == .Theorem then
-- For levels `2, 3, …` we check if the previous level was named -- For levels `2, 3, …` we check if the previous level was named
-- in which case we add it as available lemma. -- in which case we add it as available theorem.
match levelId with match levelId with
| 0 => pure () | 0 => pure ()
| 1 => pure () -- level ids start with 1, so we need to skip 1, too. | 1 => pure () -- level ids start with 1, so we need to skip 1, too.
| i₀ + 1 => | i₀ + 1 =>
-- add named statement from previous level to the available lemmas. -- add named statement from previous level to the available theorems.
match (world.levels.find! (i₀)).statementName with let some idx := world.levels.find? (i₀) | throwError s!"Level {i₀ + 1} not found for world {worldId}!"
match (idx).statementName with
| .anonymous => pure () | .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!" | .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s => | .str pre s =>
let name := Name.str pre s let name := Name.str pre s
newItems := newItems.insert name newItems := newItems.insert name
allItems := allItems.insert name allItems := allItems.insert name
lemmaStatements := lemmaStatements.insert (worldId, levelId) name theoremStatements := theoremStatements.insert (worldId, levelId) name
if inventoryType == .Lemma then if inventoryType == .Theorem then
-- if named, add the lemma from the last level of the world to the inventory -- if named, add the theorem from the last level of the world to the inventory
let i₀ := world.levels.size let i₀ := world.levels.size
match i₀ with match i₀ with
| 0 => logWarning m!"World `{worldId}` contains no levels." | 0 => logWarning m!"World `{worldId}` contains no levels."
| i₀ => | i₀ =>
match (world.levels.find! (i₀)).statementName with let some idx := world.levels.find? (i₀) | throwError s!"Level {i₀} not found for world {worldId}!"
match (idx).statementName with
| .anonymous => pure () | .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!" | .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s => | .str pre s =>
@@ -828,7 +873,7 @@ elab "MakeGame" : command => do
-- Using a match statement because the error message of `Option.get!` is not helpful. -- Using a match statement because the error message of `Option.get!` is not helpful.
match (← getInventoryItem? item inventoryType) with match (← getInventoryItem? item inventoryType) with
| none => | none =>
-- Note: we did have a panic here before because lemma statement and doc entry -- Note: we did have a panic here before because theorem statement and doc entry
-- had mismatching namespaces -- had mismatching namespaces
logError m!"There is no inventory item ({inventoryType}) for: {item}." logError m!"There is no inventory item ({inventoryType}) for: {item}."
panic s!"Inventory item {item} not found!" panic s!"Inventory item {item} not found!"
@@ -882,8 +927,8 @@ elab "MakeGame" : command => do
-- add the exercise statement from the previous level -- add the exercise statement from the previous level
-- if it was named -- if it was named
if inventoryType == .Lemma then if inventoryType == .Theorem then
match lemmaStatements.find? (worldId, levelId) with match theoremStatements.find? (worldId, levelId) with
| none => pure () | none => pure ()
| some name => | some name =>
let data := (← getInventoryItem? name inventoryType).get! let data := (← getInventoryItem? name inventoryType).get!
@@ -891,11 +936,17 @@ elab "MakeGame" : command => do
name := name name := name
displayName := data.displayName displayName := data.displayName
category := data.category category := data.category
world := worldId
-- from the previous level. This is fine b/c in practise levels start at 1
level := (levelId - 1 : Nat)
proven := true
altTitle := data.statement altTitle := data.statement
locked := false } locked := false }
-- add marks for `disabled` and `new` lemmas here, so that they only apply to -- add marks for `disabled` and `new` theorems here, so that they only apply to
-- the current level. -- the current level.
let itemsArray := items.toArray let itemsArray := items.toArray
|>.insertionSort (fun a b => a.1.toString < b.1.toString) |>.insertionSort (fun a b => a.1.toString < b.1.toString)
|>.map (·.2) |>.map (·.2)
@@ -917,10 +968,10 @@ elab "MakeGame" : command => do
| throwError "Expected item to exist: {name}" | throwError "Expected item to exist: {name}"
return item.toTile) return item.toTile)
let inventory : InventoryOverview := { let inventory : InventoryOverview := {
lemmas := (← getTiles .Lemma).map (fun tile => {tile with hidden := hiddenItems.contains tile.name}) theorems := (← getTiles .Theorem).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
tactics := (← getTiles .Tactic).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}) definitions := (← getTiles .Definition).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
lemmaTab := none theoremTab := none
} }
saveGameData allItemsByType inventory saveGameData allItemsByType inventory
+56 -45
View File
@@ -1,7 +1,18 @@
import GameServer.AbstractCtx import GameServer.AbstractCtx
import GameServer.Graph import GameServer.Graph
import GameServer.Hints
open GameServer
-- TODO: Is there a better place?
/-- Keywords that the server should not consider as tactics.
Note: Added `clear` tactic because currently it is very useful in combination with
`Branch` and `Hint` (i.e. using `clear` before a `Hint` in order to remove any irrelevant
hypotheses).
-/
def GameServer.ALLOWED_KEYWORDS : List String :=
["with", "fun", "at", "only", "by", "generalizing", "if", "then", "else", "clear", "using"]
/-- The default game name if `Game "MyGame"` is not used. -/ /-- The default game name if `Game "MyGame"` is not used. -/
def defaultGameName: String := "MyGame" def defaultGameName: String := "MyGame"
@@ -18,59 +29,43 @@ defined in this file.
open Lean 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) /-! ## Inventory (documentation)
The inventory contains documentation that the user can access. The inventory contains documentation that the user can access.
There are three inventory types: Lemma, Tactic, Definition. They vary about in the information There are three inventory types: Theorem, Tactic, Definition. They vary about in the information
they carry. they carry.
The commands `LemmaDoc`, `TacticDoc`, and `DefinitionDoc` add keys and templates to an The commands `TheoremDoc`, `TacticDoc`, and `DefinitionDoc` add keys and templates to an
env. extension called `InventoryTemplateExt`. Commands like `NewLemma`, etc. as well as env. extension called `InventoryTemplateExt`. Commands like `NewTheorem`, etc. as well as
`Statement` check if there is a key registered in this extension and might add a default or `Statement` check if there is a key registered in this extension and might add a default or
print a warning if not. print a warning if not.
Then, `MakeGame` takes the templates from `InventoryTemplateExt` and creates the documentation entries Then, `MakeGame` takes the templates from `InventoryTemplateExt` and creates the documentation entries
that are sent to the client. This allows us to modify them like adding information from that are sent to the client. This allows us to modify them like adding information from
mathlib or from parsing the lemma in question. mathlib or from parsing the theorem in question.
-/ -/
/-- The game knows three different inventory types that contain slightly different information -/ /-- The game knows three different inventory types that contain slightly different information -/
inductive InventoryType := | Tactic | Lemma | Definition inductive InventoryType := | Tactic | Theorem | Definition
deriving ToJson, FromJson, Repr, BEq, Hashable, Inhabited deriving ToJson, FromJson, Repr, BEq, Hashable, Inhabited
-- TODO: golf this? -- TODO: golf this?
instance : ToString InventoryType := ⟨fun t => match t with instance : ToString InventoryType := ⟨fun t => match t with
| .Tactic => "Tactic" | .Tactic => "Tactic"
| .Lemma => "Lemma" | .Theorem => "Theorem"
| .Definition => "Definition"⟩ | .Definition => "Definition"⟩
/-- The keys/templates of the inventory items, stored in `InventoryTemplateExt`. -/ /-- The keys/templates of the inventory items, stored in `InventoryTemplateExt`. -/
structure InventoryTemplate where structure InventoryTemplate where
/-- Lemma, Tactic, or Definition -/ /-- Theorem, Tactic, or Definition -/
type: InventoryType type: InventoryType
/-- Depends on the type: /-- Depends on the type:
* Tactic: the tactic's name * Tactic: the tactic's name
* Lemma: fully qualified lemma name * Theorem: fully qualified theorem name
* Definition: no restrictions (preferably the definitions fully qualified name) * Definition: no restrictions (preferably the definitions fully qualified name)
-/ -/
name: Name name: Name
/-- Only for Lemmas. To sort them into tabs -/ /-- Only for Theorems. To sort them into tabs -/
category: String := default category: String := default
/-- Free-text short name -/ /-- Free-text short name -/
displayName: String := name.toString displayName: String := name.toString
@@ -90,14 +85,20 @@ structure InventoryTile where
The name of the item. The restrictions are: The name of the item. The restrictions are:
* for Tactics: The name of the tactic. * for Tactics: The name of the tactic.
* for Lemmas: *Fully qualified* lemma name. * for Theorems: *Fully qualified* theorem name.
* for Definitions: no restrictions. * for Definitions: no restrictions.
-/ -/
name : Name name : Name
/-- The display name shown in the inventory. This can be free-text. -/ /-- The display name shown in the inventory. This can be free-text. -/
displayName : String displayName : String
/-- Category to group inventory items by (currently only used for lemmas). -/ /-- Category to group inventory items by (currently only used for theorems). -/
category : String category : String
/-- The world which introduced this item. -/
world : Option Name := none
/-- The level which introduced this item. -/
level : Option Nat := none
/-- Set to `true` if there exists an exercise in the game proving this statement. -/
proven := false
/-- If `true` then the item only gets unlocked in a later level. -/ /-- If `true` then the item only gets unlocked in a later level. -/
locked := true locked := true
/-- If `true` then the item is blocked for this level. -/ /-- If `true` then the item is blocked for this level. -/
@@ -108,7 +109,7 @@ structure InventoryTile where
hidden := false hidden := false
/-- hover text -/ /-- hover text -/
altTitle : String := default altTitle : String := default
deriving ToJson, FromJson, Repr, Inhabited deriving ToJson, FromJson, Repr, Inhabited, BEq
def InventoryItem.toTile (item : InventoryItem) : InventoryTile := { def InventoryItem.toTile (item : InventoryItem) : InventoryTile := {
name := item.name, name := item.name,
@@ -145,16 +146,16 @@ def getInventoryItem? [Monad m] [MonadEnv m] (n : Name) (type : InventoryType) :
structure InventoryOverview where structure InventoryOverview where
tactics : Array InventoryTile tactics : Array InventoryTile
lemmas : Array InventoryTile theorems : Array InventoryTile
definitions : Array InventoryTile definitions : Array InventoryTile
lemmaTab : Option String theoremTab : Option String
deriving ToJson, FromJson deriving ToJson, FromJson
-- TODO: Reuse the following code for checking available tactics in user code: -- TODO: Reuse the following code for checking available tactics in user code:
structure UsedInventory where structure UsedInventory where
(tactics : HashSet Name := {}) (tactics : HashSet Name := {})
(definitions : HashSet Name := {}) (definitions : HashSet Name := {})
(lemmas : HashSet Name := {}) (theorems : HashSet Name := {})
/-! ## Environment extensions for game specification -/ /-! ## Environment extensions for game specification -/
@@ -254,7 +255,7 @@ structure GameLevel where
/-- Introduction text shown all the time. (markdown) -/ /-- Introduction text shown all the time. (markdown) -/
introduction: String := default introduction: String := default
conclusion: String := default conclusion: String := default
/-- The name of the exercise proven. If provided this lemma will be available in /-- The name of the exercise proven. If provided this theorem will be available in
future levels. -/ future levels. -/
statementName: Name := default statementName: Name := default
hints: Array GoalHintEntry := default hints: Array GoalHintEntry := default
@@ -264,35 +265,37 @@ structure GameLevel where
/-- The mathematical statement in mathematician-readable form. (markdown) -/ /-- The mathematical statement in mathematician-readable form. (markdown) -/
descrText: Option String := none descrText: Option String := none
descrFormat : String := default descrFormat : String := default
/-- The `category` of lemmas to be open by default -/ /-- The `category` of theorems to be open by default -/
lemmaTab: Option String := none theoremTab: Option String := none
/-- The module to be imported when playing this level -/ /-- The module to be imported when playing this level -/
module : Name := default module : Name := default
tactics: InventoryInfo := default tactics: InventoryInfo := default
definitions: InventoryInfo := default definitions: InventoryInfo := default
lemmas: InventoryInfo := default theorems: InventoryInfo := default
/-- A proof template that is printed in an empty editor. -/ /-- A proof template that is printed in an empty editor. -/
template: Option String := none template: Option String := none
/-- The image for this level. -/ /-- The image for this level. -/
image : String := default image : String := default
/-- A sequence of tactics the game automatically executes before the first step. -/
preamble : TSyntax `Lean.Parser.Tactic.tacticSeq := default
deriving Inhabited, Repr deriving Inhabited, Repr
/-- Json-encodable version of `GameLevel` /-- Json-encodable version of `GameLevel`
Fields: Fields:
- description: Lemma in mathematical language. - description: Theorem in mathematical language.
- descriptionGoal: Lemma printed as Lean-Code. - descriptionGoal: Theorem printed as Lean-Code.
-/ -/
structure LevelInfo where structure LevelInfo where
index : Nat index : Nat
title : String title : String
tactics : Array InventoryTile tactics : Array InventoryTile
lemmas : Array InventoryTile theorems : Array InventoryTile
definitions : Array InventoryTile definitions : Array InventoryTile
introduction : String introduction : String
conclusion : String conclusion : String
descrText : Option String := none descrText : Option String := none
descrFormat : String := "" descrFormat : String := ""
lemmaTab : Option String theoremTab : Option String
module : Name module : Name
displayName : Option String displayName : Option String
statementName : Option String statementName : Option String
@@ -304,17 +307,17 @@ def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo :=
{ index := lvl.index, { index := lvl.index,
title := lvl.title, title := lvl.title,
tactics := lvl.tactics.tiles, tactics := lvl.tactics.tiles,
lemmas := lvl.lemmas.tiles, theorems := lvl.theorems.tiles,
definitions := lvl.definitions.tiles, definitions := lvl.definitions.tiles,
descrText := lvl.descrText, descrText := lvl.descrText,
descrFormat := lvl.descrFormat --toExpr <| format (lvl.goal.raw) --toString <| Syntax.formatStx (lvl.goal.raw) --Syntax.formatStx (lvl.goal.raw) , -- TODO descrFormat := lvl.descrFormat --toExpr <| format (lvl.goal.raw) --toString <| Syntax.formatStx (lvl.goal.raw) --Syntax.formatStx (lvl.goal.raw) , -- TODO
introduction := lvl.introduction introduction := lvl.introduction
conclusion := lvl.conclusion conclusion := lvl.conclusion
lemmaTab := match lvl.lemmaTab with theoremTab := match lvl.theoremTab with
| some tab => tab | some tab => tab
| none => | none =>
-- Try to set the lemma tab to the category of the first added lemma -- Try to set the theorem tab to the category of the first added theorem
match lvl.lemmas.tiles.find? (·.new) with match lvl.theorems.tiles.find? (·.new) with
| some tile => tile.category | some tile => tile.category
| none => none | none => none
statementName := lvl.statementName.toString statementName := lvl.statementName.toString
@@ -322,11 +325,11 @@ def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo :=
displayName := match lvl.statementName with displayName := match lvl.statementName with
| .anonymous => none | .anonymous => none
| name => match (inventoryExt.getState env).find? | name => match (inventoryExt.getState env).find?
(fun x => x.name == name && x.type == .Lemma) with (fun x => x.name == name && x.type == .Theorem) with
| some n => n.displayName | some n => n.displayName
| none => name.toString | none => name.toString
-- Note: we could call `.find!` because we check in `Statement` that the -- Note: we could call `.find!` because we check in `Statement` that the
-- lemma doc must exist. -- theorem doc must exist.
template := lvl.template template := lvl.template
image := lvl.image image := lvl.image
} }
@@ -529,3 +532,11 @@ def modifyLevel (levelId : LevelId) (fn : GameLevel → m GameLevel) [MonadError
let world' := {world with levels := world.levels.insert levelId.level level'} let world' := {world with levels := world.levels.insert levelId.level level'}
let game' := {game with worlds := game.worlds.insertNode levelId.world world'} let game' := {game with worlds := game.worlds.insertNode levelId.world world'}
insertGame levelId.game game' insertGame levelId.game game'
-- def getIntroducedInventory (game : Game) [MonadError m] : m (Array Name) := do
-- let allItems : Array Name := game.worlds.nodes.fold (fun L _ world => L ++
-- world.levels.fold (fun LL _ level =>
-- LL ++ level.tactics.new ++ level.theorems.new
-- ) #[]) #[]
-- pure allItems
+297 -132
View File
@@ -3,8 +3,11 @@ import Lean.Server.FileWorker
import GameServer.Game import GameServer.Game
import GameServer.ImportModules import GameServer.ImportModules
import GameServer.SaveData import GameServer.SaveData
import GameServer.EnvExtensions
import GameServer.Tactic.LetIntros
namespace MyModule namespace MyModule
open Lean open Lean
open Elab open Elab
open Parser open Parser
@@ -48,7 +51,8 @@ partial def parseTactic (inputCtx : InputContext) (pmctx : ParserModuleContext)
end MyModule end MyModule
namespace MyServer.FileWorker namespace GameServer.FileWorker
open Lean open Lean
open Lean.Server open Lean.Server
open Lean.Server.FileWorker open Lean.Server.FileWorker
@@ -57,103 +61,142 @@ open IO
open Snapshots open Snapshots
open JsonRpc open JsonRpc
structure GameWorkerState := /--
Game-specific state to be packed on top of the `Server.FileWorker.WorkerState`
used by the Lean server.
-/
structure WorkerState :=
/--
Collection of items which are considered unlocked.
Tactics and theorems are mixed together.
-/
inventory : Array String inventory : Array String
/-- /--
Check for tactics/theorems that are not unlocked. Difficulty determines whether tactics/theorems can be locked.
0: no check * 0: do not check
1: give warnings * 1: give warnings when locked items are used
2: give errors * 2: give errors when locked items are used
-/ -/
difficulty : Nat difficulty : Nat
/--
`levelInfo` contains all the (static) information about the level which is not influenced
by the user's progress.
-/
levelInfo : LevelInfo levelInfo : LevelInfo
deriving ToJson, FromJson deriving ToJson, FromJson
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM /--
Pack the our custom `WorkerState` on top of the normal worker monad
`Server.FileWorker.WorkerM`.
-/
abbrev WorkerM := StateT WorkerState Server.FileWorker.WorkerM
section Elab section Elab
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) : /-- Add a message. use `(severity := .warning)` to specify the severity-/
def addMessage (info : SourceInfo) (inputCtx : Parser.InputContext)
(severity := MessageSeverity.warning) (s : MessageData) :
Elab.Command.CommandElabM Unit := do Elab.Command.CommandElabM Unit := do
modify fun st => { st with modify fun st => { st with
messages := st.messages.add { messages := st.messages.add {
fileName := inputCtx.fileName fileName := inputCtx.fileName
severity := MessageSeverity.error severity := severity
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0) pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s data := s }}
}
}
-- TODO: use HashSet for allowed tactics? -- TODO: use HashSet for allowed tactics?
/-- Find all tactics in syntax object that are forbidden according to a /--
set `allowed` of allowed tactics. -/ Find all tactics in syntax object that are forbidden according to a
partial def findForbiddenTactics (inputCtx : Parser.InputContext) set `allowed` of allowed tactics.
(gameWorkerState : GameWorkerState) (stx : Syntax) : -/
Elab.Command.CommandElabM Unit := do partial def findForbiddenTactics (inputCtx : Parser.InputContext) (workerState : WorkerState)
let levelInfo := gameWorkerState.levelInfo (stx : Syntax) : Elab.Command.CommandElabM Unit := do
let levelInfo := workerState.levelInfo
-- Parse the syntax object and look for tactics and declarations.
match stx with match stx with
| .missing => return () | .missing => return ()
| .node _info _kind args => | .node _info _kind args =>
-- Go inside a node.
for arg in args do for arg in args do
findForbiddenTactics inputCtx gameWorkerState arg findForbiddenTactics inputCtx workerState arg
| .atom info val => | .atom info val =>
-- ignore syntax elements that do not start with a letter -- Atoms might be tactic names or other keywords.
-- and ignore "with" keyword -- Note: We whitelisted known keywords because we cannot
let allowed := ["with", "fun", "at", "only", "by", "to"] -- distinguish keywords from tactic names.
let allowed := GameServer.ALLOWED_KEYWORDS
-- Ignore syntax elements that do not start with a letter or are listed above.
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then 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` -- Treat `simp?` and `simp!` like `simp`
let val := val.dropRightWhile (fun c => c == '!' || c == '?')
match levelInfo.tactics.find? (·.name.toString == val) with match levelInfo.tactics.find? (·.name.toString == val) with
| none => | none =>
-- Note: This case means that the tactic will never be introduced in the game. -- Tactic will never be introduced in the game.
match gameWorkerState.inventory.find? (· == val) with match workerState.inventory.find? (· == val) with
| some _ =>
-- Tactic is in the inventory, allow it.
-- Note: This case shouldn't be possible...
pure ()
| none => | none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!" -- Tactic is not in the inventory.
| some _ => pure () -- tactic is in the inventory, allow it. addMessageByDifficulty info s!"The tactic '{val}' is not available in this game!"
| some tac => | some tac =>
if tac.locked then -- Tactic is introduced at some point in the game.
match gameWorkerState.inventory.find? (· == val) with if tac.disabled then
-- Tactic is disabled in this level.
addMessageByDifficulty info s!"The tactic '{val}' is disabled in this level!"
else if tac.locked then
match workerState.inventory.find? (· == val) with
| none => | none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!" -- Tactic is marked as locked and not in the inventory.
| some _ => pure () -- tactic is in the inventory, allow it. addMessageByDifficulty info s!"You have not unlocked the tactic '{val}' yet!"
else if tac.disabled then | some _ =>
addWarningMessage info s!"The tactic '{val}' is disabled in this level!" -- Tactic is in the inventory, allow it.
pure ()
| .ident info _rawVal val _preresolved => | .ident info _rawVal val _preresolved =>
-- Try to resolve the name
let ns ← let ns ←
try resolveGlobalConst (mkIdent val) try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error -- Catch "unknown constant" error
catch | _ => pure []
for n in ns do for n in ns do
let some (.thmInfo ..) := (← getEnv).find? n let some (.thmInfo ..) := (← getEnv).find? n
| return () -- not a theorem -> ignore -- Not a theorem, no checks needed.
-- Forbid the theorem we are proving currently | return ()
if some n = levelInfo.statementName then if some n = levelInfo.statementName then
addErrorMessage info inputCtx s!"Structural recursion: you can't use '{n}' to proof itself!" -- Forbid the theorem we are proving currently
addMessage info inputCtx (severity := .error)
s!"Structural recursion: you can't use '{n}' to proof itself!"
let theoremsAndDefs := levelInfo.theorems ++ levelInfo.definitions
match theoremsAndDefs.find? (·.name == n) with
| none =>
-- Theorem will never be introduced in this game
addMessageByDifficulty info s!"The theorem/definition '{n}' is not available in this game!"
| some thm =>
-- Theorem is introduced at some point in the game.
if thm.disabled then
-- Theorem is disabled in this level.
addMessageByDifficulty info s!"The theorem/definition '{n}' is disabled in this level!"
else if thm.locked then
match workerState.inventory.find? (· == n.toString) with
| none =>
-- Theorem is still locked.
addMessageByDifficulty info s!"You have not unlocked the theorem/definition '{n}' yet!"
| some _ =>
-- Theorem is in the inventory, allow it.
pure ()
let lemmasAndDefs := levelInfo.lemmas ++ levelInfo.definitions where addMessageByDifficulty (info : SourceInfo) (s : MessageData) :=
match lemmasAndDefs.find? (fun l => l.name == n) with -- See `GameServer.FileWorker.WorkerState.difficulty`. Send nothing/warnings/errors
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!" -- deppending on difficulty.
| some lem => let difficulty := workerState.difficulty
if lem.locked then
addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
else if lem.disabled then
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addWarningMessage (info : SourceInfo) (s : MessageData) :=
let difficulty := gameWorkerState.difficulty
if difficulty > 0 then if difficulty > 0 then
modify fun st => { st with addMessage info inputCtx (if difficulty > 1 then .error else .warning) s
messages := st.messages.add {
fileName := inputCtx.fileName
severity := if difficulty > 1 then MessageSeverity.error else MessageSeverity.warning
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s
}
}
else pure () else pure ()
-- where addErrorMessage (info : SourceInfo) (s : MessageData) :=
-- pure ()
open Elab Meta Expr in open Elab Meta Expr in
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool) def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
(couldBeEndSnap : Bool) (gameWorkerState : GameWorkerState) (couldBeEndSnap : Bool) (gameWorkerState : WorkerState)
(initParams : Lsp.InitializeParams) : IO Snapshot := do (initParams : Lsp.InitializeParams) : IO Snapshot := do
-- Recognize end snap -- Recognize end snap
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
@@ -214,10 +257,13 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
-- Insert final `done` command to display unsolved goal error in the end -- Insert final `done` command to display unsolved goal error in the end
let done := Syntax.node (.synthetic cmdParserState.pos cmdParserState.pos) ``Lean.Parser.Tactic.done #[] let done := Syntax.node (.synthetic cmdParserState.pos cmdParserState.pos) ``Lean.Parser.Tactic.done #[]
let tacticStx := (#[skip] ++ tacticStx.getArgs ++ #[done]).map (⟨.⟩) let tacticStx := (#[skip] ++ tacticStx.getArgs ++ #[done]).map (⟨.⟩)
let tacticStx := ← `(Lean.Parser.Tactic.tacticSeq| $[$(tacticStx)]*) let tacticStx' := ← `(Lean.Parser.Tactic.tacticSeq| $[$(tacticStx)]*)
-- Always call `let_intros` to get rid `let` statements in the goal.
-- This makes the experience for the user much nicer and allows for local
-- definitions in the exercise.
let cmdStx ← `(command| let cmdStx ← `(command|
theorem the_theorem $(level.goal) := by {$(⟨tacticStx⟩)} ) theorem the_theorem $(level.goal) := by {let_intros; $(⟨level.preamble⟩); $(⟨tacticStx'⟩)} )
Elab.Command.elabCommandTopLevel cmdStx) Elab.Command.elabCommandTopLevel cmdStx)
cmdCtx cmdStateRef cmdCtx cmdStateRef
let postNew := (← tacticCacheNew.get).post let postNew := (← tacticCacheNew.get).post
@@ -262,7 +308,7 @@ where
private def publishIleanInfo (method : String) (m : DocumentMeta) (hOut : FS.Stream) private def publishIleanInfo (method : String) (m : DocumentMeta) (hOut : FS.Stream)
(snaps : Array Snapshot) : IO Unit := do (snaps : Array Snapshot) : IO Unit := do
let trees := snaps.map fun snap => snap.infoTree let trees := snaps.map fun snap => snap.infoTree
let references := findModuleRefs m.text trees (localVars := true) let references ← findModuleRefs m.text trees (localVars := true) |>.toLspModuleRefs
let param := { version := m.version, references : LeanIleanInfoParams } let param := { version := m.version, references : LeanIleanInfoParams }
hOut.writeLspNotification { method, param } hOut.writeLspNotification { method, param }
@@ -276,6 +322,82 @@ where
uri : String uri : String
deriving ToJson, FromJson deriving ToJson, FromJson
structure GameDiagnostics where
diagnostics : List Diagnostic
deriving ToJson, FromJson
structure GameParams where
uri : String
diagnostics : GameDiagnostics
deriving ToJson, FromJson
-- `snap` and `initParams` are unused
set_option linter.unusedVariables false in
/-- WIP: publish diagnostics, all intermediate goals and if the game is completed. -/
def publishProofState (m : DocumentMeta) (snap : Snapshot) (initParams : Lsp.InitializeParams) (hOut : FS.Stream) :
IO Unit := do
-- let text := m.text
-- -- `snap` is the one snapshot containing the entire proof.
-- let mut goals : Array <| InteractiveGoalsWithHints := #[]
-- for pos in text.positions do
-- let source := text.getLineBefore pos
-- -- iterate over all newlines in the proof and get the goals and hints at each position
-- if let goalsAtResult@(_ :: _) := snap.infoTree.goalsAt? text pos then
-- pure ()
-- let goalAtPos : 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 m 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 goalAtPos : Array InteractiveGoalWithHints := ⟨goalAtPos.foldl (· ++ ·) []⟩
-- goals := goals.push ⟨goalAtPos, source⟩
-- else
-- -- No goals present
-- goals := goals.push default
-- -- Question: Is there a difference between the diags of this snap and the last snap?
-- -- Should we get the diags from there?
-- let diag : Array Widget.InteractiveDiagnostic := snap.interactiveDiags.toArray
-- -- Level is completed if there are no errrors or warnings
-- let completed : Bool := ¬ diag.any (fun d =>
-- d.severity? == some .error ∨ d.severity? == some .warning)
-- let param : ProofState := {
-- steps := goals,
-- diagnostics := diag,
-- completed := completed }
-- TODO
let param := { uri := m.uri : GameCompletedParams}
hOut.writeLspNotification { method := "$/game/publishProofState", param }
/-- Checks whether game level has been completed and sends a notification to the client -/ /-- Checks whether game level has been completed and sends a notification to the client -/
def publishGameCompleted (m : DocumentMeta) (hOut : FS.Stream) (snaps : Array Snapshot) : IO Unit := do def publishGameCompleted (m : DocumentMeta) (hOut : FS.Stream) (snaps : Array Snapshot) : IO Unit := do
-- check if there is any error or warning -- check if there is any error or warning
@@ -285,72 +407,58 @@ where
let param := { uri := m.uri : GameCompletedParams} let param := { uri := m.uri : GameCompletedParams}
hOut.writeLspNotification { method := "$/game/completed", param } hOut.writeLspNotification { method := "$/game/completed", param }
/-- Elaborates the next command after `parentSnap` and emits diagnostics into `hOut`. -/ /-- copied from `Lean.Server.FileWorker.nextCmdSnap`. -/
private def nextSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken) -- @[inherit_doc Lean.Server.FileWorker.nextCmdSnap] -- cannot inherit from private
(gameWorkerState : GameWorkerState) (initParams : Lsp.InitializeParams) private def nextCmdSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
: AsyncElabM (Option Snapshot) := do (gameWorkerState : WorkerState) (initParams : Lsp.InitializeParams) :
AsyncElabM (Option Snapshot) := do
cancelTk.check cancelTk.check
let s ← get let s ← get
let .some lastSnap := s.snaps.back? | panic! "empty snapshots" let .some lastSnap := s.snaps.back? | panic! "empty snapshots"
if lastSnap.isAtEnd then if lastSnap.isAtEnd then
publishGameCompleted m ctx.hOut s.snaps
publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut publishDiagnostics m lastSnap.diagnostics.toArray ctx.hOut
publishProgressDone m ctx.hOut publishProgressDone m ctx.hOut
-- This will overwrite existing ilean info for the file, in case something
-- went wrong during the incremental updates.
publishIleanInfoFinal m ctx.hOut s.snaps publishIleanInfoFinal m ctx.hOut s.snaps
return none return none
publishProgressAtPos m lastSnap.endPos ctx.hOut publishProgressAtPos m lastSnap.endPos ctx.hOut
-- (modified part)
-- Make sure that there is at least one snap after the head snap, so that -- Make sure that there is at least one snap after the head snap, so that
-- we can see the current goal even on an empty document -- we can see the current goal even on an empty document
let couldBeEndSnap := s.snaps.size > 1 let couldBeEndSnap := s.snaps.size > 1
let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap
gameWorkerState initParams gameWorkerState initParams
set { s with snaps := s.snaps.push snap } set { s with snaps := s.snaps.push snap }
-- TODO(MH): check for interrupt with increased precision
cancelTk.check cancelTk.check
/- NOTE(MH): This relies on the client discarding old diagnostics upon receiving new ones -- publishProofState m snap initParams ctx.hOut
while preferring newer versions over old ones. The former is necessary because we do
not explicitly clear older diagnostics, while the latter is necessary because we do
not guarantee that diagnostics are emitted in order. Specifically, it may happen that
we interrupted this elaboration task right at this point and a newer elaboration task
emits diagnostics, after which we emit old diagnostics because we did not yet detect
the interrupt. Explicitly clearing diagnostics is difficult for a similar reason,
because we cannot guarantee that no further diagnostics are emitted after clearing
them. -/
-- NOTE(WN): this is *not* redundant even if there are no new diagnostics in this snapshot
-- because empty diagnostics clear existing error/information squiggles. Therefore we always
-- want to publish in case there was previously a message at this position.
publishDiagnostics m snap.diagnostics.toArray ctx.hOut publishDiagnostics m snap.diagnostics.toArray ctx.hOut
publishIleanInfoUpdate m ctx.hOut #[snap] publishIleanInfoUpdate m ctx.hOut #[snap]
return some snap return some snap
/-- Elaborates all commands after the last snap (at least the header snap is assumed to exist), emitting the diagnostics into `hOut`. -/ -- Copied from `Lean.Server.FileWorker.unfoldCmdSnaps` using our own `nextCmdSnap`.
def unfoldSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken) @[inherit_doc Lean.Server.FileWorker.unfoldCmdSnaps]
(startAfterMs : UInt32) (gameWorkerState : GameWorkerState) def unfoldCmdSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
(startAfterMs : UInt32) (gameWorkerState : WorkerState)
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do : ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
let ctx ← read let ctx ← read
let some headerSnap := snaps[0]? | panic! "empty snapshots" let some headerSnap := snaps[0]? | panic! "empty snapshots"
if headerSnap.msgLog.hasErrors then if headerSnap.msgLog.hasErrors then
-- Treat header processing errors as fatal so users aren't swamped with
-- followup errors
publishProgressAtPos m headerSnap.beginPos ctx.hOut (kind := LeanFileProgressKind.fatalError) publishProgressAtPos m headerSnap.beginPos ctx.hOut (kind := LeanFileProgressKind.fatalError)
publishIleanInfoFinal m ctx.hOut #[headerSnap] publishIleanInfoFinal m ctx.hOut #[headerSnap]
return AsyncList.ofList [headerSnap] return AsyncList.ofList [headerSnap]
else else
-- This will overwrite existing ilean info for the file since this has a
-- higher version number.
publishIleanInfoUpdate m ctx.hOut snaps publishIleanInfoUpdate m ctx.hOut snaps
return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
IO.sleep startAfterMs IO.sleep startAfterMs
AsyncList.unfoldAsync (nextSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps }) AsyncList.unfoldAsync (nextCmdSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps })
end Elab end Elab
section Updates section Updates
/-- Given the new document, updates editable doc state. -/ /-- Given the new document, updates editable doc state. -/
def updateDocument (newMeta : DocumentMeta) : GameWorkerM Unit := do def updateDocument (newMeta : DocumentMeta) : WorkerM Unit := do
let s ← get let s ← get
let ctx ← read let ctx ← read
let oldDoc := (← StateT.lift get).doc let oldDoc := (← StateT.lift get).doc
@@ -393,7 +501,7 @@ section Updates
validSnaps := validSnaps.dropLast validSnaps := validSnaps.dropLast
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger -- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable) -- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
unfoldSnaps newMeta validSnaps.toArray cancelTk s ctx unfoldCmdSnaps newMeta validSnaps.toArray cancelTk s ctx
(startAfterMs := ctx.initParams.editDelay.toUInt32) (startAfterMs := ctx.initParams.editDelay.toUInt32)
StateT.lift <| modify fun st => { st with StateT.lift <| modify fun st => { st with
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }} doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
@@ -407,6 +515,12 @@ section Initialization
fileName := (System.Uri.fileUriToPath? doc.uri).getD doc.uri |>.toString fileName := (System.Uri.fileUriToPath? doc.uri).getD doc.uri |>.toString
fileMap := default fileMap := default
/-- `gameDir` and `module` were added.
TODO: In general this resembles little similarity with the
original code, and I don't know why...
-/
-- @[inherit_doc Lean.Server.FileWorker.compileHeader]
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool) def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
(gameDir : String) (module : Name): (gameDir : String) (module : Name):
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
@@ -442,7 +556,7 @@ section Initialization
let cmdState := Elab.Command.mkState headerEnv {} opts let cmdState := Elab.Command.mkState headerEnv {} opts
let cmdState := { cmdState with infoState := { let cmdState := { cmdState with infoState := {
enabled := true enabled := true
trees := #[Elab.InfoTree.context ({ trees := #[Elab.InfoTree.context (.commandCtx {
env := headerEnv env := headerEnv
fileMap := m.text fileMap := m.text
ngen := { namePrefix := `_worker } ngen := { namePrefix := `_worker }
@@ -459,7 +573,7 @@ section Initialization
let headerSnap := { let headerSnap := {
beginPos := 0 beginPos := 0
stx := headerStx stx := headerStx
mpState := {} mpState := {} -- was `headerParserState`
cmdState := cmdState cmdState := cmdState
interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets) interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets)
tacticCache := (← IO.mkRef {}) tacticCache := (← IO.mkRef {})
@@ -467,15 +581,17 @@ section Initialization
publishDiagnostics m headerSnap.diagnostics.toArray hOut publishDiagnostics m headerSnap.diagnostics.toArray hOut
return (headerSnap, srcSearchPath) return (headerSnap, srcSearchPath)
/-- Copied from `Lean.Server.FileWorker.initializeWorker`. Added `gameDir` and
`gameWorkerState` arguments and use custom `unfoldCmdSnaps`. -/
-- @[inherit_doc Lean.Server.FileWorker.initializeWorker]
def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options) def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
(gameDir : String) (gameWorkerState : GameWorkerState) : IO (WorkerContext × WorkerState) := do (gameDir : String) (gameWorkerState : WorkerState) : IO (WorkerContext × Server.FileWorker.WorkerState) := do
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets) let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
gameDir gameWorkerState.levelInfo.module (gameDir := gameDir) (module := gameWorkerState.levelInfo.module)
let cancelTk ← CancelToken.new let cancelTk ← CancelToken.new
let ctx := let ctx := {
{ hIn := i hIn := i
hOut := o hOut := o
hLog := e hLog := e
headerTask headerTask
@@ -483,11 +599,11 @@ section Initialization
clientHasWidgets clientHasWidgets
} }
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0) | Except.ok (s, _) => unfoldCmdSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0)
| Except.error e => throw (e : ElabTaskError)) | Except.error e => throw (e : ElabTaskError))
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk } let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
return (ctx, return (ctx, {
{ doc := doc doc := doc
initHeaderStx := headerStx initHeaderStx := headerStx
currHeaderStx := headerStx currHeaderStx := headerStx
importCachingTask? := none importCachingTask? := none
@@ -499,80 +615,110 @@ end Initialization
section NotificationHandling section NotificationHandling
def handleDidChange (p : DidChangeTextDocumentParams) : GameWorkerM Unit := do /-- Copied from `Lean.Server.FileWorker.handleDidChange` but with our custom `WorkerM` and
`updateDocument` -/
-- @[inherit_doc Lean.Server.FileWorker.handleDidChange]
def handleDidChange (p : DidChangeTextDocumentParams) : WorkerM Unit := do
let docId := p.textDocument let docId := p.textDocument
let changes := p.contentChanges let changes := p.contentChanges
let oldDoc := (← StateT.lift get).doc let oldDoc := (← StateT.lift get).doc -- needed a lift to our custom `WorkerM`
let some newVersion ← pure docId.version? let newVersion := docId.version?.getD 0
| throwServerError "Expected version number" if ¬ changes.isEmpty then
if newVersion ≤ oldDoc.meta.version then
-- TODO(WN): This happens on restart sometimes.
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
else if ¬ changes.isEmpty then
let newDocText := foldDocumentChanges changes oldDoc.meta.text let newDocText := foldDocumentChanges changes oldDoc.meta.text
-- modification: set the `DependencyBuildMode` from
-- `oldDoc.meta.dependencyBuildMode` to `.always`
updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩ updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩
end NotificationHandling end NotificationHandling
section MessageHandling section MessageHandling
def handleNotification (method : String) (params : Json) : GameWorkerM Unit := do
let handle := fun paramType [FromJson paramType] (handler : paramType → GameWorkerM Unit) => /--
Modified notification handler.
Compare to `Lean.Server.FileWorker.handleNotification`.
We use the modified `WorkerM` and use our custom `handleDidChange`.
-/
def handleNotification (method : String) (params : Json) : WorkerM Unit := do
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
(StateT.lift <| parseParams paramType params) >>= handler (StateT.lift <| parseParams paramType params) >>= handler
match method with match method with
-- Modified `textDocument/didChange`, using a custom `handleDidChange`
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange) | "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
-- unmodified
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·) | "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
| "$/setTrace" => pure () -- unmodified
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·) | "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
-- unmodified
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·) | "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
-- New. TODO: What is this for?
| "$/setTrace" => pure ()
| _ => throwServerError s!"Got unsupported notification method: {method}" | _ => throwServerError s!"Got unsupported notification method: {method}"
end MessageHandling end MessageHandling
section MainLoop section MainLoop
partial def mainLoop : GameWorkerM Unit := do
/--
The main-loop. Copied from `Lean.Server.FileWorker.mainLoop`. Use custom `WorkerM` as well
as custom `handleNotification`.
-/
--@[inherit_doc Lean.Server.FileWorker.mainLoop]
partial def mainLoop : WorkerM Unit := do
let ctx ← read let ctx ← read
let mut st ← StateT.lift get let mut st ← StateT.lift get
let msg ← ctx.hIn.readLspMessage let msg ← ctx.hIn.readLspMessage
-- Erase finished tasks if there are no errors.
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit)) let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
: IO PendingRequestMap := do : IO PendingRequestMap := do
if (← hasFinished task) then if (← hasFinished task) then
/- Handler tasks are constructed so that the only possible errors here
are failures of writing a response into the stream. -/
if let Except.error e := task.get then if let Except.error e := task.get then
throwServerError s!"Failed responding to request {id}: {e}" throwServerError s!"Failed responding to request {id}: {e}"
pure <| acc.erase id pure <| acc.erase id
else pure acc else pure acc
let pendingRequests ← st.pendingRequests.foldM (fun acc id task => filterFinishedTasks acc id task) st.pendingRequests let pendingRequests ← st.pendingRequests.foldM (fun acc id task => filterFinishedTasks acc id task) st.pendingRequests
st := { st with pendingRequests } st := { st with pendingRequests }
-- Opportunistically (i.e. when we wake up on messages) check if any RPC session has expired.
for (id, seshRef) in st.rpcSessions do for (id, seshRef) in st.rpcSessions do
let sesh ← seshRef.get let sesh ← seshRef.get
if (← sesh.hasExpired) then if (← sesh.hasExpired) then
st := { st with rpcSessions := st.rpcSessions.erase id } st := { st with rpcSessions := st.rpcSessions.erase id }
set st set st
-- Process the RPC-message and restart main-loop.
match msg with match msg with
| Message.request id "shutdown" none =>
--added. TODO: why do we need that? Or has it just removed in Lean since when we started?
ctx.hOut.writeLspResponse ⟨id, Json.null⟩
mainLoop
| Message.request id method (some params) => | Message.request id method (some params) =>
-- Requests are handled by the unmodified lean server.
handleRequest id method (toJson params) handleRequest id method (toJson params)
mainLoop mainLoop
| Message.notification "exit" none => | Message.notification "exit" none =>
let doc := st.doc let doc := st.doc
doc.cancelTk.set doc.cancelTk.set
doc.cmdSnaps.cancel
return () return ()
| Message.request id "shutdown" none =>
ctx.hOut.writeLspResponse ⟨id, Json.null⟩
mainLoop
| Message.notification method (some params) => | Message.notification method (some params) =>
-- Custom notification handler
handleNotification method (toJson params) handleNotification method (toJson params)
mainLoop mainLoop
| _ => throwServerError s!"Got invalid JSON-RPC message: {toJson msg}" | _ =>
throwServerError s!"Got invalid JSON-RPC message: {toJson msg}"
end MainLoop end MainLoop
/-- Modified from `Lean.Server.FileWorker.initAndRunWorker`.
Added `gameDir` argument, -/
-- @[inherit_doc Lean.Server.FileWorker.initAndRunWorker]
def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do
let i ← maybeTee "fwIn.txt" false i let i ← maybeTee "fwIn.txt" false i
let o ← maybeTee "fwOut.txt" true o let o ← maybeTee "fwOut.txt" true o
-- BIG MODIFICATION
let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams
o.writeLspResponse { o.writeLspResponse {
id := initRequest.id id := initRequest.id
@@ -588,16 +734,15 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
discard $ i.readLspNotificationAs "initialized" InitializedParams discard $ i.readLspNotificationAs "initialized" InitializedParams
let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams
let doc := param.textDocument let doc := param.textDocument
/- NOTE(WN): `toFileMap` marks line beginnings as immediately following -- modification: using `.always`
"\n", which should be enough to handle both LF and CRLF correctly.
This is because LSP always refers to characters by (line, column),
so if we get the line number correct it shouldn't matter that there
is a CR there. -/
let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap, .always⟩ let meta : DocumentMeta := ⟨doc.uri, doc.version, doc.text.toFileMap, .always⟩
let e := e.withPrefix s!"[{param.textDocument.uri}] " let e := e.withPrefix s!"[{param.textDocument.uri}] "
let _ ← IO.setStderr e let _ ← IO.setStderr e
try try
-- BIG MODIFICATION
let game ← loadGameData gameDir let game ← loadGameData gameDir
-- TODO: We misuse the `rootUri` field to the gameName -- TODO: We misuse the `rootUri` field to the gameName
let rootUri? : Option String := some (toString game.name) let rootUri? : Option String := some (toString game.name)
@@ -606,16 +751,29 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
initParams meta.mkInputContext.fileName initParams meta.mkInputContext.fileName
| throwServerError s!"Could not determine level ID: {meta.mkInputContext.fileName}" | throwServerError s!"Could not determine level ID: {meta.mkInputContext.fileName}"
let levelInfo ← loadLevelData gameDir levelId.world levelId.level let levelInfo ← loadLevelData gameDir levelId.world levelId.level
let some initializationOptions := initRequest.param.initializationOptions?
| throwServerError "no initialization options found" -- TODO: I don't know how to pass these options to the Lean server
let gameWorkerState : GameWorkerState:= { -- with the new lean4monaco setup!!
-- let some initializationOptions := initRequest.param.initializationOptions?
-- | throwServerError "no initialization options found"
let initializationOptions : Game.InitializationOptions := {
difficulty := 1
inventory := #[]
editDelay? := none
hasWidgets? := none
}
let gameWorkerState : WorkerState := {
inventory := initializationOptions.inventory inventory := initializationOptions.inventory
difficulty := initializationOptions.difficulty difficulty := initializationOptions.difficulty
levelInfo levelInfo
} }
let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState
-- Run the main loop
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <| let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
StateT.run (s := gameWorkerState) <| (mainLoop) StateT.run (s := gameWorkerState) <| (mainLoop)
return (0 : UInt32) return (0 : UInt32)
catch e => catch e =>
IO.eprintln e IO.eprintln e
@@ -625,6 +783,15 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
message := e.toString }] o message := e.toString }] o
return (1 : UInt32) return (1 : UInt32)
/--
The main function. Simply wrapping `initAndRunWorker`.
Copied from `Lean.Server.FileWorker.workerMain`. We add `args` as an argument to pass on
the `gameDir`.
TODO: The first arg `args[0]` is always expected to be `--server`. We could drop this completely.
-/
-- @[inherit_doc Lean.Server.FileWorker.workerMain]
def workerMain (opts : Options) (args : List String): IO UInt32 := do def workerMain (opts : Options) (args : List String): IO UInt32 := do
let i ← IO.getStdin let i ← IO.getStdin
let o ← IO.getStdout let o ← IO.getStdout
@@ -632,8 +799,6 @@ def workerMain (opts : Options) (args : List String): IO UInt32 := do
try try
let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir" let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir"
let exitCode ← initAndRunWorker i o e opts gameDir let exitCode ← initAndRunWorker i o e opts gameDir
-- HACK: all `Task`s are currently "foreground", i.e. we join on them on main thread exit, but we definitely don't
-- want to do that in the case of the worker processes, which can produce non-terminating tasks evaluating user code
o.flush o.flush
e.flush e.flush
IO.Process.exit exitCode.toUInt8 IO.Process.exit exitCode.toUInt8
@@ -641,4 +806,4 @@ def workerMain (opts : Options) (args : List String): IO UInt32 := do
e.putStrLn s!"worker initialization error: {err}" e.putStrLn s!"worker initialization error: {err}"
return (1 : UInt32) return (1 : UInt32)
end MyServer.FileWorker end GameServer.FileWorker
+3 -39
View File
@@ -1,13 +1,14 @@
import Lean import Lean
import GameServer.Helpers.PrettyPrinter
/-! This document contains various things which cluttered `Commands.lean`. -/ /-! This document contains various things which cluttered `Commands.lean`. -/
open Lean Meta Elab Command open Lean Meta Elab Command
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-! ## Doc Comment Parsing -/ /-! ## Doc Comment Parsing -/
namespace GameServer
/-- Read a doc comment and get its content. Return `""` if no doc comment available. -/ /-- Read a doc comment and get its content. Return `""` if no doc comment available. -/
def parseDocComment! (doc: Option (TSyntax `Lean.Parser.Command.docComment)) : def parseDocComment! (doc: Option (TSyntax `Lean.Parser.Command.docComment)) :
CommandElabM String := do CommandElabM String := do
@@ -61,47 +62,10 @@ def parseDocCommentLegacy (doc: Option (TSyntax `Lean.Parser.Command.docComment)
and remove the string following it!" and remove the string following it!"
pure <| ← parseDocComment! doc 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. -/ /-- A `attr := ...` option for `Statement`. Add attributes to the defined theorem. -/
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")" syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO -- TODO
/-- 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⟩ ""
/-! ## Loops in Graph-like construct /-! ## Loops in Graph-like construct
TODO: Why are we not using graphs here but our own construct `HashMap Name (HashSet Name)`? TODO: Why are we not using graphs here but our own construct `HashMap Name (HashSet Name)`?

Some files were not shown because too many files have changed in this diff Show More