Compare commits

..
Author SHA1 Message Date
Alexander Bentkamp 78ef3e0755 test ubuntu-20.04 2023-10-04 13:40:26 +02:00
47 changed files with 3292 additions and 4318 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ run-name: Build the project
on: [push] on: [push]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-20.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
+37 -46
View File
@@ -8,7 +8,9 @@ A game consist of worlds which have multiple levels each. In the following we de
### Level ### Level
A level file is a lean file that imports at least `import GameServer.Commands` and starts with the following Lean commands. #### Preample
A level file is a lean file that imports at least `GameServer.Commands` and starts with the four lean commands
```lean ```lean
Game "NNG" Game "NNG"
@@ -17,12 +19,12 @@ Level 1
Title "The rfl tactic" Title "The rfl tactic"
``` ```
Note that the levels inside a world must have consecutive numbering starting with `1`. The `Game` Note that the levels inside a world must have consequtive numbering starting with `1`. The `Game`
and `World` strings can be anything, see below. and `World` strings can be anything.
#### Statement #### Statement
The core of a level is the `Statement`, which is the exercise that should be proven. The core of a level is the `Statement`, which is the exercise that should be proven:
```lean ```lean
/-- For all natural numbers $n$, we have $0 + n = n$. -/ /-- For all natural numbers $n$, we have $0 + n = n$. -/
@@ -51,12 +53,10 @@ There are a few extra tactics that help you structuring the proof:
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches - `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 alternativ tactic sequence, which
helps setting `Hints` in different places. The `Branch` does not affect the main helps setting `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`: not implemented yet, but used to provide a sample proof template.
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
having a `Template` will force the user to use Editor-mode for this level.
##### Statement Name (optional) ##### Statement Name (optional)
@@ -77,27 +77,26 @@ There are three places where the documentation comment appears:
description there including examples etc. description there including examples etc.
Both latter points support Markdown (including katex). Both latter points support Markdown (including katex).
##### Attributes (optional) ##### Attributes (optional)
the `@[ attributes ]` prefix should work just like you know it from the `theorem` keyword. the `@[ attributes ]` prefix should work just like you know it from the `theorem` keyword.
#### Introduction/Conclusion #### Introduction/Conclusion
Optionally, you can add an `Introduction "some text"` and `Conclusion "some text"` to your level. Optionally, you can add a `Introduction "some text"` and `Conclusion "some text"` to your level.
The introduction will be shown at the beginning, the conclusion is displayed once the level The introduction will be shown at the beginning, the conclusion is displayed once the level
is solved. is solved.
#### Theorems/Tactics/Definitions #### Lemmas/Tactics/Definitions
Only enabled theorems/tactics/definitions (called "items" here) are available in a level. Only enabled lemmas/tactics/definitions (called "items" here) are available in a level.
To add a new item in a level, you can add To add a new item in a level, you can add
```lean ```lean
NewTactic rfl simp NewTactic rfl simp
NewLemma MyNat.add_zero MyNat.add_succ NewLemma MyNat.add_zero
NewDefinition Nat Pow Mul NewDefinition Nat
``` ```
Once added, items will be available in all future levels/worlds, Once added, items will be available in all future levels/worlds,
@@ -108,7 +107,7 @@ DisabledTactic tauto
DisabledLemma MyNat.add_zero DisabledLemma MyNat.add_zero
``` ```
or specify explicitly which items should be available with or specify explicitely which items should be available with
```lean ```lean
OnlyTactic rw rfl apply OnlyTactic rw rfl apply
@@ -135,9 +134,10 @@ Introduction "some text"
``` ```
The `Title` is the world's display title. The `Introduction` is displayed before loading level 1. The `Title` is the world's display title. The `Introduction` is displayed before loading level 1.
Note that all levels of a world should be imported by the world file. Note that all levels of a world should be imported by the world file and they **must not** be
imported in another world's levels. (bug?)
BUG: A level **must not** be imported in a different world's level. Instead, you have to import an entire world there: `import NNG.Levels.Addition` However, you can import an entire world in a different world's level: `import NNG.Levels.Addition`
### Game ### Game
@@ -152,18 +152,17 @@ Game "NNG"
Title "Natural Number Game" Title "Natural Number Game"
Introduction "some text" Introduction "some text"
Path Addition → Multiplication → Power
MakeGame MakeGame
``` ```
The game will automatically compute the order of the worlds depending on the sample proofs of the Levels (ignoring anything inside a `Branch`). You can add additional dependencies manually by adding `Dependency PowerWorld → ImpossibleWorld` before `MakeGame`. There can be several `Path` statements to define the relation between the worlds. The worlds
The order of worlds influences which tactics and lemmas will be unlocked in a given level. must form a directed acyclic graph, i.e. have no loops. The order of worlds influences which tactics and lemmas will be unlocked in a given level.
`MakeGame` will display warnings about things in the game that need to be fixed, like missing
documentation or if a tactic is never introduced.
### Documentation ### Documentation
Each tactic, theorem, or definition (all called items here) that is introduced in the game Each tactic, lemma, or definition (all called items here) that is introduced in the game
needs a documentation entry. These are statements of the following form: needs a documentation entry. These are statements of the following form:
```lean ```lean
@@ -195,7 +194,7 @@ There will be features added to get automatic information from mathlib!
Inside the doc comment you don't need to escape the backslashes: Inside the doc comment you don't need to escape the backslashes:
```lean ```
/-- $\operatorname{succ}(n)$. notation for naturals is `\N`. -/ /-- $\operatorname{succ}(n)$. notation for naturals is `\N`. -/
Statement ... Statement ...
``` ```
@@ -204,17 +203,11 @@ However, inside interpolated strings (e.g. in `Hint`, `Introduction` and `Conclu
you do need to escape backslashes you do need to escape backslashes
with `\\` and `{` with `\{`: with `\\` and `{` with `\{`:
```lean ```
Hint "This code has some $\\operatorname\{succ}(n)$ math. The value of `h` is {h}. Hint "This code has some $\\operatorname\{succ}(n)$ math. The value of `h` is {h}.
Notation for naturals is `\\N`." Notation for naturals is `\\N`."
``` ```
## Game design
Here are some things you should consider designing a new game:
* A world with more than 16 levels will be displayed with the levels spiraling outwards,
it might be desirable to stay below that bound. Above 22 levels the spiral start getting out
of control.
# Running Games Locally # Running Games Locally
@@ -224,7 +217,7 @@ The installation instructions are not yet tested on Mac/Windows. Comments very w
1. **Install Docker and Dev Containers** *(once)*:<br/> 1. **Install Docker and Dev Containers** *(once)*:<br/>
See [official instructions](https://code.visualstudio.com/docs/devcontainers/containers#_getting-started). See [official instructions](https://code.visualstudio.com/docs/devcontainers/containers#_getting-started).
Explicitly this means: Explicitely this means:
* Install docker engine if you have not yet: [Instructions](https://docs.docker.com/engine/install/). * Install docker engine if you have not yet: [Instructions](https://docs.docker.com/engine/install/).
I followed the "Server" instructions for linux. I followed the "Server" instructions for linux.
* Note that on Linux you need to add your user to the `docker` group * Note that on Linux you need to add your user to the `docker` group
@@ -251,30 +244,29 @@ The installation instructions are not yet tested on Mac/Windows. Comments very w
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode. running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`, * 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 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 explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time) container. (this will again take some time)
## Without Dev Containers ## Without Dev Containers
Install `nvm`: Install `nvm`:
```bash ```
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
``` ```
then reopen bash and test with `command -v nvm` if it is available (Should print "nvm"). then reopen bash and test with `command -v nvm` if it is available (Should print "nvm").
Now install node: Now install node:
```bash ```
nvm install node nvm install node
``` ```
Clone the game (e.g. `NNG4` here): Clone the game (e.g. `NNG4` here):
```bash ```
git clone https://github.com/hhu-adam/NNG4.git git clone git@github.com:hhu-adam/NNG4.git
# or: git clone git@github.com:hhu-adam/NNG4.git
``` ```
Download dependencies and build the game: Download dependencies and build the game:
```bash ```
cd NNG4 cd NNG4
lake update lake update
lake exe cache get # if your game depends on mathlib lake exe cache get # if your game depends on mathlib
@@ -282,22 +274,21 @@ lake build
``` ```
Clone the game repository into a directory next to the game: Clone the game repository into a directory next to the game:
```bash ```
cd .. cd ..
git clone https://github.com/leanprover-community/lean4game.git git clone git@github.com:leanprover-community/lean4game.git
# or: git clone git@github.com:leanprover-community/lean4game.git
``` ```
The folders `NNG4` and `lean4game` must be in the same directory! The folders `NNG4` and `lean4game` must be in the same directory!
In `lean4game`, install dependencies: In `lean4game`, install dependencies:
```bash ```
cd lean4game cd lean4game
npm install npm install
``` ```
If you are developing a game other than `Robo` or `NNG4`, adapt the If you are developing a game other than `Robo` or `NNG4`, adapt the
code at the beginning of `lean4game/server/index.mjs`: code at the beginning of `lean4game/server/index.mjs`:
```typescript ```
const games = { const games = {
"g/hhu-adam/robo": { "g/hhu-adam/robo": {
dir: "../../../../Robo", dir: "../../../../Robo",
@@ -311,7 +302,7 @@ const games = {
``` ```
Run the game: Run the game:
```bash ```
npm start npm start
``` ```
@@ -323,7 +314,7 @@ and the game is available on http://localhost:3000/#/g/hhu-adam/NNG4.
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with this When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with this
setup by setting `export NODE_ENV=development` inside your local game before building it: setup by setting `export NODE_ENV=development` inside your local game before building it:
```bash ```
cd NNG4 cd NNG4
export NODE_ENV=development export NODE_ENV=development
lake update lake update
+15 -18
View File
@@ -1,36 +1,33 @@
# Lean 4 Game # Lean 4 Game
This is the source code for a Lean 4 game platform hosted at [adam.math.hhu.de](https://adam.math.hhu.de). This is a prototype for a Lean 4 game platform. The project is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
The project is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
(NNG)](https://www.ma.imperial.ac.uk/~buzzard/xena/natural_number_game/) (NNG)](https://www.ma.imperial.ac.uk/~buzzard/xena/natural_number_game/)
of Kevin Buzzard and Mohammad Pedramfar. of Kevin Buzzard and Mohammad Pedramfar.
The project is based on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4). The project is based on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4).
## Creating a Game ### Progress & Contributing
Currently the interface is still undergoing bigger changes, contributions are of course welcome, but it might be better to wait with them for a bit until proper support for external games is implemented and the existing games are separated from this repository. (ca. Sept. 2023)
Please follow the tutorial [Creating a Game](doc/create_game.md).
In particular step 5 thereof explains [How to Run Games Locally](doc/running_locally.md).
### Publishing a Game ### Documentation
We encourage anybody to have games hosted on our [Lean Game Server](https://adam.math.hhu.de) for anybody to play. For that you simply need to contact us with the link to your game repo. We are also happy to add work-in-progress games and games in any language. For game developers, there is a work-in-progress Documentation: [Create a Game](DOCUMENTATION.md).
Best to talk with us directly.
For example, you can [contact Jon on Zulip](https://leanprover.zulipchat.com/#narrow/dm/385895-Jon-Eugster). Or [via Email](https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster). For the game engine itself, documentation is missing currently.
## Documentation ## NPM Scripts
The documentation for the game engine itself is still missing, but there is [Creating a Game](doc/create_game.md) explaining the API to create a game. * `npm start`: Start the project in development mode. The browser will automatically reload when client files get changed. The Lean server will get recompiled and restarted when lean files get changed. The Lean server will be started without a container. The client and server can be started separately using the scripts `npm run start_client` and `npm run start_server`. The project can be accessed via `http://localhost:3000`.
Internally, websocket requests to `ws://localhost:3000/websockets` will be forwarded to a Lean server running on port `8080`.
Some documentation: * `npm run build`: Build the project in production mode. All assets of the client will be compiled into `client/dist`.
On the server side, the command will set up a docker image containing the Lean server. The two parts can be built separately using `npm run build_client` and `npm run build_server`.
- [NPM Scripts](doc/npm_scripts.md) * `npm run production`: Start the project in production mode. This requires that the build script has been run. It will start a server on the port specified in the `PORT` environment variable or by default on `8080`. You can run on a specifiv port by running `PORT=80 npm run production`. The server will serve the files in `client/dist` via http and give access to the docker-contained Lean server via the web socket protocol.
- [Old documentation](doc/DOCUMENTATION.md)
## Contributing
Contributions to `lean4game` are always welcome!
## 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 in a Docker container
secured by [gVisor](https://gvisor.dev/).
@@ -108,7 +108,6 @@ em {
flex: 0; flex: 0;
background: var(--clr-primary); background: var(--clr-primary);
display: flex; display: flex;
position: relative;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding: 1.1em; padding: 1.1em;
@@ -140,7 +139,3 @@ em {
.markdown ul, .markdown ol { .markdown ul, .markdown ol {
margin:0 1.5em 1.5em 1.5em; margin:0 1.5em 1.5em 1.5em;
} }
.privacy-policy {
z-index: 10;
}
+13 -3
View File
@@ -6,23 +6,33 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css'; import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css'; import '@fontsource/roboto/700.css';
import './css/reset.css'; import './reset.css';
import './css/app.css'; import './app.css';
import { MobileContext } from './components/infoview/context'; import { MobileContext } from './components/infoview/context';
import { useWindowDimensions } from './window_width'; import { useWindowDimensions } from './window_width';
import { selectOpenedIntro } from './state/progress';
import { useSelector } from 'react-redux';
export const GameIdContext = React.createContext<string>(undefined); export const GameIdContext = React.createContext<string>(undefined);
function App() { function App() {
const params = useParams() const params = useParams()
const gameId = "g/" + params.owner + "/" + params.repo const gameId = "g/" + params.owner + "/" + params.repo
// TODO: Make mobileLayout be changeable in settings
// TODO: Handle resize Events
const {width, height} = useWindowDimensions() const {width, height} = useWindowDimensions()
const [mobile, setMobile] = React.useState(width < 800) const [mobile, setMobile] = React.useState(width < 800)
// On mobile, there are multiple pages on the welcome page to switch between
const openedIntro = useSelector(selectOpenedIntro(gameId))
// On mobile, there are multiple pages to switch between
const [pageNumber, setPageNumber] = React.useState(openedIntro ? 1 : 0)
return ( return (
<div className="app"> <div className="app">
<GameIdContext.Provider value={gameId}> <GameIdContext.Provider value={gameId}>
<MobileContext.Provider value={{mobile, setMobile}}> <MobileContext.Provider value={{mobile, setMobile, pageNumber, setPageNumber}}>
<Outlet /> <Outlet />
</MobileContext.Provider> </MobileContext.Provider>
</GameIdContext.Provider> </GameIdContext.Provider>
+345 -199
View File
@@ -1,45 +1,45 @@
/**
* @file contains the navigation bars of the app.
*/
import * as React from 'react' import * as React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from "../app" import { GameIdContext } from "../app"
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context" import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
import { GameInfo, useGetGameInfoQuery } from '../state/api' import { GameInfo, useGetGameInfoQuery } from '../state/api'
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress' import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
import { useSelector } from 'react-redux'
import { useAppDispatch, useAppSelector } from '../hooks' import { useAppDispatch, useAppSelector } from '../hooks'
import { Button } from './button' import { Button } from './button'
import { downloadProgress } from './popup/erase' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faGlobe, faHome, faArrowRight, faArrowLeft, faXmark, faBars, faCode, faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { PrivacyPolicyPopup } from './privacy_policy'
import { WorldSelectionMenu, downloadFile } from './world_tree'
/** navigation buttons for mobile welcome page to switch between intro/tree/inventory. */ /** navigation to switch between pages on mobile */
function MobileNavButtons({pageNumber, setPageNumber}: function MobileNav({pageNumber, setPageNumber}:
{ pageNumber: number, { pageNumber: number,
setPageNumber: any}) { setPageNumber: any}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch() 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 prevText = {0 : null, 1: "Intro", 2: null}[pageNumber]
let prevIcon = {0: null, 1: null, 2: faBookOpen}[pageNumber] let prevIcon = {0 : null, 1: null, 2: faXmark}[pageNumber]
let prevTitle = {0: null, 1: "Game Introduction", 2: "World selection"}[pageNumber] let prevTitle = {
// if `nextText` or `nextIcon` is set, show a button to go forward 0: null,
1: "Game Introduction",
2: "World selection"}[pageNumber]
let nextText = {0 : "Start", 1: null, 2: null}[pageNumber] let nextText = {0 : "Start", 1: null, 2: null}[pageNumber]
let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber] let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber]
let nextTitle = {0: "World selection", 1: "Inventory", 2: null}[pageNumber] let nextTitle = {
0: "World selection",
1: "Inventory",
2: null}[pageNumber]
return <> return <>
{(prevText || prevIcon) && {(prevText || prevTitle || prevIcon) &&
<Button className="btn btn-inverted toggle-width" to={pageNumber == 0 ? "/" : ""} <Button className="btn btn-inverted toggle-width" to={pageNumber == 0 ? "/" : ""} inverted="true" title={prevTitle}
inverted="true" title={prevTitle}
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}> onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
{prevIcon && <FontAwesomeIcon icon={prevIcon} />} {prevIcon && <FontAwesomeIcon icon={prevIcon} />}
{prevText && `${prevText}`} {prevText && `${prevText}`}
</Button> </Button>
} }
{(nextText || nextIcon) && {(nextText || nextTitle || nextIcon) &&
<Button className="btn btn-inverted toggle-width" to="" inverted="true" <Button className="btn btn-inverted toggle-width" to="" inverted="true"
title={nextTitle} onClick={() => { title={nextTitle} onClick={() => {
console.log(`page number: ${pageNumber}`) console.log(`page number: ${pageNumber}`)
@@ -52,20 +52,330 @@ function MobileNavButtons({pageNumber, setPageNumber}:
</> </>
} }
/** button to toggle dropdown menu. */ export function WelcomeAppBar({gameInfo, toggleImpressum, openEraseMenu, openUploadMenu, toggleInfo} : {
function MenuButton({navOpen, setNavOpen}) { gameInfo: GameInfo,
return <Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}}> toggleImpressum: any,
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />} openEraseMenu: any,
</Button> openUploadMenu: any,
toggleInfo: any
}) {
const gameId = React.useContext(GameIdContext)
const {mobile, pageNumber, setPageNumber} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
/** Download the current progress (i.e. what's saved in the browser store) */
const gameProgress = useSelector(selectProgress(gameId))
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
} }
/** button to go one level futher.
* for the last level, this button turns into a button going back to the welcome page. return <div className="app-bar" >
*/ <>
function NextButton({worldSize, difficulty, completed, setNavOpen}) { <div>
<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>
<span className="app-bar-title">
{mobile ? '' : gameInfo?.title}
</span>
</div>
<div className="nav-btns">
{mobile && <>
{/* BUTTONS for MOBILE */}
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
</>}
<Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
</Button>
</div>
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
{/* {levelId < gameInfo.data?.worldSize[worldId] &&
<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>
}
{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={`/${gameId}`} inverted="true" title="back to world selection">
<FontAwesomeIcon icon={faHome} />&nbsp;Home
</Button>
<Button disabled={levelId <= 0} inverted="true" to=""
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
title="toggle Editor mode">
<FontAwesomeIcon icon={faCode} />&nbsp;Toggle Editor
</Button> */}
<Button title="Game Info & Credits" inverted="true" to="" onClick={(ev) => {toggleInfo(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Game Info
</Button>
<Button title="Clear Progress" inverted="true" to="" onClick={(ev) => {openEraseMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faEraser} />&nbsp;Erase
</Button>
<Button title="Download Progress" inverted="true" to="" onClick={(ev) => {downloadProgress(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faDownload} />&nbsp;Download
</Button>
<Button title="Load Progress from JSON" inverted="true" to="" onClick={(ev) => {openUploadMenu(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faUpload} />&nbsp;Upload
</Button>
<Button title="Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum
</Button>
</div>
</>
</div>
}
// /** The menu that is shown next to the world selection graph */
// function WorldSelectionMenu() {
// const [file, setFile] = React.useState<File>();
// const gameId = React.useContext(GameIdContext)
// const store = useStore()
// const difficulty = useSelector(selectDifficulty(gameId))
// /* state variables to toggle the pop-up menus */
// const [eraseMenu, setEraseMenu] = React.useState(false);
// const openEraseMenu = () => setEraseMenu(true);
// const closeEraseMenu = () => setEraseMenu(false);
// const [uploadMenu, setUploadMenu] = React.useState(false);
// const openUploadMenu = () => setUploadMenu(true);
// const closeUploadMenu = () => setUploadMenu(false);
// const gameProgress = useSelector(selectProgress(gameId))
// const dispatch = useAppDispatch()
// /** Download the current progress (i.e. what's saved in the browser store) */
// const downloadProgress = (e) => {
// e.preventDefault()
// downloadFile({
// data: JSON.stringify(gameProgress, null, 2),
// fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
// fileType: 'text/json',
// })
// }
// const handleFileChange = (e) => {
// if (e.target.files) {
// setFile(e.target.files[0])
// }
// }
// /** Upload progress from a */
// const uploadProgress = (e) => {
// if (!file) {return}
// const fileReader = new FileReader()
// fileReader.readAsText(file, "UTF-8")
// fileReader.onload = (e) => {
// const data = JSON.parse(e.target.result.toString()) as GameProgressState
// console.debug("Json Data", data)
// dispatch(loadProgress({game: gameId, data: data}))
// }
// closeUploadMenu()
// }
// const eraseProgress = () => {
// dispatch(deleteProgress({game: gameId}))
// closeEraseMenu()
// }
// const downloadAndErase = (e) => {
// downloadProgress(e)
// eraseProgress()
// }
// function label(x : number) {
// return x == 0 ? 'none' : x == 1 ? 'lax' : 'regular'
// }
// return <nav className="world-selection-menu">
// <Button onClick={downloadProgress} title="Download game progress" to=""><FontAwesomeIcon icon={faDownload} /></Button>
// <Button title="Load game progress from JSON" onClick={openUploadMenu} to=""><FontAwesomeIcon icon={faUpload} /></Button>
// <Button title="Clear game progress" to="" onClick={openEraseMenu}><FontAwesomeIcon icon={faEraser} /></Button>
// <div className="slider-wrap">
// <span className="difficulty-label">Game Rules:</span>
// <Slider
// title="Game Rules:&#10;- regular: 🔐 levels, 🔐 tactics&#10;- lax: 🔓 levels, 🔐 tactics&#10;- none: 🔓 levels, 🔓 tactics"
// min={0} max={2}
// aria-label="Game Rules"
// defaultValue={difficulty}
// marks={[
// {value: 0, label: label(0)},
// {value: 1, label: label(1)},
// {value: 2, label: label(2)}
// ]}
// valueLabelFormat={label}
// getAriaValueText={label}
// valueLabelDisplay="auto"
// onChange={(ev, val: number) => {
// dispatch(changedDifficulty({game: gameId, difficulty: val}))
// }}
// ></Slider>
// </div>
// {eraseMenu?
// <div className="modal-wrapper">
// <div className="modal-backdrop" onClick={closeEraseMenu} />
// <div className="modal">
// <div className="codicon codicon-close modal-close" onClick={closeEraseMenu}></div>
// <h2>Delete Progress?</h2>
// <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>
// <Button onClick={eraseProgress} to="">Delete</Button>
// <Button onClick={downloadAndErase} to="">Download & Delete</Button>
// <Button onClick={closeEraseMenu} to="">Cancel</Button>
// </div>
// </div> : null}
// {uploadMenu ?
// <div className="modal-wrapper">
// <div className="modal-backdrop" onClick={closeUploadMenu} />
// <div className="modal">
// <div className="codicon codicon-close modal-close" onClick={closeUploadMenu}></div>
// <h2>Upload Saved Progress</h2>
// <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!
// Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
// <input type="file" onChange={handleFileChange}/>
// <Button to="" onClick={uploadProgress}>Load selected file</Button>
// </div>
// </div> : null}
// </nav>
// }
/** The top-navigation bar */
export function LevelAppBar({
isLoading, levelTitle, impressum, toggleImpressum,
pageNumber = undefined, setPageNumber = undefined}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext) const {worldId, levelId} = React.useContext(WorldLevelIdContext)
return (levelId < worldSize ? const gameInfo = useGetGameInfoQuery({game: gameId})
const {mobile} = React.useContext(MobileContext)
const difficulty = useSelector(selectDifficulty(gameId))
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const { commandLineMode, setCommandLineMode } = React.useContext(InputModeContext)
const [navOpen, setNavOpen] = React.useState(false)
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">
{mobile && pageNumber == 0 ?
<Button to="" className="btn btn-inverted toggle-width"
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
<FontAwesomeIcon icon={faBook}/>
</Button>
: pageNumber == 1 &&
<Button className="btn btn-inverted toggle-width" to=""
title="show inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
<FontAwesomeIcon icon={faXmark}/>
</Button>
}
<Button to="" className="btn toggle-width" id="menu-btn" onClick={(ev) => {setNavOpen(!navOpen)}} >
{navOpen ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faBars} />}
</Button>
</div>
<div className={'menu dropdown' + (navOpen ? '' : ' hidden')}>
{levelId < gameInfo.data?.worldSize[worldId] &&
<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>
}
{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={`/${gameId}`} inverted="true" title="back to world selection">
<FontAwesomeIcon icon={faHome} />&nbsp;Home
</Button>
<Button disabled={levelId <= 0} inverted="true" to=""
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
title="toggle Editor mode">
<FontAwesomeIcon icon={faCode} />&nbsp;Toggle Editor
</Button>
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Info &amp; Impressum
</Button>
</div>
</>
:
<>
{/* DESKTOP VERSION */}
<div>
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />
</Button>
<span className="app-bar-title">
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
</span>
</div>
<div>
<span className="app-bar-title">
{levelTitle}
</span>
</div>
<div className="nav-btns">
{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>
</>}
{levelId < gameInfo.data?.worldSize[worldId] ?
<Button inverted="true" <Button inverted="true"
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level" to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
disabled={difficulty >= 2 && !(completed || levelId == 0)} disabled={difficulty >= 2 && !(completed || levelId == 0)}
@@ -76,179 +386,15 @@ function NextButton({worldSize, difficulty, completed, setNavOpen}) {
<Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn"> <Button to={`/${gameId}`} inverted="true" title="back to world selection" id="home-btn">
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World <FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button> </Button>
)
} }
<Button className="btn btn-inverted toggle-width" disabled={levelId <= 0} inverted="true" to=""
/** button to go one level back. onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
* only renders if the current level id is > 0. title="toggle Editor mode">
*/ <FontAwesomeIcon icon={commandLineMode ? faCode : faTerminal} />
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>
</>) <Button className="btn btn-inverted toggle-width" title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
} <FontAwesomeIcon icon={impressum ? faXmark : faCircleInfo} />
/** 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>
}
/** 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} : {
pageNumber: number,
setPageNumber: any,
gameInfo: GameInfo,
toggleImpressum: any,
toggleEraseMenu: any,
toggleUploadMenu: any,
toggleInfo: any
}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useAppSelector(selectProgress(gameId))
const {mobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false)
return <div className="app-bar">
<div>
<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>
</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>
<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>
</> </>
} }
@@ -64,17 +64,17 @@ 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 CommandLine({hidden}: {hidden?: boolean}) {
/** 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 {commandLineInput, setCommandLineInput} = React.useContext(InputModeContext)
const inputRef = useRef() const inputRef = useRef()
@@ -91,20 +91,16 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
*/ */
const loadAllGoals = React.useCallback(() => { const loadAllGoals = React.useCallback(() => {
if (!model || ! uri) {
return
}
let goalCalls = [] let goalCalls = []
let msgCalls = [] let msgCalls = []
// For each line of code ask the server for the goals and the messages on this line // 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++) { for (let i = 0; i < model.getLineCount(); i++) {
goalCalls.push( goalCalls.push(
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri})) rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
) )
msgCalls.push( msgCalls.push(
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1}).catch((error) => {console.debug("promise broken")}) getInteractiveDiagnostics(rpcSess, {start: i, end: i+1})
) )
} }
@@ -162,7 +158,7 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// with no goals there will be no hints. // with no goals there will be no hints.
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : [] let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
console.debug(`Command (${i}): `, i ? model?.getLineContent(i) : '') console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
console.debug(`Goals: (${i}): `, goalsToString(goals)) // console.debug(`Goals: (${i}): `, goalsToString(goals)) //
console.debug(`Hints: (${i}): `, hints) console.debug(`Hints: (${i}): `, hints)
console.debug(`Errors: (${i}): `, messages) console.debug(`Errors: (${i}): `, messages)
@@ -170,7 +166,7 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
tmpProof.push({ tmpProof.push({
// the command of the line above. Note that `getLineContent` starts counting // the command of the line above. Note that `getLineContent` starts counting
// at `1` instead of `zero`. The first ProofStep will have an empty command. // at `1` instead of `zero`. The first ProofStep will have an empty command.
command: i ? model?.getLineContent(i) : '', command: i ? model.getLineContent(i) : '',
// TODO: store correct data // TODO: store correct data
goals: goals.goals, goals: goals.goals,
// only need the hints of the active goals in chat // only need the hints of the active goals in chat
@@ -182,57 +178,47 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
}) })
// Save the proof to the context // Save the proof to the context
setProof(tmpProof) setProof(tmpProof)
}).catch((error) => {console.debug("promise broken")}) })
}).catch((error) => {console.debug("promise broken")}) })
}, [editor, rpcSess, uri, model]) }, [editor, rpcSess, uri, model])
// Run the command // Run the command
const runCommand = React.useCallback(() => { const runCommand = React.useCallback(() => {
if (processing) {return} if (processing) {return}
if (!uri) {return}
// 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 (commandLineInput) {
setProcessing(true) setProcessing(true)
editor.executeEdits("typewriter", [{ editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions( range: monaco.Selection.fromPositions(
pos, pos,
model.getFullModelRange().getEndPosition() editor.getModel().getFullModelRange().getEndPosition()
), ),
text: typewriterInput.trim() + "\n", text: commandLineInput.trim() + "\n",
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setTypewriterInput('')
} }
editor.setPosition(pos) editor.setPosition(pos)
}, [typewriterInput, editor, model]) }, [commandLineInput, editor])
useEffect(() => { useEffect(() => {
if (oneLineEditor && oneLineEditor.getValue() !== typewriterInput) { if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
oneLineEditor.setValue(typewriterInput) oneLineEditor.setValue(commandLineInput)
} }
}, [typewriterInput]) }, [commandLineInput])
useEffect(() => {
if (proof.length && hasInteractiveErrors(proof[proof.length - 1].errors)) {
setTypewriterInput(proof[proof.length - 1].command)
}
}, [proof])
// React when answer from the server comes back // React when answer from the server comes back
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (!uri) {return}
if (params.uri == uri) { if (params.uri == uri) {
setProcessing(false) setProcessing(false)
loadAllGoals() loadAllGoals()
if (!hasErrors(params.diagnostics)) { if (!hasErrors(params.diagnostics)) {
//setTypewriterInput("") setCommandLineInput("")
editor.setPosition(model.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}`)
@@ -241,11 +227,11 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// TODO: This is the wrong place apparently. Where do wee need to load them? // 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 // TODO: instead of loading all goals every time, we could only load the last one
// loadAllGoals() // loadAllGoals()
}, [uri, editor, model]); }, [uri]);
useEffect(() => { useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, { const myEditor = monaco.editor.create(inputRef.current!, {
value: typewriterInput, value: commandLineInput,
language: "lean4cmd", language: "lean4cmd",
quickSuggestions: false, quickSuggestions: false,
lightbulb: { lightbulb: {
@@ -288,14 +274,14 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// Ensure that our one-line editor can only have a single line // Ensure that our one-line editor can only have a single line
const l = oneLineEditor.getModel().onDidChangeContent((e) => { const l = oneLineEditor.getModel().onDidChangeContent((e) => {
const value = oneLineEditor.getValue() const value = oneLineEditor.getValue()
setTypewriterInput(value) setCommandLineInput(value)
const newValue = value.replace(/[\n\r]/g, '') const newValue = value.replace(/[\n\r]/g, '')
if (value != newValue) { if (value != newValue) {
oneLineEditor.setValue(newValue) oneLineEditor.setValue(newValue)
} }
}) })
return () => { l.dispose() } return () => { l.dispose() }
}, [oneLineEditor, setTypewriterInput]) }, [oneLineEditor, setCommandLineInput])
useEffect(() => { useEffect(() => {
if (!oneLineEditor) return if (!oneLineEditor) return
@@ -311,8 +297,10 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
// BUG: Causes `file closed` error // BUG: Causes `file closed` error
//TODO: Intention is to run once when loading, does that work? //TODO: Intention is to run once when loading, does that work?
useEffect(() => { useEffect(() => {
console.debug(`time to update: ${uri} \n ${rpcSess}`)
console.debug(rpcSess)
loadAllGoals() loadAllGoals()
}, []) }, [rpcSess])
/** Process the entered command */ /** Process the entered command */
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => { const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
@@ -320,10 +308,10 @@ export function Typewriter({hidden, disabled}: {hidden?: boolean, disabled?: boo
runCommand() runCommand()
} }
return <div className={`typewriter${hidden ? ' hidden' : ''}${disabled ? ' disabled' : ''}`}> return <div className={`command-line${hidden ? ' hidden' : ''}`}>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="typewriter-input-wrapper"> <div className="command-line-input-wrapper">
<div ref={inputRef} className="typewriter-input" /> <div ref={inputRef} className="command-line-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} /> Execute
+12 -12
View File
@@ -65,9 +65,13 @@ export const ProofStateContext = React.createContext<{
export const MobileContext = React.createContext<{ export const MobileContext = React.createContext<{
mobile : boolean, mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>, setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
pageNumber: number,
setPageNumber: React.Dispatch<React.SetStateAction<Number>>
}>({ }>({
mobile : false, mobile : false,
setMobile: () => {}, setMobile: () => {},
pageNumber: 0,
setPageNumber: () => {}
}) })
export const WorldLevelIdContext = React.createContext<{ export const WorldLevelIdContext = React.createContext<{
@@ -101,17 +105,13 @@ export const DeletedChatContext = React.createContext<{
}) })
export const InputModeContext = React.createContext<{ export const InputModeContext = React.createContext<{
typewriterMode: boolean, commandLineMode: boolean,
setTypewriterMode: React.Dispatch<React.SetStateAction<boolean>>, setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
typewriterInput: string, commandLineInput: string,
setTypewriterInput: React.Dispatch<React.SetStateAction<string>>, setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
lockInputMode: boolean,
setLockInputMode: React.Dispatch<React.SetStateAction<boolean>>,
}>({ }>({
typewriterMode: true, commandLineMode: true,
setTypewriterMode: () => {}, setCommandLineMode: () => {},
typewriterInput: "", commandLineInput: "",
setTypewriterInput: () => {}, setCommandLineInput: () => {},
lockInputMode: false,
setLockInputMode: () => {},
}); });
+8 -8
View File
@@ -124,7 +124,7 @@ interface GoalProps {
goal: InteractiveGoal goal: InteractiveGoal
filter: GoalFilterState filter: GoalFilterState
showHints?: boolean showHints?: boolean
typewriter: boolean commandLine: boolean
} }
interface ProofDisplayProps { interface ProofDisplayProps {
@@ -136,7 +136,7 @@ interface ProofDisplayProps {
* 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, commandLine } = props
// TODO: Apparently `goal` can be `undefined` // TODO: Apparently `goal` can be `undefined`
if (!goal) {return <></>} if (!goal) {return <></>}
@@ -163,18 +163,18 @@ export const Goal = React.memo((props: GoalProps) => {
// const hints = <Hints hints={goal.hints} key={goal.mvarId} /> // const hints = <Hints hints={goal.hints} key={goal.mvarId} />
const objectHyps = hyps.filter(hyp => !hyp.isAssumption) const objectHyps = hyps.filter(hyp => !hyp.isAssumption)
const assumptionHyps = hyps.filter(hyp => hyp.isAssumption) const assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
const {typewriterMode} = React.useContext(InputModeContext) const {commandLineMode} = React.useContext(InputModeContext)
return <div> return <div>
{/* {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}
{! typewriter && objectHyps.length > 0 && {! commandLine && objectHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Objects:</div> <div className="hyp-group"><div className="hyp-group-title">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 && {!commandLine && assumptionHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div> <div className="hyp-group"><div className="hyp-group-title">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 />} */} {/* {commandLine && commandLineMode && <CommandLine />} */}
{!filter.reverse && goalLi} {!filter.reverse && goalLi}
{/* {showHints && hints} */} {/* {showHints && hints} */}
</div> </div>
@@ -229,7 +229,7 @@ export const OtherGoals = React.memo((props: GoalProps2) => {
<summary> <summary>
<InteractiveCode fmt={goal.type} /> <InteractiveCode fmt={goal.type} />
</summary> </summary>
<Goal typewriter={false} filter={filter} goal={goal} /> <Goal commandLine={false} filter={filter} goal={goal} />
</details>)} </details>)}
</div>} </div>}
</> </>
@@ -264,7 +264,7 @@ export function Goals({ goals, filter }: GoalsProps) {
return <>No goals</> return <>No goals</>
} else { } else {
return <> return <>
{goals.goals.map((g, i) => <Goal typewriter={false} key={i} goal={g} filter={filter} />)} {goals.goals.map((g, i) => <Goal commandLine={false} key={i} goal={g} filter={filter} />)}
</> </>
} }
} }
+2 -2
View File
@@ -129,7 +129,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
</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 commandLine={true} filter={goalFilter} key='mainGoal' goal={goals.goals[0]} showHints={true} />
: <div className="goals-section-title">No Goals</div> : <div className="goals-section-title">No Goals</div>
)} )}
</div> </div>
@@ -156,7 +156,7 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
<div className="goals-section-title">Weitere Goals</div> <div className="goals-section-title">Weitere Goals</div>
{goals.goals.slice(1).map((goal, i) => {goals.goals.slice(1).map((goal, i) =>
<details key={i}><summary><InteractiveCode fmt={goal.type} /></summary> <Goal typewriter={false} filter={goalFilter} goal={goal} /></details>)} <details key={i}><summary><InteractiveCode fmt={goal.type} /></summary> <Goal commandLine={false} filter={goalFilter} goal={goal} /></details>)}
</div>} </div>}
</LocationsContext.Provider> */} </LocationsContext.Provider> */}
</> </>
@@ -43,7 +43,7 @@
font-weight: 500; font-weight: 500;
} }
.typewriter { .command-line {
background: var(--clr-primary); background: var(--clr-primary);
padding: 0.5em; padding: 0.5em;
font-family: var(--ff-primary); font-family: var(--ff-primary);
@@ -51,33 +51,17 @@
/* margin: 0.2em 0 0; */ /* margin: 0.2em 0 0; */
} }
.typewriter.disabled{ .command-line form {
background: #bbb;
}
.typewriter.disabled .btn-inverted,
.typewriter.disabled .mtk1 {
color: #777;
}
.typewriter.disabled .typewriter-input-wrapper,
.typewriter.disabled .monaco-editor,
.typewriter.disabled .monaco-editor-background,
.typewriter.disabled .btn-inverted {
background-color: #eee;
}
.typewriter form {
display: flex; display: flex;
} }
.typewriter button, .undo-button { .command-line button, .undo-button {
display: block; display: block;
white-space: nowrap; white-space: nowrap;
flex: 0; flex: 0;
} }
.typewriter .typewriter-input-wrapper{ .command-line .command-line-input-wrapper{
min-width: 0; min-width: 0;
flex: 1; flex: 1;
padding: 0.4em .6em 0; padding: 0.4em .6em 0;
@@ -87,22 +71,15 @@
flex-direction: column; flex-direction: column;
} }
.typewriter-interface .proof .MuiCircularProgress-root { .command-line .command-line-input {
left: 50%;
position: relative;
margin-left: -20px;
margin-bottom: 0.6em;
}
.typewriter .typewriter-input {
flex: 1; flex: 1;
} }
/* Turn off some monaco decorations: */ /* Turn off some monaco decorations: */
.typewriter-input .monaco-editor .view-overlays .current-line { .command-line-input .monaco-editor .view-overlays .current-line {
border: 0; border: 0;
} }
.typewriter-input .monaco-editor .scroll-decoration { .command-line-input .monaco-editor .scroll-decoration {
box-shadow: none; box-shadow: none;
} }
@@ -161,7 +138,7 @@
/* Push the goals to the bottom for now, until we insert the proof history above. */ /* Push the goals to the bottom for now, until we insert the proof history above. */
.typewriter-interface .content { .commandline-interface .content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -172,7 +149,7 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.typewriter-interface .content .tmp-pusher { .commandline-interface .content .tmp-pusher {
flex: 1; flex: 1;
} }
@@ -180,10 +157,7 @@
background-color: #bbb; background-color: #bbb;
padding: .5em; padding: .5em;
border-radius: .2em; border-radius: .2em;
/* TODO: It seems my browsers merge the margings of the proof steps, margin-bottom: 2rem;
so that it only shows once 0.5rem instead of twice. Thus have 1.5 here now.
*/
margin-bottom: 1.5rem;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
} }
+89 -141
View File
@@ -1,13 +1,12 @@
/* Partly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/main.tsx */ /* Partly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/main.tsx */
import * as React from 'react'; import * as React from 'react';
import {useEffect} from 'react';
import type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location, DocumentUri } from 'vscode-languageserver-protocol'; import type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location, DocumentUri } from 'vscode-languageserver-protocol';
import 'tachyons/css/tachyons.css'; import 'tachyons/css/tachyons.css';
import '@vscode/codicons/dist/codicon.css'; import '@vscode/codicons/dist/codicon.css';
import '../../../../node_modules/lean4-infoview/src/infoview/index.css'; import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
import '../../css/infoview.css' import './infoview.css'
import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api'; import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api';
import { useClientNotificationEffect, useServerNotificationEffect, useEventResult, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util'; import { useClientNotificationEffect, useServerNotificationEffect, useEventResult, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util';
@@ -22,14 +21,14 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { GameIdContext } from '../../app'; import { GameIdContext } from '../../app';
import { useAppDispatch, useAppSelector } from '../../hooks'; import { useAppDispatch, useAppSelector } from '../../hooks';
import { LevelInfo } from '../../state/api'; import { LevelInfo } from '../../state/api';
import { changedInventory, levelCompleted, selectCode, selectCompleted, selectInventory } from '../../state/progress'; import { changedInventory, levelCompleted, selectCompleted, selectInventory } from '../../state/progress';
import Markdown from '../markdown'; 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 } from './goals';
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context'; import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext } from './context';
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter'; import { CommandLine, hasErrors, hasInteractiveErrors } from './command_line';
import { InteractiveDiagnostic } from '@leanprover/infoview/*'; import { InteractiveDiagnostic } from '@leanprover/infoview/*';
import { Button } from '../button'; import { Button } from '../button';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
@@ -42,16 +41,19 @@ import { Hints } from '../hints';
*/ */
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 { commandLineMode } = React.useContext(InputModeContext)
return <> return <>
<div className={typewriterMode ? 'hidden' : ''}> <div className={commandLineMode ? 'hidden' : ''}>
<ExerciseStatement data={level} showLeanStatement={true} /> <ExerciseStatement data={level} />
<div ref={codeviewRef} className={'codeview'}></div> <div ref={codeviewRef} className={'codeview'}></div>
</div> </div>
{ec ? {ec ?
<DualEditorMain worldId={worldId} levelId={levelId} level={level} worldSize={worldSize} /> : <DualEditorMain worldId={worldId} levelId={levelId} level={level} worldSize={worldSize} /> :
// TODO: Style this if relevant. // TODO: Style this if relevant.
<></> <>
<p>Editor is starting up...</p>
<CircularProgress />
</>
} }
</> </>
} }
@@ -60,7 +62,7 @@ export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize })
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 { commandLineMode } = React.useContext(InputModeContext)
// Mark level as completed when server gives notification // Mark level as completed when server gives notification
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -78,11 +80,6 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
...level?.definitions ...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name) ].filter((tile) => tile.new).map((tile) => tile.name)
// Add the proven statement to the local storage as well.
if (level?.statementName != null) {
newTiles.push(level?.statementName)
}
let inv: string[] = selectInventory(gameId)(store.getState()) let inv: string[] = selectInventory(gameId)(store.getState())
// add new items and remove duplicates // add new items and remove duplicates
@@ -111,10 +108,10 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
<WithRpcSessions> <WithRpcSessions>
<WithLspDiagnosticsContext> <WithLspDiagnosticsContext>
<ProgressContext.Provider value={allProgress}> <ProgressContext.Provider value={allProgress}>
{typewriterMode ? {commandLineMode ?
<TypewriterInterfaceWrapper world={worldId} level={levelId} data={level} worldSize={worldSize}/> <CommandLineInterface 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} />
} }
</ProgressContext.Provider> </ProgressContext.Provider>
</WithLspDiagnosticsContext> </WithLspDiagnosticsContext>
@@ -129,45 +126,22 @@ function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: strin
* - Theorem xyz: description * - Theorem xyz: description
* - Theorem xyz * - Theorem xyz
* - Exercises: description * - Exercises: description
*
* If `showLeanStatement` is true, it will additionally display the lean code.
*/ */
function ExerciseStatement({ data, showLeanStatement = false }) { function ExerciseStatement({ data }) {
if (!(data?.descrText || data?.descrFormat)) { return <></> } if (!data?.descrText) { return <></> }
return <> return <div className="exercise-statement"><Markdown>
<div className="exercise-statement"> {(data?.statementName ? `**Theorem** \`${data?.statementName}\`: ` : data?.descrText && "**Exercise**: ") + `${data?.descrText}`}
{data?.descrText && </Markdown></div>
<Markdown>
{(data?.displayName ? `**Theorem** \`${data?.displayName}\`: ` : '') + data?.descrText}
</Markdown>
}
{data?.descrFormat && showLeanStatement &&
<p><code className="lean-code">{data?.descrFormat}</code></p>
}
</div>
</>
} }
// TODO: This is only used in `EditorInterface` // TODO: This is only used in `EditorInterface`
// while `TypewriterInterface` has this copy-pasted in. // while `CommandLineInterface` has this copy-pasted in.
export function Main(props: { world: string, level: number, data: LevelInfo}) { export function Main(props: { world: string, level: number }) {
const ec = React.useContext(EditorContext); const ec = React.useContext(EditorContext);
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level)) const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
console.debug(`template: ${props.data.template}`)
// React.useEffect (() => {
// if (props.data.template) {
// let code: string = selectCode(gameId, worldId, levelId)(store.getState())
// if (!code.length) {
// //models.push(monaco.editor.createModel(code, 'lean4', uri))
// }
// }
// }, [props.data.template])
/* 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;
@@ -229,8 +203,8 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
if (!command) { return <></> } if (!command) { return <></> }
return <div className="command"> return <div className="command">
<div className="command-text">{command}</div> <div className="command-text">{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="Delete this and future commands" onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Retry <FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Delete
</Button> </Button>
</div> </div>
} }
@@ -258,7 +232,7 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// message = diag.message // message = diag.message
// } // }
// const { typewriterMode } = React.useContext(InputModeContext) // const { commandLineMode } = React.useContext(InputModeContext)
// return ( // return (
// // <details open> // // <details open>
@@ -277,7 +251,7 @@ function Command({ command, deleteProof }: { command: string, deleteProof: any }
// // </span> // // </span>
// // </summary> // // </summary>
// <div className={severityClass + ' ml1 message'}> // <div className={severityClass + ' ml1 message'}>
// {!typewriterMode && <p className="mv2">{title}</p>} // {!commandLineMode && <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>
@@ -287,124 +261,66 @@ 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 ahs after the command of this step has been processed */
function GoalsTabs({ proofStep, last, onClick, onGoalChange=(n)=>{}}: { proofStep: ProofStep, last : boolean, onClick? : any, onGoalChange?: (n?: number) => void }) { function GoalsTab({ proofStep, last}: { proofStep: ProofStep, last : boolean }) {
const [selectedGoal, setSelectedGoal] = React.useState<number>(0) const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
return <div className="goal-tabs" onClick={onClick}> if (!proofStep.goals.length) { return <></> }
return <div>
<div className={`tab-bar ${last ? 'current' : ''}`}> <div className={`tab-bar ${last ? 'current' : ''}`}>
{proofStep.goals.map((goal, i) => ( {proofStep.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) => { setSelectedGoal(i); ev.stopPropagation() }}>
{i ? `Goal ${i + 1}` : "Active Goal"} {i ? `Goal ${i + 1}` : "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 commandLine={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} />
</div> </div>
</div> </div>
} }
// Splitting up Typewriter into two parts is a HACK /** The interface in command line mode */
export function TypewriterInterfaceWrapper(props: { world: string, level: number, data: LevelInfo, worldSize: number }) { export function CommandLineInterface(props: { world: string, level: number, data: LevelInfo, worldSize: number }) {
const ec = React.useContext(EditorContext) const ec = React.useContext(EditorContext)
const gameId = React.useContext(GameIdContext)
useClientNotificationEffect(
'textDocument/didClose',
(params: DidCloseTextDocumentParams) => {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.textDocument.uri) {
ec.events.changedCursorLocation.fire(undefined)
}
}, []
)
const serverVersion =
useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
const serverStoppedResult = useEventResult(ec.events.serverStopped);
// NB: the cursor may temporarily become `undefined` when a file is closed. In this case
// it's important not to reconstruct the `WithBlah` wrappers below since they contain state
// that we want to persist.
// Catch loss of internet connection
try {
const editor = React.useContext(MonacoEditorContext) const editor = React.useContext(MonacoEditorContext)
const model = editor.getModel() const model = editor.getModel()
if (!model) { const uri = model.uri.toString()
return <p>no internet?</p> const gameId = React.useContext(GameIdContext)
} const { proof } = React.useContext(ProofContext)
} catch { const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
return <p>no internet??</p> const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
}
if (!serverVersion) { return <></> } const {mobile} = React.useContext(MobileContext)
if (serverStoppedResult) {
return <div>
<p>{serverStoppedResult.message}</p>
<p className="error">{serverStoppedResult.reason}</p>
</div>
}
return <TypewriterInterface props={props} /> const proofPanelRef = React.useRef<HTMLDivElement>(null)
}
/** Delete all proof lines starting from a given line. /** Delete all proof lines starting from a given line.
* Note that the first line (i.e. deleting everything) is `1`! * Note that the first line (i.e. deleting everything) is `1`!
*/ */
function deleteProof(line: number) { function deleteProof(line: number) {
const editor = React.useContext(MonacoEditorContext)
const { proof } = React.useContext(ProofContext)
const { setSelectedStep } = React.useContext(SelectionContext)
const { setDeletedChat, showHelp } = React.useContext(DeletedChatContext)
const { setTypewriterInput } = React.useContext(InputModeContext)
return (ev) => { return (ev) => {
if (editor) {
const model = editor.getModel()
if (model) {
let deletedChat: Array<GameHint> = [] let deletedChat: Array<GameHint> = []
proof.slice(line).map((step, i) => { proof.slice(line).map((step, i) => {
// 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, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))] deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
}) })
setDeletedChat(deletedChat) setDeletedChat(deletedChat)
editor.executeEdits("typewriter", [{
editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions( range: monaco.Selection.fromPositions(
{ lineNumber: line, column: 1 }, { lineNumber: line, column: 1 },
model.getFullModelRange().getEndPosition() editor.getModel().getFullModelRange().getEndPosition()
), ),
text: '', text: '',
forceMoveMarkers: false forceMoveMarkers: false
}]) }])
setSelectedStep(undefined) setSelectedStep(undefined)
setTypewriterInput(proof[line].command)
ev.stopPropagation() ev.stopPropagation()
} }
} }
}
}
/** The interface in command line mode */
export function TypewriterInterface({props}) {
const gameId = React.useContext(GameIdContext)
const editor = React.useContext(MonacoEditorContext)
const [disableInput, setDisableInput] = React.useState<boolean>(false)
const { showHelp, setShowHelp } = React.useContext(DeletedChatContext)
const {mobile} = React.useContext(MobileContext)
const { proof } = React.useContext(ProofContext)
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
// const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
// const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
// rpc session
// editor, model or uri might be null if connection is broken
const rpcSess = useRpcSessionAtPos({uri: editor?.getModel()?.uri?.toString() ?? '', line: 0, character: 0})
function toggleSelectStep(line: number) { function toggleSelectStep(line: number) {
return (ev) => { return (ev) => {
@@ -426,11 +342,6 @@ export function TypewriterInterface({props}) {
} else { } else {
proofPanelRef.current?.scrollTo(0,0) 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]) }, [proof])
// Scroll to element if selection changes // Scroll to element if selection changes
@@ -442,6 +353,37 @@ export function TypewriterInterface({props}) {
} }
}, [selectedStep]) }, [selectedStep])
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
const curUri = useEventResult(ec.events.changedCursorLocation, loc => loc?.uri);
useClientNotificationEffect(
'textDocument/didClose',
(params: DidCloseTextDocumentParams) => {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.textDocument.uri) {
ec.events.changedCursorLocation.fire(undefined)
}
}, []
)
const serverVersion =
useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
const serverStoppedResult = useEventResult(ec.events.serverStopped);
// NB: the cursor may temporarily become `undefined` when a file is closed. In this case
// it's important not to reconstruct the `WithBlah` wrappers below since they contain state
// that we want to persist.
if (!serverVersion) { return <p>Waiting for Lean server to start...</p> }
if (serverStoppedResult) {
return <div>
<p>{serverStoppedResult.message}</p>
<p className="error">{serverStoppedResult.reason}</p>
</div>
}
// TODO: This about hidden hints is all copied from `level.tsx`. Can we move that into `hints.tsx`? // TODO: This about hidden hints is all copied from `level.tsx`. Can we move that into `hints.tsx`?
// If the last step has errors, we want to treat it as if it is part of the second-to-last step // If the last step has errors, we want to treat it as if it is part of the second-to-last step
@@ -475,10 +417,16 @@ export function TypewriterInterface({props}) {
let lastStepErrors = proof.length ? hasInteractiveErrors(proof[proof.length - 1].errors) : false let lastStepErrors = proof.length ? hasInteractiveErrors(proof[proof.length - 1].errors) : false
return <div className="typewriter-interface"> // TODO: does the position matter at all?
const rpcSess = useRpcSessionAtPos({uri: uri, line: 0, character: 0})
return <div className="commandline-interface">
<RpcContext.Provider value={rpcSess}> <RpcContext.Provider value={rpcSess}>
<div className="content"> <div className="content">
<div className="tmp-pusher"> <div className="tmp-pusher">
{!proof.length &&
<CircularProgress />
}
</div> </div>
<div className='proof' ref={proofPanelRef}> <div className='proof' ref={proofPanelRef}>
<ExerciseStatement data={props.data} /> <ExerciseStatement data={props.data} />
@@ -490,12 +438,12 @@ export function TypewriterInterface({props}) {
// entered command as it is still present in the command line. // entered command as it is still present in the command line.
// TODO: Should not use index as key. // TODO: Should not use index as key.
return <div key={`proof-step-${i}`}> return <div key={`proof-step-${i}`}>
<Errors errors={step.errors} typewriterMode={true} /> <Errors errors={step.errors} commandLineMode={true} />
</div> </div>
} else { } 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' : '')} onClick={toggleSelectStep(i)}>
<Command command={step.command} deleteProof={deleteProof(i)} /> <Command command={step.command} deleteProof={deleteProof(i)} />
<Errors errors={step.errors} typewriterMode={true} /> <Errors errors={step.errors} commandLineMode={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>
@@ -512,7 +460,7 @@ export function TypewriterInterface({props}) {
} }
</> </>
} }
<GoalsTabs proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/> <GoalsTab proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} />
{/* 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">
@@ -541,11 +489,11 @@ export function TypewriterInterface({props}) {
} }
</div> </div>
} }
</> : <CircularProgress /> </> : <></>
} }
</div> </div>
</div> </div>
<Typewriter hidden={!withErr && proof[proof.length - 1]?.goals.length == 0} disabled={disableInput || !proof.length}/> <CommandLine hidden={!withErr && proof[proof.length - 1]?.goals.length == 0}/>
</RpcContext.Provider> </RpcContext.Provider>
</div> </div>
} }
+6 -6
View File
@@ -18,7 +18,7 @@ interface MessageViewProps {
} }
/** A list of messages (info/warning/error) that are produced after this command */ /** A list of messages (info/warning/error) that are produced after this command */
function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewriterMode : boolean}) { function Error({error, commandLineMode} : {error : InteractiveDiagnostic, commandLineMode : boolean}) {
// The first step will always have an empty command // The first step will always have an empty command
const severityClass = error.severity ? { const severityClass = error.severity ? {
@@ -41,7 +41,7 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri
} }
return <div className={severityClass + ' ml1 message'}> return <div className={severityClass + ' ml1 message'}>
{!typewriterMode && <p className="mv2">{title}</p>} {!commandLineMode && <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>
@@ -50,9 +50,9 @@ function Error({error, typewriterMode} : {error : InteractiveDiagnostic, typewri
// TODO: Should not use index as key. // TODO: Should not use index as key.
/** A list of messages (info/warning/error) that are produced after this command */ /** A list of messages (info/warning/error) that are produced after this command */
export function Errors ({errors, typewriterMode} : {errors : InteractiveDiagnostic[], typewriterMode : boolean}) { export function Errors ({errors, commandLineMode} : {errors : InteractiveDiagnostic[], commandLineMode : boolean}) {
return <div> return <div>
{errors.map((err, i) => (<Error key={`error-${i}`} error={err} typewriterMode={typewriterMode}/>))} {errors.map((err, i) => (<Error key={`error-${i}`} error={err} commandLineMode={commandLineMode}/>))}
</div> </div>
} }
@@ -79,7 +79,7 @@ const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
message = diag.message message = diag.message
} }
const { typewriterMode } = React.useContext(InputModeContext) const { commandLineMode } = React.useContext(InputModeContext)
return ( return (
// <details open> // <details open>
@@ -98,7 +98,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>} {!commandLineMode && <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>
+7 -24
View File
@@ -1,12 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import '../css/inventory.css' import './inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan } from '@fortawesome/free-solid-svg-icons' import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'; import { GameIdContext } from '../app';
import Markdown from './markdown'; import Markdown from './markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api'; import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
import { QueryStatus } from '@reduxjs/toolkit/query/react'
import { selectDifficulty, selectInventory } from '../state/progress'; 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';
@@ -30,7 +29,7 @@ export function Inventory({levelInfo, openDoc, enableAll=false} :
{levelInfo?.definitions && {levelInfo?.definitions &&
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/> <InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
} }
<h2>Theorems</h2> <h2>Lemmas</h2>
{levelInfo?.lemmas && {levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/> <InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
} }
@@ -115,32 +114,16 @@ function InventoryItem({name, displayName, locked, disabled, newly, showDoc, ena
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div> return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
} }
/** Wrapper to catch rejected/pending queries. */
function DocContent({doc}) {
switch(doc.status) {
case QueryStatus.fulfilled:
return <>
<h1 className="doc">{doc.data.displayName}</h1>
<p><code>{doc.data.statement}</code></p>
{/* <code>docstring: {doc.data.docstring}</code> */}
<Markdown>{doc.data.content}</Markdown>
</>
case QueryStatus.rejected:
return <p>Looks like there is a connection problem!</p>
case QueryStatus.pending:
return <p>Loading...</p>
default:
return <></>
}
}
export function Documentation({name, type, handleClose}) { export function Documentation({name, type, handleClose}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const doc = useLoadDocQuery({game: gameId, type: type, name: name}) const doc = useLoadDocQuery({game: gameId, type: type, name: name})
return <div className="documentation"> return <div className="documentation">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div> <div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<DocContent doc={doc} /> <h1 className="doc">{doc.data?.displayName}</h1>
<p><code>{doc.data?.statement}</code></p>
{/* <code>docstring: {doc.data?.docstring}</code> */}
<Markdown>{doc.data?.content}</Markdown>
</div> </div>
} }
+5 -16
View File
@@ -6,12 +6,12 @@ import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css'; import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css'; import '@fontsource/roboto/700.css';
import '../css/landing_page.css' import './landing_page.css'
import coverRobo from '../assets/covers/formaloversum.png' import coverRobo from '../assets/covers/formaloversum.png'
import bgImage from '../assets/bg.jpg' import bgImage from '../assets/bg.jpg'
import Markdown from './markdown'; import Markdown from './markdown';
import {PrivacyPolicyPopup} from './popup/privacy_policy' import {PrivacyPolicyPopup} from './privacy_policy'
const flag = { const flag = {
'Dutch': '🇳🇱', 'Dutch': '🇳🇱',
@@ -57,7 +57,6 @@ function GameTile({
<div className="long description"><Markdown>{description}</Markdown></div> <div className="long description"><Markdown>{description}</Markdown></div>
</div> </div>
<table className="info"> <table className="info">
<tbody>
<tr> <tr>
<td title="consider playing these games first.">Prerequisites</td> <td title="consider playing these games first.">Prerequisites</td>
<td><Markdown>{prereq}</Markdown></td> <td><Markdown>{prereq}</Markdown></td>
@@ -74,7 +73,6 @@ function GameTile({
<td>Language</td> <td>Language</td>
<td title={`in ${language}`}>{flag[language]}</td> <td title={`in ${language}`}>{flag[language]}</td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
@@ -113,8 +111,8 @@ function LandingPage() {
learning the basics about theorem proving in Lean. learning the basics about theorem proving in Lean.
This is a good first introduction to Lean!" This is a good first introduction to Lean!"
worlds="4" worlds="9"
levels="30" levels="72"
language="English" language="English"
/> />
@@ -130,15 +128,6 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
language="German" language="German"
/> />
<GameTile
title="NNG (OLD)"
gameId="g/hhu-adam/nng4-old"
intro="The old version of the NNG copied from lean3."
description="This version is not maintained and might break at any point. You should play the new version instead"
worlds="9"
levels="72"
language="English"
/>
</div> </div>
<section> <section>
<div className="wrapper"> <div className="wrapper">
@@ -164,7 +153,7 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
a template. a template.
</p> </p>
<p> <p>
There is an option to load and run your own games directly on the server, There is an option to load and run your own games direclty on the server,
instructions are in the NNG repo. Since this is still in development we'd like to instructions are in the NNG repo. Since this is still in development we'd like to
encourage you to contact us for support creating your own game. The documentation is encourage you to contact us for support creating your own game. The documentation is
not polished yet. not polished yet.
@@ -41,20 +41,6 @@
padding-right: .5em; padding-right: .5em;
} }
.exercise-statement {
padding-top: 1em;
padding-bottom: 1em;
}
.exercise-statement p {
margin: 0;
}
.exercise-statement .lean-code {
color: rgba(0, 32, 90, 0.87);
font-size: 12px; /* TODO: is the monaco font-size hardcoded? */
}
.conclusion { .conclusion {
padding: 1em; padding: 1em;
} }
@@ -232,17 +218,17 @@ td code {
height: 100%; height: 100%;
} */ } */
.typewriter-interface { .commandline-interface {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
height: 100%; height: 100%;
} }
.typewriter { .command-line {
flex: 0 1 auto; flex: 0 1 auto;
} }
.typewriter-interface .content { .commandline-interface .content {
flex: 1 1 auto; flex: 1 1 auto;
overflow-y: scroll; overflow-y: scroll;
padding: 0; padding: 0;
@@ -271,7 +257,7 @@ td code {
box-shadow: .0em .0em .4em .1em #8cbbe9; box-shadow: .0em .0em .4em .1em #8cbbe9;
} }
.exercise .step.selected .goal-tabs, .chat .selected { .exercise .step.selected, .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);
} }
@@ -332,11 +318,11 @@ td code {
justify-content: center; justify-content: center;
} }
.typewriter-interface .content, .world-image-container.empty { .commandline-interface .content, .world-image-container.empty {
background-color: #eee; background-color: #eee;
} }
.typewriter-interface .proof { .commandline-interface .proof {
background-color: #fff; background-color: #fff;
} }
+114 -140
View File
@@ -4,7 +4,7 @@ import { useSelector, useStore } from 'react-redux'
import Split from 'react-split' import Split from 'react-split'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHome, faArrowRight } from '@fortawesome/free-solid-svg-icons' import { faBars, faBook, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft, faTerminal } from '@fortawesome/free-solid-svg-icons'
import { CircularProgress } from '@mui/material' import { CircularProgress } from '@mui/material'
import type { Location } from 'vscode-languageserver-protocol' import type { Location } from 'vscode-languageserver-protocol'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
@@ -27,13 +27,13 @@ import { store } from '../state/store'
import { Button } from './button' import { Button } from './button'
import Markdown from './markdown' import Markdown from './markdown'
import {InventoryPanel} from './inventory' import {InventoryPanel} from './inventory'
import { hasInteractiveErrors } from './infoview/typewriter' import { hasInteractiveErrors } from './infoview/command_line'
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context' ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main' import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api' import { GameHint } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints } from './hints' import { DeletedHints, Hint, Hints } from './hints'
import { PrivacyPolicyPopup } from './popup/privacy_policy' import { Impressum } from './privacy_policy'
import '@fontsource/roboto/300.css' import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css' import '@fontsource/roboto/400.css'
@@ -41,7 +41,7 @@ import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css' import '@fontsource/roboto/700.css'
import 'lean4web/client/src/editor/infoview.css' import 'lean4web/client/src/editor/infoview.css'
import 'lean4web/client/src/editor/vscode.css' import 'lean4web/client/src/editor/vscode.css'
import '../css/level.css' import './level.css'
import { LevelAppBar } from './app_bar' import { LevelAppBar } from './app_bar'
function Level() { function Level() {
@@ -50,17 +50,8 @@ function Level() {
const worldId = params.worldId const worldId = params.worldId
// useLoadWorldFiles(worldId) // useLoadWorldFiles(worldId)
const [impressum, setImpressum] = React.useState(false)
const closeImpressum = () => {
setImpressum(false)
}
return <WorldLevelIdContext.Provider value={{worldId, levelId}}> return <WorldLevelIdContext.Provider value={{worldId, levelId}}>
{levelId == 0 ? {levelId == 0 ? <Introduction /> : <PlayableLevel key={`${worldId}/${levelId}`}/>}
<Introduction impressum={impressum} setImpressum={setImpressum} /> :
<PlayableLevel key={`${worldId}/${levelId}`} impressum={impressum} setImpressum={setImpressum} />}
{impressum ? <PrivacyPolicyPopup handleClose={closeImpressum} /> : null}
</WorldLevelIdContext.Provider> </WorldLevelIdContext.Provider>
} }
@@ -136,14 +127,13 @@ function ChatPanel({lastLevel}) {
// // chatRef.current!.scrollTo(0,0) // // chatRef.current!.scrollTo(0,0)
// }, [gameId, worldId, levelId]) // }, [gameId, worldId, levelId])
let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/)
return <div className="chat-panel"> return <div className="chat-panel">
<div ref={chatRef} className="chat"> <div ref={chatRef} className="chat">
{introText?.filter(t => t.trim()).map(((t, i) => {level?.data?.introduction &&
<Hint key={`intro-p-${i}`} <div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelection(0)}>
hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} /> <Markdown>{level?.data?.introduction}</Markdown>
))} </div>
}
{proof.map((step, i) => { {proof.map((step, i) => {
// It the last step has errors, it will have the same hints // It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them. // as the second-to-last step. Therefore we should not display them.
@@ -186,7 +176,7 @@ function ChatPanel({lastLevel}) {
</div> </div>
} }
function ExercisePanel({codeviewRef, visible=true}) { function ExercisePanel({codeviewRef, impressum, closeImpressum, visible=true}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId}) const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
@@ -195,10 +185,11 @@ function ExercisePanel({codeviewRef, visible=true}) {
<div className="exercise"> <div className="exercise">
<DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/> <DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/>
</div> </div>
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
</div> </div>
} }
function PlayableLevel({impressum, setImpressum}) { function PlayableLevel() {
const codeviewRef = useRef<HTMLDivElement>(null) const codeviewRef = useRef<HTMLDivElement>(null)
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext)
@@ -221,14 +212,14 @@ function PlayableLevel({impressum, setImpressum}) {
const [showHelp, setShowHelp] = useState<Set<number>>(new Set()) const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
// Only for mobile layout // Only for mobile layout
const [pageNumber, setPageNumber] = useState(0) const [pageNumber, setPageNumber] = useState(0)
const [typewriterMode, setTypewriterMode] = useState(true) const [commandLineMode, setCommandLineMode] = useState(true)
// set to true to prevent switching between typewriter and editor const [commandLineInput, setCommandLineInput] = useState("")
const [lockInputMode, setLockInputMode] = useState(false)
const [typewriterInput, setTypewriterInput] = useState("")
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId] const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
// impressum pop-up // impressum pop-up
const [impressum, setImpressum] = React.useState(false)
function closeImpressum() {setImpressum(false)}
function toggleImpressum() {setImpressum(!impressum)} function toggleImpressum() {setImpressum(!impressum)}
// When clicking on an inventory item, the inventory is overlayed by the item's doc. // When clicking on an inventory item, the inventory is overlayed by the item's doc.
@@ -237,6 +228,8 @@ function PlayableLevel({impressum, setImpressum}) {
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
function closeInventoryDoc () {setInventoryDoc(null)} function closeInventoryDoc () {setInventoryDoc(null)}
const onDidChangeContent = (code) => { const onDidChangeContent = (code) => {
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code})) dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
} }
@@ -252,30 +245,30 @@ function PlayableLevel({impressum, setImpressum}) {
const {editor, infoProvider, editorConnection} = const {editor, infoProvider, editorConnection} =
useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
// /** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside /** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside
// * `TypewriterInterface`. * `CommandLineInterface`.
// */ */
// const handleUndo = () => { const handleUndo = () => {
// const endPos = editor.getModel().getFullModelRange().getEndPosition() const endPos = editor.getModel().getFullModelRange().getEndPosition()
// let range let range
// console.log(endPos.column) console.log(endPos.column)
// if (endPos.column === 1) { if (endPos.column === 1) {
// range = monaco.Selection.fromPositions( range = monaco.Selection.fromPositions(
// new monaco.Position(endPos.lineNumber - 1, 1), new monaco.Position(endPos.lineNumber - 1, 1),
// endPos endPos
// ) )
// } else { } else {
// range = monaco.Selection.fromPositions( range = monaco.Selection.fromPositions(
// new monaco.Position(endPos.lineNumber, 1), new monaco.Position(endPos.lineNumber, 1),
// endPos endPos
// ) )
// } }
// editor.executeEdits("undo-button", [{ editor.executeEdits("undo-button", [{
// range, range,
// text: "", text: "",
// forceMoveMarkers: false forceMoveMarkers: false
// }]); }]);
// } }
// Select and highlight proof steps and corresponding hints // Select and highlight proof steps and corresponding hints
// TODO: with the new design, there is no difference between the introduction and // TODO: with the new design, there is no difference between the introduction and
@@ -285,100 +278,29 @@ function PlayableLevel({impressum, setImpressum}) {
// if the user inventory changes, notify the server // if the user inventory changes, notify the server
useEffect(() => { useEffect(() => {
let leanClient = connection.getLeanClient(gameId) let leanClient = connection.getLeanClient(gameId)
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, difficulty: difficulty}) leanClient.sendNotification('$/game/setInventory', {inventory: inventory, checkEnabled: difficulty > 0})
}, [inventory]) }, [inventory])
useEffect (() => {
// Lock editor mode
if (level?.data?.template) {
setTypewriterMode(false)
if (editor) {
let model = editor.getModel()
if (model) {
let code = model.getLinesContent()
// console.log(`insert. code: ${code}`)
// console.log(`insert. join: ${code.join('')}`)
// console.log(`insert. trim: ${code.join('').trim()}`)
// console.log(`insert. length: ${code.join('').trim().length}`)
// console.log(`insert. range: ${editor.getModel().getFullModelRange()}`)
// TODO: It does seem that the template is always indented by spaces.
// This is a hack, assuming there are exactly two.
if (!code.join('').trim().length) {
console.debug(`inserting template:\n${level.data.template}`)
// TODO: This does not work! HERE
// Probably overwritten by a query to the server
editor.executeEdits("template-writer", [{
range: model.getFullModelRange(),
text: level.data.template + `\n`,
forceMoveMarkers: true
}])
} else {
console.debug(`not inserting template.`)
}
}
}
} else {
setTypewriterMode(true)
}
}, [level, levelId, worldId, gameId, editor])
useEffect(() => { useEffect(() => {
// TODO: That's a problem if the saved proof contains an error // TODO: That's a problem if the saved proof contains an error
// Reset command line input when loading a new level // Reset command line input when loading a new level
setTypewriterInput("") setCommandLineInput("")
// Load the selected help steps from the store // Load the selected help steps from the store
setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState()))) setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState())))
}, [gameId, worldId, levelId]) }, [gameId, worldId, levelId])
// switching editor mode
useEffect(() => { useEffect(() => {
if (editor) { if (!commandLineMode) {
let model = editor.getModel() // Delete last input attempt from command line
if (model) { editor.executeEdits("command-line", [{
if (typewriterMode) {
// typewriter gets enabled
let code = model.getLinesContent().filter(line => line.trim())
editor.executeEdits("typewriter", [{
range: model.getFullModelRange(),
text: code.length ? code.join('\n') + '\n' : '',
forceMoveMarkers: true
}])
// let endPos = model.getFullModelRange().getEndPosition()
// if (model.getLineContent(endPos.lineNumber).trim() !== "") {
// editor.executeEdits("typewriter", [{
// range: monaco.Selection.fromPositions(endPos, endPos),
// text: "\n",
// forceMoveMarkers: true
// }]);
// }
// let endPos = model.getFullModelRange().getEndPosition()
// let currPos = editor.getPosition()
// if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
// // This is not a position that would naturally occur from Typewriter, reset:
// editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
// }
} else {
// typewriter gets disabled
// delete last input attempt from command line
editor.executeEdits("typewriter", [{
range: editor.getSelection(), range: editor.getSelection(),
text: "", text: "",
forceMoveMarkers: false forceMoveMarkers: false
}]); }]);
editor.focus() editor.focus()
} }
} }, [commandLineMode])
}
}, [editor, typewriterMode])
useEffect(() => { useEffect(() => {
// Forget whether hidden hints are displayed for steps that don't exist yet // Forget whether hidden hints are displayed for steps that don't exist yet
@@ -396,25 +318,48 @@ function PlayableLevel({impressum, setImpressum}) {
} }
}, [showHelp]) }, [showHelp])
// Effect when command line mode gets enabled
useEffect(() => {
if (editor && commandLineMode) {
let endPos = editor.getModel().getFullModelRange().getEndPosition()
if (editor.getModel().getLineContent(endPos.lineNumber).trim() !== "") {
editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions(endPos, endPos),
text: "\n",
forceMoveMarkers: true
}]);
}
endPos = editor.getModel().getFullModelRange().getEndPosition()
let currPos = editor.getPosition()
if (currPos.column != 1 || (currPos.lineNumber != endPos.lineNumber && currPos.lineNumber != endPos.lineNumber - 1)) {
// This is not a position that would naturally occur from CommandLine, reset:
editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
}
}
}, [editor, commandLineMode])
return <> return <>
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div> <div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
<DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}> <DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}>
<SelectionContext.Provider value={{selectedStep, setSelectedStep}}> <SelectionContext.Provider value={{selectedStep, setSelectedStep}}>
<InputModeContext.Provider value={{typewriterMode, setTypewriterMode, typewriterInput, setTypewriterInput, lockInputMode, setLockInputMode}}> <InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
<ProofContext.Provider value={{proof, setProof}}> <ProofContext.Provider value={{proof, setProof}}>
<EditorContext.Provider value={editorConnection}> <EditorContext.Provider value={editorConnection}>
<MonacoEditorContext.Provider value={editor}> <MonacoEditorContext.Provider value={editor}>
<LevelAppBar <LevelAppBar
pageNumber={pageNumber} setPageNumber={setPageNumber}
isLoading={level.isLoading} isLoading={level.isLoading}
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` + levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
(level?.data?.title && ` : ${level?.data?.title}`)} (level?.data?.title && ` : ${level?.data?.title}`)}
toggleImpressum={toggleImpressum} /> impressum={impressum}
toggleImpressum={toggleImpressum}
pageNumber={pageNumber} setPageNumber={setPageNumber} />
{mobile? {mobile?
// TODO: This is copied from the `Split` component below... // TODO: This is copied from the `Split` component below...
<> <>
<div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}> <div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
<ExercisePanel <ExercisePanel
impressum={impressum}
closeImpressum={closeImpressum}
codeviewRef={codeviewRef} codeviewRef={codeviewRef}
visible={pageNumber == 0} /> visible={pageNumber == 0} />
<InventoryPanel levelInfo={level?.data} visible={pageNumber == 1} /> <InventoryPanel levelInfo={level?.data} visible={pageNumber == 1} />
@@ -424,6 +369,8 @@ function PlayableLevel({impressum, setImpressum}) {
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}> <Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
<ChatPanel lastLevel={lastLevel}/> <ChatPanel lastLevel={lastLevel}/>
<ExercisePanel <ExercisePanel
impressum={impressum}
closeImpressum={closeImpressum}
codeviewRef={codeviewRef} /> codeviewRef={codeviewRef} />
<InventoryPanel levelInfo={level?.data} /> <InventoryPanel levelInfo={level?.data} />
</Split> </Split>
@@ -437,7 +384,7 @@ function PlayableLevel({impressum, setImpressum}) {
</> </>
} }
function IntroductionPanel({gameInfo}) { function IntroductionPanel({gameInfo, impressum, closeImpressum}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId} = useContext(WorldLevelIdContext) const {worldId} = useContext(WorldLevelIdContext)
@@ -449,6 +396,7 @@ function IntroductionPanel({gameInfo}) {
<Hint key={`intro-p-${i}`} <Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}} step={0} selected={null} toggleSelection={undefined} /> hint={{text: t, hidden: false}} step={0} selected={null} toggleSelection={undefined} />
))} ))}
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
</div> </div>
<div className="button-row"> <div className="button-row">
{gameInfo.data?.worldSize[worldId] == 0 ? {gameInfo.data?.worldSize[worldId] == 0 ?
@@ -464,7 +412,7 @@ function IntroductionPanel({gameInfo}) {
export default Level export default Level
/** The site with the introduction text of a world */ /** The site with the introduction text of a world */
function Introduction({impressum, setImpressum}) { function Introduction() {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {mobile} = useContext(MobileContext) const {mobile} = useContext(MobileContext)
@@ -472,26 +420,56 @@ function Introduction({impressum, setImpressum}) {
const gameInfo = useGetGameInfoQuery({game: gameId}) const gameInfo = useGetGameInfoQuery({game: gameId})
const [impressum, setImpressum] = React.useState(false)
const closeImpressum = () => {
setImpressum(false)
}
const toggleImpressum = () => { const toggleImpressum = () => {
setImpressum(!impressum) setImpressum(!impressum)
} }
return <> return <>
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/> <LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" impressum={impressum} toggleImpressum={toggleImpressum}/>
{gameInfo.isLoading ? {gameInfo.isLoading ?
<div className="app-content loading"><CircularProgress /></div> <div className="app-content loading"><CircularProgress /></div>
: mobile ? : mobile ?
<IntroductionPanel gameInfo={gameInfo} /> <IntroductionPanel gameInfo={gameInfo} impressum={impressum} closeImpressum={closeImpressum} />
: :
<Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}> <Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level`}>
<IntroductionPanel gameInfo={gameInfo} /> <IntroductionPanel gameInfo={gameInfo} impressum={impressum} closeImpressum={closeImpressum} />
<div className="world-image-container empty"></div> <div className="world-image-container empty"></div>
<InventoryPanel levelInfo={inventory?.data} /> <InventoryPanel levelInfo={inventory?.data} />
</Split> </Split>
} }
</> </>
} }
// {mobile?
// // TODO: This is copied from the `Split` component below...
// <>
// <div className={`app-content level-mobile ${level.isLoading ? 'hidden' : ''}`}>
// <ExercisePanel
// impressum={impressum}
// closeImpressum={closeImpressum}
// codeviewRef={codeviewRef}
// visible={pageNumber == 0} />
// <InventoryPanel levelInfo={level?.data} visible={pageNumber == 1} />
// </div>
// </>
// :
// <Split minSize={0} snapOffset={200} sizes={[25, 50, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
// <ChatPanel lastLevel={lastLevel}/>
// <ExercisePanel
// impressum={impressum}
// closeImpressum={closeImpressum}
// codeviewRef={codeviewRef} />
// <InventoryPanel levelInfo={level?.data} />
// </Split>
// }
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) { function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
const connection = React.useContext(ConnectionContext) const connection = React.useContext(ConnectionContext)
@@ -586,7 +564,6 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
if (!model) { if (!model) {
model = monaco.editor.createModel(initialCode, 'lean4', uri) model = monaco.editor.createModel(initialCode, 'lean4', uri)
} }
if (model) { // in case of broken pipe, this remains null
model.onDidChangeContent(() => onDidChangeContent(model.getValue())) model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections())) editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
editor.setModel(model) editor.setModel(model)
@@ -600,7 +577,6 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
editorConnection.api.sendClientNotification(uriStr, "textDocument/didClose", {textDocument: {uri: uriStr}}) editorConnection.api.sendClientNotification(uriStr, "textDocument/didClose", {textDocument: {uri: uriStr}})
model.dispose(); } model.dispose(); }
} }
}
}, [editor, levelId, connection, leanClientStarted]) }, [editor, levelId, connection, leanClientStarted])
@@ -608,7 +584,6 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
if (editor && leanClientStarted) { if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri) let model = monaco.editor.getModel(uri)
if (model) {
infoviewApi.serverRestarted(leanClient.initializeResult) infoviewApi.serverRestarted(leanClient.initializeResult)
infoProvider.openPreview(editor, infoviewApi) infoProvider.openPreview(editor, infoviewApi)
@@ -618,7 +593,6 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); } return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
} }
}
}, [editor, connection, leanClientStarted]) }, [editor, connection, leanClientStarted])
return {editor, infoProvider, editorConnection} return {editor, infoProvider, editorConnection}
@@ -643,7 +617,7 @@ function useLoadWorldFiles(worldId) {
models.push(monaco.editor.createModel(code, 'lean4', uri)) models.push(monaco.editor.createModel(code, 'lean4', uri))
} }
} }
return () => { for (let model of models) { try {model.dispose()} catch {console.log(`failed to dispose model ${model}`)}} } return () => { for (let model of models) { model.dispose() } }
} }
}, [gameInfo.data, worldId]) }, [gameInfo.data, worldId])
} }
-59
View File
@@ -1,59 +0,0 @@
/**
* @fileOverview
*/
import * as React from 'react'
import { useSelector } from 'react-redux'
import { GameIdContext } from '../../app'
import { useAppDispatch } from '../../hooks'
import { deleteProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree'
import { Button } from '../button'
/** download the current progress (i.e. what's saved in the browser store) */
export function downloadProgress(gameId: string, gameProgress: any, ev: React.MouseEvent) {
ev.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
/** Pop-up to delete game progress.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function ErasePopup ({handleClose}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch()
const eraseProgress = () => {
dispatch(deleteProgress({game: gameId}))
handleClose()
}
const downloadAndErase = (ev) => {
downloadProgress(gameId, gameProgress, ev)
eraseProgress()
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<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>
(This deletes your proofs and your collected inventory.
Saves from other games are not deleted.)
</p>
<Button onClick={eraseProgress} to="">Delete</Button>
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
<Button onClick={handleClose} to="">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>
}
@@ -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>
}
-70
View File
@@ -1,70 +0,0 @@
/**
* @fileOverview
*/
import * as React from 'react'
import { useSelector } from 'react-redux'
import { GameIdContext } from '../../app'
import { useAppDispatch } from '../../hooks'
import { GameProgressState, loadProgress, selectProgress } from '../../state/progress'
import { downloadFile } from '../world_tree'
import { Button } from '../button'
/** 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 UploadPopup ({handleClose}) {
const [file, setFile] = React.useState<File>();
const gameId = React.useContext(GameIdContext)
const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch()
const handleFileChange = (e) => {
if (e.target.files) {
setFile(e.target.files[0])
}
}
/** Upload progress from a */
const uploadProgress = (e) => {
if (!file) {return}
const fileReader = new FileReader()
fileReader.readAsText(file, "UTF-8")
fileReader.onload = (e) => {
const data = JSON.parse(e.target.result.toString()) as GameProgressState
console.debug("Json Data", data)
dispatch(loadProgress({game: gameId, data: data}))
}
handleClose()
}
/** Download the current progress (i.e. what's saved in the browser store) */
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<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><b>Warning:</b> This will delete your current game progress!
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
<p>
<input type="file" onChange={handleFileChange}/>
</p>
<Button to="" onClick={uploadProgress}>Load selected file</Button>
</div>
</div>
}
@@ -11,7 +11,7 @@ import * as React from 'react'
* controlled by the containing element. * controlled by the containing element.
*/ */
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) { export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
return <div className="privacy-policy modal-wrapper"> return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} /> <div className="modal-backdrop" onClick={handleClose} />
<div className="modal"> <div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div> <div className="codicon codicon-close modal-close" onClick={handleClose}></div>
@@ -31,7 +31,7 @@ export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
<p>Our server is located in Germany.</p> <p>Our server is located in Germany.</p>
<p> <p>
<strong>Contact information:</strong><br /> <strong>Contact information:</strong><br />
Alexander Bentkamp, Jon Eugster<br /> 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 />
@@ -39,21 +39,6 @@ export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
+49 211 81-12173<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> <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> </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>
</div> </div>
} }
@@ -73,3 +58,49 @@ export const PrivacyPolicy: React.FC = () => {
</> </>
) )
} }
export function Impressum({handleClose}) {
return <div className="impressum">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Funding</h2>
<p>
This Lean 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>
<h2>Development</h2>
<p>
The source code 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>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 anonymised.
</p>
<p>
We do not use cookies, but your game progress is stored in the browser storage
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 />
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>
</div>
}
+152 -44
View File
@@ -1,32 +1,29 @@
import * as React from 'react' import * as React from 'react'
import { useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import Split from 'react-split' import Split from 'react-split'
import { Box, CircularProgress } from '@mui/material' import { Box, Typography, CircularProgress } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faArrowRight } from '@fortawesome/free-solid-svg-icons' import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app' import { GameIdContext } from '../app'
import { useAppDispatch, useAppSelector } from '../hooks' import { useAppDispatch } from '../hooks'
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress' import { GameProgressState, changedOpenedIntro, deleteProgress, loadProgress, selectOpenedIntro, selectProgress } from '../state/progress'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api' import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'
import { Button } from './button' import { Button } from './button'
import { MobileContext } from './infoview/context' import { MobileContext } from './infoview/context'
import { InventoryPanel } from './inventory' import { InventoryPanel } from './inventory'
import { ErasePopup } from './popup/erase' import Markdown from './markdown'
import { InfoPopup } from './popup/game_info' import {PrivacyPolicyPopup} from './privacy_policy'
import { PrivacyPolicyPopup } from './popup/privacy_policy' import { WorldTreePanel, downloadFile } from './world_tree'
import { RulesHelpPopup } from './popup/rules_help'
import { UploadPopup } from './popup/upload'
import { WorldTreePanel } from './world_tree'
import '../css/welcome.css' import './welcome.css'
import { WelcomeAppBar } from './app_bar' import { WelcomeAppBar } from './app_bar'
import { Hint } from './hints' import { Hint } from './hints'
/** The panel showing the game's introduction text */
/** the panel showing the game's introduction text */ function IntroductionPanel({introduction}: {introduction: string}) {
function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) { const {mobile, setPageNumber} = React.useContext(MobileContext)
const {mobile} = React.useContext(MobileContext)
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -46,6 +43,11 @@ function IntroductionPanel({introduction, setPageNumber}: {introduction: string,
: <></> : <></>
))} ))}
</div> </div>
{/* <Typography variant="body1" component="div" className="welcome-text">
<h1>{title}</h1>
<Markdown>{introduction}</Markdown>
</Typography>
*/}
{mobile && {mobile &&
<div className="button-row"> <div className="button-row">
<Button className="btn" to="" <Button className="btn" to=""
@@ -60,32 +62,142 @@ function IntroductionPanel({introduction, setPageNumber}: {introduction: string,
</div> </div>
} }
/** main page of the game showing among others the tree of worlds/levels */ 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>
}
function ErasePopup ({handleClose}) {
const gameId = React.useContext(GameIdContext)
const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch()
/** Download the current progress (i.e. what's saved in the browser store) */
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
const eraseProgress = () => {
dispatch(deleteProgress({game: gameId}))
handleClose()
}
const downloadAndErase = (e) => {
downloadProgress(e)
eraseProgress()
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<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>
(This deletes your proofs and your collected inventory.
Saves from other games are not deleted.)
</p>
<Button onClick={eraseProgress} to="">Delete</Button>
<Button onClick={downloadAndErase} to="">Download & Delete</Button>
<Button onClick={handleClose} to="">Cancel</Button>
</div>
</div>
}
function UploadPopup ({handleClose}) {
const [file, setFile] = React.useState<File>();
const gameId = React.useContext(GameIdContext)
const gameProgress = useSelector(selectProgress(gameId))
const dispatch = useAppDispatch()
const handleFileChange = (e) => {
if (e.target.files) {
setFile(e.target.files[0])
}
}
/** Upload progress from a */
const uploadProgress = (e) => {
if (!file) {return}
const fileReader = new FileReader()
fileReader.readAsText(file, "UTF-8")
fileReader.onload = (e) => {
const data = JSON.parse(e.target.result.toString()) as GameProgressState
console.debug("Json Data", data)
dispatch(loadProgress({game: gameId, data: data}))
}
handleClose()
}
/** Download the current progress (i.e. what's saved in the browser store) */
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress, null, 2),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
}
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<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><b>Warning:</b> This will delete your current game progress!
Consider <a className="download-link" onClick={downloadProgress} >downloading your current progress</a> first!</p>
<input type="file" onChange={handleFileChange}/>
<Button to="" onClick={uploadProgress}>Load selected file</Button>
</div>
</div>
}
/** main page of the game showing amoung others the tree of worlds/levels */
function Welcome() { function Welcome() {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {mobile} = React.useContext(MobileContext) const {mobile, pageNumber, setPageNumber} = React.useContext(MobileContext)
const gameInfo = useGetGameInfoQuery({game: gameId}) const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId}) const inventory = useLoadInventoryOverviewQuery({game: gameId})
// For mobile only // impressum pop-up
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 [impressum, setImpressum] = React.useState(false)
const [info, setInfo] = React.useState(false)
const [rulesHelp, setRulesHelp] = React.useState(false)
const [uploadMenu, setUploadMenu] = React.useState(false)
function closeEraseMenu() {setEraseMenu(false)}
function closeImpressum() {setImpressum(false)} function closeImpressum() {setImpressum(false)}
function closeInfo() {setInfo(false)}
function closeRulesHelp() {setRulesHelp(false)}
function closeUploadMenu() {setUploadMenu(false)}
function toggleEraseMenu() {setEraseMenu(!eraseMenu)}
function toggleImpressum() {setImpressum(!impressum)} function toggleImpressum() {setImpressum(!impressum)}
function toggleInfo() {setInfo(!info)}
function toggleUploadMenu() {setUploadMenu(!uploadMenu)} const [info, setInfo] = React.useState(false)
function closeInfo() {setInfo(false)}
function toggleInfo() {setInfo(!impressum)}
/* state variables to toggle the pop-up menus */
const [eraseMenu, setEraseMenu] = React.useState(false);
const openEraseMenu = () => setEraseMenu(true);
const closeEraseMenu = () => setEraseMenu(false);
const [uploadMenu, setUploadMenu] = React.useState(false);
const openUploadMenu = () => setUploadMenu(true);
const closeUploadMenu = () => setUploadMenu(false);
// set the window title // set the window title
useEffect(() => { useEffect(() => {
@@ -99,32 +211,28 @@ function Welcome() {
<CircularProgress /> <CircularProgress />
</Box> </Box>
: <> : <>
<WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum} <WelcomeAppBar gameInfo={gameInfo.data} toggleImpressum={toggleImpressum} openEraseMenu={openEraseMenu}
toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu} openUploadMenu={openUploadMenu} toggleInfo={toggleInfo} />
toggleInfo={toggleInfo} />
<div className="app-content"> <div className="app-content">
{ mobile ? { mobile ?
<div className="welcome mobile"> <div className="welcome mobile">
{(pageNumber == 0 ? {(pageNumber == 0 ?
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} /> <IntroductionPanel introduction={gameInfo.data?.introduction} />
: pageNumber == 1 ? : pageNumber == 1 ?
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
: :
<InventoryPanel levelInfo={inventory?.data} /> <InventoryPanel levelInfo={inventory?.data} />
)} )}
</div> </div>
: :
<Split className="welcome" minSize={0} snapOffset={200} sizes={[25, 50, 25]}> <Split className="welcome" minSize={0} snapOffset={200} sizes={[25, 50, 25]}>
<IntroductionPanel introduction={gameInfo.data?.introduction} setPageNumber={setPageNumber} /> <IntroductionPanel introduction={gameInfo.data?.introduction} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} <WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} />
<InventoryPanel levelInfo={inventory?.data} /> <InventoryPanel levelInfo={inventory?.data} />
</Split> </Split>
} }
</div> </div>
{impressum ? <PrivacyPolicyPopup handleClose={closeImpressum} /> : null} {impressum ? <PrivacyPolicyPopup handleClose={closeImpressum} /> : null}
{rulesHelp ? <RulesHelpPopup handleClose={closeRulesHelp} /> : null}
{eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null} {eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null}
{uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null} {uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null}
{info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null} {info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null}
@@ -49,6 +49,7 @@ svg .disabled {
} }
.world-selection-menu { .world-selection-menu {
padding: .5em 1em;
position: absolute; position: absolute;
right: 1em; right: 1em;
top: 1em; top: 1em;
@@ -74,7 +75,7 @@ svg .disabled {
/* width: 100%; */ /* width: 100%; */
height: 7em; height: 7em;
/* min-width: 16em; */ /* min-width: 16em; */
padding: .5em 1em 1.3em 1em; padding-bottom: .8em;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -108,10 +109,3 @@ svg .disabled {
padding: .2em; padding: .2em;
border-radius: .5em; border-radius: .5em;
} }
.world-selection-menu .helpButton {
position: absolute;
right: .5em;
top: .5em;
color: var(--clr-primary);
}
+21 -33
View File
@@ -8,14 +8,16 @@ 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons' import { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app' import { GameIdContext } from '../app'
import { useAppDispatch } from '../hooks' import { useAppDispatch } from '../hooks'
import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress' import { deleteProgress, selectProgress, loadProgress, GameProgressState,
selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
import { store } from '../state/store' import { store } from '../state/store'
import { Button } from './button'
import '../css/world_tree.css' import './world_tree.css'
// Settings for the world tree // Settings for the world tree
cytoscape.use( klay ) cytoscape.use( klay )
@@ -26,11 +28,8 @@ const lineWidth = 10 //
const ds = .75 // scale the resulting svg image const ds = .75 // scale the resulting svg image
const NMIN = 5 // min. worldsize const NMIN = 5 // min. worldsize
const NLABEL = 8 // max. world size to display label below the world const NLABEL = 9 // max. world size to display label below the world
const NMAX = 16 // max. world size. Level icons start spiraling out if the world has more levels. const NMAX = 16 // max. world size. Level icons start spiraling out if the world has more levels.
const NSPIRAL = 12 // world size if NMAX has been passed and need to spiral.
const MINFONT = 12
// colours // colours
const grey = '#999' const grey = '#999'
@@ -55,9 +54,8 @@ export function LevelIcon({ world, level, position, completed, unlocked, worldSi
const N = Math.max(worldSize, NMIN) const N = Math.max(worldSize, NMIN)
// divide circle into `N+2` equal pieces. // divide circle into `N+2` equal pieces
// only for non-spiraling case const beta = 2 * Math.PI / Math.min(N+2, (NMAX+1))
const beta = 2 * Math.PI / Math.min(N+2, ((N < (NMAX+1) ? NMAX : NSPIRAL)+1))
// We want distance between two level icons to be `2.2*r`, therefore: // We want distance between two level icons to be `2.2*r`, therefore:
// Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2) // Sinus-Satz: (1.1*r) / sin(β/2) = R / sin(π/2)
@@ -72,19 +70,19 @@ export function LevelIcon({ world, level, position, completed, unlocked, worldSi
* works fine in tests up to `N=30`. * works fine in tests up to `N=30`.
*/ */
function betaSpiral(level) { function betaSpiral(level) {
return 2 * Math.PI / ((NSPIRAL+1) + 2 * Math.max(0, (level-2)) / (NSPIRAL+1)) return 2 * Math.PI / ((NMAX+1) + Math.max(0, (level-2)) / (NMAX+1))
} }
const x = N < (NMAX+1) ? const x = N < (NMAX+1) ?
// normal case // normal case
s * position.x + Math.sin(level * beta) * R : s * position.x + Math.sin(level * beta) * R :
// spiraling case // spiraling case
s * position.x + Math.sin(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NSPIRAL+1)) s * position.x + Math.sin(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NMAX+1))
const y = N < (NMAX+1) ? const y = N < (NMAX+1) ?
// normal case // normal case
s * position.y - Math.cos(level * beta) * R : s * position.y - Math.cos(level * beta) * R :
// spiraling case // spiraling case
s * position.y - Math.cos(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NSPIRAL+1)) s * position.y - Math.cos(level * betaSpiral(level)) * (R + 2*r*(level-1)/(NMAX+1))
return ( return (
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level == 1 ? 0 : level}`} <Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level == 1 ? 0 : level}`}
@@ -114,11 +112,9 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
// 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)
const betaHalf = Math.PI / Math.min(N+2, ((N < (NMAX+1) ? NMAX : NSPIRAL) + 1)) const betaHalf = Math.PI / Math.min(N+2, (NMAX + 1))
let R = 1.1 * r / Math.sin(betaHalf) - 1.2 * r let R = 1.1 * r / Math.sin(betaHalf) - 1.2 * r
let fontSize = Math.floor(R/4)
// Offset for the labels for small worlds // Offset for the labels for small worlds
let labelOffset = R + 2.5 * r let labelOffset = R + 2.5 * r
@@ -141,14 +137,12 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
className={playable ? '' : 'disabled'}> className={playable ? '' : 'disabled'}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R} <circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill={completed ? green : unlocked ? blue : grey}/> fill={completed ? green : unlocked ? blue : grey}/>
{ false ? // fontSize >= MINFONT ? {worldSize > NLABEL ?
// NOTE: This code would display the world names inside the bubble, but currently
// it isn't used.
// Label for large worlds // Label for large worlds
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y} <foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}
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: Math.floor(R/4) + "px"}}>
{title ? title : world} {title ? title : world}
</p> </p>
</div> </div>
@@ -159,7 +153,7 @@ export function WorldIcon({world, title, position, completedLevels, difficulty,
width='150px' height='2em' style={{overflow: 'visible'}} width='150px' height='2em' style={{overflow: 'visible'}}
> >
<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'>
{title ? title : world} {title ? title : world}
</p> </p>
</div> </div>
@@ -193,7 +187,7 @@ export const downloadFile = ({ data, fileName, fileType } :
} }
/** The menu that is shown next to the world selection graph */ /** The menu that is shown next to the world selection graph */
export function WorldSelectionMenu({rulesHelp, setRulesHelp}) { export function WorldSelectionMenu() {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -202,15 +196,12 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular' return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular'
} }
return <nav className="world-selection-menu"> return <nav className="world-selection-menu">
<div className="slider-wrap"> <div className="slider-wrap">
<span className="difficulty-label">Rules <span className="difficulty-label">Game Rules</span>
<FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/>
</span>
<Slider <Slider
orientation="vertical" orientation="vertical"
title="Game Rules" title="Game Rules:&#10;- regular: 🔐 levels, 🔐 tactics&#10;- relaxed: 🔓 levels, 🔐 tactics&#10;- none: 🔓 levels, 🔓 tactics"
min={0} max={2} min={0} max={2}
aria-label="Game Rules" aria-label="Game Rules"
defaultValue={difficulty} defaultValue={difficulty}
@@ -265,12 +256,9 @@ export function computeWorldLayout(worlds) {
} }
export function WorldTreePanel({worlds, worldSize, rulesHelp, setRulesHelp}: export function WorldTreePanel({worlds, worldSize}:
{ worlds: any, { worlds: any,
worldSize: any, worldSize: any}) {
rulesHelp: boolean,
setRulesHelp: any,
}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId)) const difficulty = useSelector(selectDifficulty(gameId))
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []} const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []}
@@ -354,7 +342,7 @@ 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="column">
<WorldSelectionMenu rulesHelp={rulesHelp} setRulesHelp={setRulesHelp} /> <WorldSelectionMenu />
<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}` : ''}
-1
View File
@@ -36,7 +36,6 @@ export interface LevelInfo {
descrFormat: null|string, descrFormat: null|string,
lemmaTab: null|string, lemmaTab: null|string,
statementName: null|string, statementName: null|string,
displayName: null|string,
template: null|string template: null|string
} }
-255
View File
@@ -1,255 +0,0 @@
# Creating a new game
This tutorial walks you through creating a new game for lean4. It covers from writing new levels, over testing the game locally, to publishing the game online.
## 1. Create the project
1. Use the [NNG template](https://github.com/hhu-adam/NNG4) to create a new github repo for your game: On github, click on "Use this template" > "Create a new repository".
2. Clone the game repo.
3. Call `lake update && lake exe cache get && lake build` to build the Lean project.
Note that you need to host your game's code on github to publish it online later on. If you only
want to play it locally, you can simply clone the NNG repo and start modifying that one.
## 2. Game.lean
The file `Game.lean` is the backbone of the game putting everything together.
1. `MakeGame`: This command is where the game gets built. If there are things to fix, they will be shown here as warnings or errors (orange/red squigglies in VSCode). Note that you might have to call "Lean: Restart File" (Ctrl+Shift+X) to reload the file and see changes made in other files.
1. `Title`: Set the display title of your game.
1. `Introduction`: This is the text displayed at the beginning next to the world selection tree.
1. `Info`: This will be displayed in the game's menu as "Game Info". Use it for credits, funding and other meta information about the game.
1. Imports: The file needs to import all world files, which we look at in a second. If you add a new world, remember to import it here!
1. `Dependency`: (optional) This command adds another edge in the world tree. The game computes them automatically based on used tactics/theorems. However, sometimes you need a dependency the game can't compute, for example you explained `≠` in one world and in another world, the user is expected to know it already. The syntax is `Dependency World₁ → World₂ → World₃`.
So the `Game.lean` has the following structure:
```lean
import GameServer.Commands
-- import all worlds
import Game.Levels.Tutorial
Title "My Game"
Introduction "
Hi, nice you're here! Click on a world to start.
"
Info "
This game has been developed by me.
"
-- Dependency World₁ → World₂ -- because of `≠`
MakeGame
```
## 3. Creating a Level
In this tutorial we first learn about Levels. A game consists of a tree of worlds, each world has
a number of levels, enumerated 1 to n. Here we create level 1 of a world `MyWorld`. We add this world to the game in the next section.
A minimal level file looks like the following. There are many options to add, which we will dive
into in a later section
```lean
import GameServer.Commands
World "MyWorld"
Level 1
Title "Hello World"
Introduction "
A message shown at the beginning of the level. Use it to explain any new concepts.
"
/-- The exercise statement in natural language using latex: $\iff$. -/
Statement (n : Nat) : 0 + n = n := by
sorry
Conclusion "
The message shown when the level is completed
"
```
1. Create a folder `Game/Levels/MyWorld`
1. Create a file `Game/Levels/MyWorld/L01_hello.lean`
1. Copy the above inside your first level.
## 4. Creating a World
Now we create a world. For that we create a file `MyWorld.lean` that imports all levels of this world:
```lean
import Game.Levels.MyWorld.L01_hello
World "MyWorld"
Title "My First World"
Introduction
"
This introduction text is shown when one first enters a world.
"
```
1. Create a file `Game/Levels/MyWorld.lean`
1. Use the template above and make sure you import all levels of this world.
1. In `Game.lean` import the world with `import Game.Levels.MyWorld`
Now you created a world with one level and added it🎉 The command `MakeGame` in `Game.lean` shows you any problems you might need to fix. Currently it shows
```text
No world introducing sorry, but required by MyWorld
```
which means that the world `MyWorld` uses the tactic `sorry` in a proof, but `sorry` has not been
introduced anywhere.
## 5. Testing the Game Locally
Now it's time to test the game locally and play it.
There are multiple ways how you can start the game locally to test-play it described at [How to Run the Game Locally](running_locally.md). If you have problems getting one of the setups to work, please get in contact!
## 6. Dive into Level creation
Now that you have a running game, we have a closer look at the level files and all the options
you have to design your game.
### 6. a) Inventory
The player has an inventory with tactics, theorems, and definitions that unlock during the game. You can unlock/introduce such items in a Level by adding one of the following below the `Statement`.
```lean
NewTactic induction simp
NewLemma Nat.zero_mul
NewDefinition Pow
```
#### 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.
```lean
LemmaDoc Nat.zero_mul as "zero_mul" in "Mul"
"some description"
TacticDoc simp
"some description"
DefinitionDoc Pow as "^"
"some description"
```
(e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it)
If you do not provide any content for the inventory, the game will try to find a docstring of that item and display that docstring.
#### Inventory management
You have a few options to disable inventory items that have been unlocked in previous levels:
```lean
DisableTactic, DisableLemma, OnlyTactic, OnlyLemma
```
have the same syntax as above. The former two disable items for this level, the latter two
disable all items except the ones specified.
#### Theorem Tab
Theorems are sorted into tabs. with `LemmaTab "Mul"` you specify which tab should be open by default in this level.
#### HiddenTactic
`NewHiddenTactic` lets you add a tactic without adding it to the inventory. This can be useful for variants of tactics. E.g. you could do
```lean
NewTactic rw
NewHiddenTactic rewrite nth_rewrite rwa
```
and only `rw` would show up in the inventory.
### 6. b) Statement
The statement is the exercise of the level. the basics work the same as they would in `example` or `theorem`. Note however, that you **must** do a tactic proof, i.e. the `:= by` is a hard-coded part of the syntax
#### 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.
#### Doc String / Exercise statement
Add a docstring that contains the exercise statement in natural language. If you do this, it will appear at the top of the exercise. It supports Latex.
```lean
/-- The exercise statement in natural language using latex: $\iff$. -/
Statement ...
sorry
```
#### Attributes
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
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
There are a few extra tactics that help you structuring the proof:
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
the one where `Hint` is placed. For more options about hints, see below.
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
helps setting `Hints` in different places. The `Branch` does not affect the main
proof and does not need to finish any goals.
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
having a `Template` will force the user to use Editor-mode for this level.
One thing to keep in mind is that the game will look at the main proof to figure out which tactics and theorems are needed, but it ignores any `Branch`es.
### 6. d) Hints
Most important for game development are probably the `Hints`.
The hints will be displayed whenever the player's current goal matches the goal the hint is
placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands. If you specify
```
Hint (strict := true) "some hidden hint"
```
a hint only matches iff the assumptions match exactly one-to-one. (Otherwise, it does not care if there are additional assumptions in context)
Further, you can choose to hide hints and only have them displayed when the player presses "More Help":
```
Hint (hidden := true) "some hidden hint"
```
Lastly, you should put variable names in hints inside brackets:
```
Hint "now use `rw [{h}]` to use your assumption {h}."
```
That way, the game will replace it with the actual name the assumption has in the player's proof state.
## Further Notes
Here are some random further things you should consider designing a new game:
* Inside strings, you need to escape backslashes, but not inside doc-strings, therefore you
would write `Introduction "some latex here: $\\iff$."` but
`/-- some latex here: $\iff$. -/ Statement ...`
* A world with more than 16 levels will be displayed with the levels spiraling outwards,
it might be desirable to stay below that bound. Above 22 levels the spiral starts getting out
of control.
-9
View File
@@ -1,9 +0,0 @@
## NPM Scripts
* `npm start`: Start the project in development mode. The browser will automatically reload when client files get changed. The Lean server will get recompiled and restarted when lean files get changed. The Lean server will be started without a container. The client and server can be started separately using the scripts `npm run start_client` and `npm run start_server`. The project can be accessed via `http://localhost:3000`.
Internally, websocket requests to `ws://localhost:3000/websockets` will be forwarded to a Lean server running on port `8080`.
* `npm run build`: Build the project in production mode. All assets of the client will be compiled into `client/dist`.
On the server side, the command will set up a docker image containing the Lean server. The two parts can be built separately using `npm run build_client` and `npm run build_server`.
* `npm run production`: Start the project in production mode. This requires that the build script has been run. It will start a server on the port specified in the `PORT` environment variable or by default on `8080`. You can run on a specific port by running `PORT=80 npm run production`. The server will serve the files in `client/dist` via http and give access to the bubblewrapped Lean server via the web socket protocol.
-121
View File
@@ -1,121 +0,0 @@
# Running Games Locally
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
There are several options to play a game locally:
- VSCode Dev Container: needs `docker` installed on your machine
- Codespaces: Needs active internet connection and computing time is limited.
- Gitpod: does not work yet (I that true?)
- Manual installation: Needs `npm` installed on your system
The recommended option is "VSCode Dev containers" but you may choose any option above depending on your setup.
## VSCode Dev Containers
1. **Install Docker and Dev Containers** *(once)*:<br/>
See [official instructions](https://code.visualstudio.com/docs/devcontainers/containers#_getting-started).
Explicitly this means:
* Install docker engine if you have not yet: [Instructions](https://docs.docker.com/engine/install/).
I followed the "Server" instructions for linux.
* Note that on Linux you need to add your user to the `docker` group
([see instructions](https://docs.docker.com/engine/install/linux-postinstall/)) and probably reboot.
* Open the games folder in VSCode: `cd NNG4 && code .` or "Open Folder" within VSCode
* a message appears prompting you to install the "Dev Containers" extension (by Microsoft). (Alternatively install it from the VSCode extensions).
2. **Open Project in Dev Container** *(everytime)*:<br/>
Once you have the Dev Containers Extension installed, (re)open the project folder of your game in VSCode.
A message appears asking you to "Reopen in Container".
* The first start will take a while, ca. 2-10 minutes. After the first
start this should be very quickly.
* Once built, you can open http://localhost:3000 in your browser. which should load the game
3. **Editing Files** *(everytime)*:<br/>
After editing some Lean files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`. Now you can reload your browser to see the changes.
### Errors
* If you don't get the pop-up, you might have disabled them and you can reenable it by
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
you might have deleted stuff from docker via your shell. Try deleting the container and image
explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time)
## 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".
Note: You have to wait until npm started properly. In particular, this is after a message like `[client] webpack 5.81.0 compiled successfully in 38119 ms` appears in the terminal, which might take a good while.
As with devcontainers, you need to run `lake build` after changing any lean files and then reload the browser.
## Gitpod
TODO: Not sure this works yet!
Open the game clicking on the gitpod link. Again you will need to wait until it is fully built and then enter `lake build` in the shell whenever you made changes.
## Manual installation
This installs the `lean4game` manually on your computer. (It is the same installation as one would
use to setup a proper server online, only the run command (i.e. `npm start`) is different.)
Install `nvm`:
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
```
then reopen bash and test with `command -v nvm` if it is available (Should print "nvm").
Now install node:
```bash
nvm install node
```
Clone the game (e.g. `NNG4` here):
```bash
git clone https://github.com/hhu-adam/NNG4.git
# or: git clone git@github.com:hhu-adam/NNG4.git
```
Download dependencies and build the game:
```bash
cd NNG4
lake update
lake exe cache get # if your game depends on mathlib
lake build
```
Clone the game repository into a directory next to the game:
```bash
cd ..
git clone https://github.com/leanprover-community/lean4game.git
# or: git clone git@github.com:leanprover-community/lean4game.git
```
The folders `NNG4` and `lean4game` must be in the same directory!
In `lean4game`, install dependencies:
```bash
cd lean4game
npm install
```
Run the game:
```bash
npm start
```
This takes a little time. Eventually, the game is available on http://localhost:3000/#/g/local/NNG4. Replace `NNG4` with the folder name of your local game.
## Modifying the GameServer
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with the same setup as above (manual installation) by setting `export NODE_ENV=development` inside your local game before building it:
```bash
cd NNG4
export NODE_ENV=development
lake update
lake build
```
This causes lake to search locally for the `GameServer` lake package instead of using the version from github. Therefore, when you `lake build` your game, it will rebuild with the modified `GameServer`.
+2147 -2578
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -4,7 +4,6 @@
"private": true, "private": true,
"homepage": ".", "homepage": ".",
"dependencies": { "dependencies": {
"@babel/plugin-transform-optional-chaining": "^7.23.0",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8", "@fontsource/roboto": "^4.5.8",
-1
View File
@@ -1,5 +1,4 @@
import Lean import Lean
import GameServer.Utils
section AbstractCtx section AbstractCtx
open Lean open Lean
+100 -104
View File
@@ -10,14 +10,6 @@ register_option lean4game.showDependencyReasons : Bool := {
descr := "show reasons for calculated world dependencies." descr := "show reasons for calculated world dependencies."
} }
/-- Let `MakeGame` print the reasons why the worlds depend on each other.
Note: currently unused in favour of setting `set_option trace.debug true`. -/
register_option lean4game.verbose : Bool := {
defValue := false
descr := "display more info messages to help developing the game."
}
/-! # Game metadata -/ /-! # Game metadata -/
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/ /-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
@@ -80,64 +72,6 @@ in the first level and get enabled during the game.
/-! ## Doc entries -/ /-! ## Doc entries -/
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields /-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning. warning.
@@ -160,22 +94,13 @@ def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.g
match template with match template with
-- Warn about missing documentation -- Warn about missing documentation
| none => | none =>
let docstring ← match (← getDocstring env name type) with
| some ds =>
logInfoAt ref (m!"Missing {type} Documentation. Using existing docstring. " ++
m!"Add {name}\nAdd `{type}Doc {name}` somewhere above this statement.")
pure s!"*(lean docstring)*\\\n{ds}"
| none =>
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
pure "(missing)"
-- We just add a dummy entry -- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := type type := type
name := name name := name
category := if type == .Lemma then s!"{n.getPrefix}" else "" category := if type == .Lemma then s!"{n.getPrefix}" else "" })
content := docstring}) logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
-- Add the default documentation -- Add the default documentation
| some s => | some s =>
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
@@ -215,7 +140,7 @@ LemmaDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc."
* The identifier after `in` is the category to group lemmas by (in the Inventory). * The identifier after `in` is the category to group lemmas by (in the Inventory).
* The description is a string supporting Markdown. * The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requries
The lemma/definition to have the same fully qualified name as in mathlib. The lemma/definition to have the same fully qualified name as in mathlib.
-/ -/
elab "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str : command => elab "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str : command =>
@@ -243,7 +168,7 @@ DefinitionDoc Function.Bijective as "Bijective" "defined as `Injective f ∧ Sur
* The string following `as` is the displayed name (in the Inventory). * The string following `as` is the displayed name (in the Inventory).
* The description is a string supporting Markdown. * The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requries
The lemma/definition to have the same fully qualified name as in mathlib. The lemma/definition to have the same fully qualified name as in mathlib.
-/ -/
elab "DefinitionDoc" name:ident "as" displayName:str template:str : command => elab "DefinitionDoc" name:ident "as" displayName:str template:str : command =>
@@ -255,17 +180,31 @@ elab "DefinitionDoc" name:ident "as" displayName:str template:str : command =>
/-! ## Add inventory items -/ /-! ## Add inventory items -/
-- namespace Lean.PrettyPrinter
-- def ppSignature' (c : Name) : MetaM String := do
-- let decl ← getConstInfo c
-- let e := .const c (decl.levelParams.map mkLevelParam)
-- let (stx, _) ← delabCore e (delab := Delaborator.delabConstWithSignature)
-- let f ← ppTerm stx
-- return toString f
-- end Lean.PrettyPrinter
def getStatement (name : Name) : CommandElabM MessageData := do def getStatement (name : Name) : CommandElabM MessageData := do
-- let c := name.getId
let decl ← getConstInfo name
-- -- TODO: How to go between CommandElabM and MetaM
-- addCompletionInfo <| .id name c (danglingDot := false) {} none
return ← addMessageContextPartial (.ofPPFormat { pp := fun return ← addMessageContextPartial (.ofPPFormat { pp := fun
| some ctx => ctx.runMetaM <| PrettyPrinter.ppSignature name | some ctx => ctx.runMetaM <| ppExpr decl.type
-- PrettyPrinter.ppSignature' c
-- PrettyPrinter.ppSignature c
| none => return "that's a bug." }) | none => return "that's a bug." })
-- Note: We use `String` because we can't send `MessageData` as json, but -- Note: We use `String` because we can't send `MessageData` as json, but
-- `MessageData` might be better for interactive highlighting. -- `MessageData` might be better for interactive highlighting.
/-- Get a string of the form `my_lemma (n : ℕ) : n + n = 2 * n`. /-- 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 def getStatementString (name : Name) : CommandElabM String := do
try try
return ← (← getStatement name).toString return ← (← getStatement name).toString
@@ -275,15 +214,12 @@ def getStatementString (name : Name) : CommandElabM String := do
/-- Declare tactics that are introduced by this level. -/ /-- Declare tactics that are introduced by this level. -/
elab "NewTactic" args:ident* : command => do elab "NewTactic" args:ident* : command => do
for name in ↑args do for name in ↑args do checkInventoryDoc .Tactic name -- TODO: Add (template := "[docstring]")
checkInventoryDoc .Tactic name -- TODO: Add (template := "[docstring]")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId)}} tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId)}}
/-- Declare tactics that are introduced by this level but do not show up in inventory. -/ /-- Declare tactics that are introduced by this level. -/
elab "NewHiddenTactic" args:ident* : command => do elab "NewHiddenTactic" args:ident* : command => do
for name in ↑args do
checkInventoryDoc .Tactic name (template := "")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId), tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId),
hidden := level.tactics.hidden ++ args.map (·.getId)}} hidden := level.tactics.hidden ++ args.map (·.getId)}}
@@ -291,8 +227,6 @@ elab "NewHiddenTactic" args:ident* : command => do
/-- Declare lemmas that are introduced by this level. -/ /-- Declare lemmas that are introduced by this level. -/
elab "NewLemma" args:ident* : command => do elab "NewLemma" args:ident* : command => do
for name in ↑args do 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]") checkInventoryDoc .Lemma 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)}} lemmas := {level.lemmas with new := args.map (·.getId)}}
@@ -475,11 +409,13 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
-- the namespace? -- the namespace?
let currNamespace ← getCurrNamespace let currNamespace ← getCurrNamespace
-- Format theorem statement for displaying let st ← match statementName with
let sigString := sig.raw.reprint.getD "" | some name => getStatementString <| currNamespace ++ name.getId
let descrFormat : String := match statementName with | none => getStatementString <| currNamespace ++ (defaultDeclName).getId
| some name => s!"theorem {name.getId} {sigString} := by"
| none => s!"example {sigString} := by" let head := match statementName with
| some name => Format.join ["theorem ", (currNamespace ++ name.getId).toString]
| none => "example"
modifyCurLevel fun level => pure { level with modifyCurLevel fun level => pure { level with
module := env.header.mainModule module := env.header.mainModule
@@ -489,7 +425,7 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
statementName := match statementName with statementName := match statementName with
| none => default | none => default
| some name => currNamespace ++ name.getId | some name => currNamespace ++ name.getId
descrFormat := descrFormat descrFormat := (Format.join [head, " ", st, " := by"]).pretty 10
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}
@@ -521,7 +457,7 @@ elab (name := GameServer.Tactic.Hint) "Hint" args:hintArg* msg:interpolatedStr(t
let mut strict := false let mut strict := false
let mut hidden := false let mut hidden := false
-- remove spaces at the beginning of new lines -- remove spaces at the beginngng 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
match m with match m with
| Syntax.node info k args => | Syntax.node info k args =>
@@ -575,9 +511,9 @@ 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." logInfo "This branch finishes the proof."
else else
trace[debug] "This branch leaves open goals." logInfo "This branch leaves open goals."
let msgs ← Core.getMessageLog let msgs ← Core.getMessageLog
b.restore b.restore
@@ -589,7 +525,7 @@ elab (name := GameServer.Tactic.Hole) "Hole" t:tacticSeq : tactic => do
/-- /--
Iterate recursively through the Syntax, replace `Hole` with `sorry` and remove all Iterate recursively through the Syntax, replace `Hole` with `sorry` and remove all
`Hint`/`Branch` occurrences. `Hint`/`Branch` occurences.
-/ -/
def replaceHoles (tacs : Syntax) : Syntax := def replaceHoles (tacs : Syntax) : Syntax :=
match tacs with match tacs with
@@ -603,7 +539,7 @@ where filterArgs (args : List Syntax) : List Syntax :=
-- replace `Hole` with `sorry`. -- replace `Hole` with `sorry`.
| Syntax.node info `GameServer.Tactic.Hole _ :: r => | Syntax.node info `GameServer.Tactic.Hole _ :: r =>
Syntax.node info `Lean.Parser.Tactic.tacticSorry #[Syntax.atom info "sorry"] :: filterArgs r Syntax.node info `Lean.Parser.Tactic.tacticSorry #[Syntax.atom info "sorry"] :: filterArgs r
-- delete all `Hint` and `Branch` occurrences in the middle. -- delete all `Hint` and `Branch` occurences in the middle.
| Syntax.node _ `GameServer.Tactic.Hint _ :: _ :: r | Syntax.node _ `GameServer.Tactic.Hint _ :: _ :: r
| Syntax.node _ `GameServer.Tactic.Branch _ :: _ :: r => | Syntax.node _ `GameServer.Tactic.Branch _ :: _ :: r =>
filterArgs r filterArgs r
@@ -622,7 +558,7 @@ elab "Template" tacs:tacticSeq : tactic => do
Tactic.evalTactic tacs Tactic.evalTactic tacs
let newTacs : TSyntax `Lean.Parser.Tactic.tacticSeq := ⟨replaceHoles tacs.raw⟩ let newTacs : TSyntax `Lean.Parser.Tactic.tacticSeq := ⟨replaceHoles tacs.raw⟩
let template ← PrettyPrinter.ppCategory `Lean.Parser.Tactic.tacticSeq newTacs let template ← PrettyPrinter.ppCategory `Lean.Parser.Tactic.tacticSeq newTacs
trace[debug] s!"Template:\n{template}" logInfo s!"Template:\n{template}"
modifyLevel (←getCurLevelId) fun level => do modifyLevel (←getCurLevelId) fun level => do
return {level with template := s!"{template}"} return {level with template := s!"{template}"}
@@ -655,6 +591,65 @@ def GameLevel.setComputedInventory (level : GameLevel) :
| .Definition, v => {level with definitions := {level.definitions with tiles := v}} | .Definition, v => {level with definitions := {level.definitions with tiles := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}} | .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}}
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name)) partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) : (newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
@@ -1053,6 +1048,7 @@ elab "MakeGame" : command => do
modifyLevel ⟨← getCurGameId, worldId, levelId⟩ fun level => do modifyLevel ⟨← getCurGameId, worldId, levelId⟩ fun level => do
return level.setComputedInventory inventoryType itemsArray return level.setComputedInventory inventoryType itemsArray
/-! # Debugging tools -/ /-! # Debugging tools -/
-- /-- Print current game for debugging purposes. -/ -- /-- Print current game for debugging purposes. -/
+1 -6
View File
@@ -67,7 +67,7 @@ structure InventoryTemplate where
/-- Depends on the type: /-- Depends on the type:
* Tactic: the tactic's name * Tactic: the tactic's name
* Lemma: fully qualified lemma name * Lemma: fully qualified lemma name
* Definition: no restrictions (preferably the definitions fully qualified name) * Definition: no restrictions (preferrably the definions fully qualified name)
-/ -/
name: Name name: Name
/-- Only for Lemmas. To sort them into tabs -/ /-- Only for Lemmas. To sort them into tabs -/
@@ -386,11 +386,6 @@ def modifyCurWorld (fn : World → m World) [MonadError m] : m Unit := do
return {game with worlds := {game.worlds with nodes := game.worlds.nodes.insert world.name (← fn world) }} return {game with worlds := {game.worlds with nodes := game.worlds.nodes.insert world.name (← fn world) }}
def addLevel (level : GameLevel) [MonadError m] : m Unit := do def addLevel (level : GameLevel) [MonadError m] : m Unit := do
let worldId ← getCurWorldId
match ← getLevel? ⟨← getCurGameId, worldId, level.index⟩ with
| some _existingLevel =>
throwError m!"Level {level.index} already exists for world {worldId}!"
| none =>
modifyCurWorld fun world => do modifyCurWorld fun world => do
return {world with levels := world.levels.insert level.index level} return {world with levels := world.levels.insert level.index level}
+9 -28
View File
@@ -57,17 +57,6 @@ open JsonRpc
section Elab section Elab
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) :
Elab.Command.CommandElabM Unit := do
modify fun st => { st with
messages := st.messages.add {
fileName := inputCtx.fileName
severity := MessageSeverity.error
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
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 /-- Find all tactics in syntax object that are forbidden according to a
set `allowed` of allowed tactics. -/ set `allowed` of allowed tactics. -/
@@ -76,7 +65,7 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
Elab.Command.CommandElabM Unit := do Elab.Command.CommandElabM Unit := do
match stx with match stx with
| .missing => return () | .missing => return ()
| .node _info _kind args => | .node info kind args =>
for arg in args do for arg in args do
findForbiddenTactics inputCtx levelParams arg findForbiddenTactics inputCtx levelParams arg
| .atom info val => | .atom info val =>
@@ -100,17 +89,13 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
| some _ => pure () -- tactic is in the inventory, allow it. | some _ => pure () -- tactic is in the inventory, allow it.
else if tac.disabled then else if tac.disabled then
addWarningMessage info s!"The tactic '{val}' is disabled in this level!" addWarningMessage info s!"The tactic '{val}' is disabled in this level!"
| .ident info _rawVal val _preresolved => | .ident info rawVal val preresolved =>
let ns ← let ns ←
try resolveGlobalConst (mkIdent val) try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error catch | _ => pure [] -- catch "unknown constant" error
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 | return () -- not a theroem -> ignore
-- Forbid the theorem we are proving currently
if n = levelParams.statementName then
addErrorMessage info inputCtx s!"Structural recursion: you can't use '{n}' to proof itself!"
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
match lemmasAndDefs.find? (fun l => l.name == n) with match lemmasAndDefs.find? (fun l => l.name == n) with
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!" | none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
@@ -120,19 +105,14 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
else if lem.disabled then else if lem.disabled then
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!" addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addWarningMessage (info : SourceInfo) (s : MessageData) := where addWarningMessage (info : SourceInfo) (s : MessageData) :=
let difficulty := levelParams.difficulty
if difficulty > 0 then
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 := if difficulty > 1 then MessageSeverity.error else MessageSeverity.warning severity := MessageSeverity.warning
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0) pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s data := s
} }
} }
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)
@@ -190,6 +170,7 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
parseResultRef.set (tacticStx, cmdParserState) parseResultRef.set (tacticStx, cmdParserState)
-- Check for forbidden tactics -- Check for forbidden tactics
if levelParams.checkEnabled then
findForbiddenTactics inputCtx levelParams tacticStx findForbiddenTactics inputCtx levelParams tacticStx
-- Insert invisible `skip` command to make sure we always display the initial goal -- Insert invisible `skip` command to make sure we always display the initial goal
@@ -292,7 +273,7 @@ where
-- TODO(MH): check for interrupt with increased precision -- 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 /- NOTE(MH): This relies on the client discarding old diagnostics upon receiving new ones
while preferring newer versions over old ones. The former is necessary because we do while prefering 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 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 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 we interrupted this elaboration task right at this point and a newer elaboration task
@@ -300,7 +281,7 @@ where
the interrupt. Explicitly clearing diagnostics is difficult for a similar reason, the interrupt. Explicitly clearing diagnostics is difficult for a similar reason,
because we cannot guarantee that no further diagnostics are emitted after clearing because we cannot guarantee that no further diagnostics are emitted after clearing
them. -/ them. -/
-- NOTE(WN): this is *not* redundant even if there are no new diagnostics in this snapshot -- NOTE(WN): this is *not* redundent even if there are no new diagnostics in this snapshot
-- because empty diagnostics clear existing error/information squiggles. Therefore we always -- because empty diagnostics clear existing error/information squiggles. Therefore we always
-- want to publish in case there was previously a message at this position. -- 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
@@ -412,7 +393,7 @@ section Initialization
-- Set the search path -- Set the search path
Lean.searchPathRef.set paths Lean.searchPathRef.set paths
let env ← importModules #[{ module := `Init : Import }, { module := levelParams.levelModule : Import }] {} 0 let env ← importModules [{ module := `Init : Import }, { module := levelParams.levelModule : Import }] {} 0
-- return (env, paths) -- return (env, paths)
-- use empty header -- use empty header
@@ -552,7 +533,7 @@ section MainLoop
set {s with levelParams := {s.levelParams with set {s with levelParams := {s.levelParams with
inventory := p.inventory, inventory := p.inventory,
difficulty := p.difficulty}} checkEnabled := p.checkEnabled}}
mainLoop mainLoop
| Message.notification method (some params) => | Message.notification method (some params) =>
handleNotification method (toJson params) handleNotification method (toJson params)
+8 -17
View File
@@ -7,7 +7,7 @@ structure GameServerState :=
(game : Name) (game : Name)
(gameDir : String) (gameDir : String)
(inventory : Array String) (inventory : Array String)
(difficulty : Nat) (checkEnabled : Bool)
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
@@ -50,7 +50,6 @@ structure LevelInfo where
descrText : Option String := none descrText : Option String := none
descrFormat : String := "" descrFormat : String := ""
lemmaTab : Option String lemmaTab : Option String
displayName : Option String
statementName : Option String statementName : Option String
template : Option String template : Option String
deriving ToJson, FromJson deriving ToJson, FromJson
@@ -80,15 +79,8 @@ structure DidOpenLevelParams where
lemmas : Array InventoryTile lemmas : Array InventoryTile
definitions : Array InventoryTile definitions : Array InventoryTile
inventory : Array String inventory : Array String
/-- /-- if true the server gives warnings for used tactics/lemmas that are not unlocked. -/
Check for tactics/theorems that are not unlocked. checkEnabled : Bool
0: no check
1: give warnings
2: give errors
-/
difficulty : Nat
/-- The name of the theorem to be proven in this level. -/
statementName : Name
deriving ToJson, FromJson deriving ToJson, FromJson
structure LoadDocParams where structure LoadDocParams where
@@ -98,7 +90,7 @@ deriving ToJson, FromJson
structure SetInventoryParams where structure SetInventoryParams where
inventory : Array String inventory : Array String
difficulty : Nat checkEnabled : Bool
deriving ToJson, FromJson deriving ToJson, FromJson
def handleDidOpenLevel (params : Json) : GameServerM Unit := do def handleDidOpenLevel (params : Json) : GameServerM Unit := do
@@ -125,8 +117,7 @@ def handleDidOpenLevel (params : Json) : GameServerM Unit := do
lemmas := lvl.lemmas.tiles lemmas := lvl.lemmas.tiles
definitions := lvl.definitions.tiles definitions := lvl.definitions.tiles
inventory := s.inventory inventory := s.inventory
difficulty := s.difficulty checkEnabled := s.checkEnabled
statementName := lvl.statementName
: DidOpenLevelParams : DidOpenLevelParams
} }
} }
@@ -169,8 +160,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
match lvl.lemmas.tiles.find? (·.new) with match lvl.lemmas.tiles.find? (·.new) with
| some tile => tile.category | some tile => tile.category
| none => none | none => none
statementName := lvl.statementName.toString statementName := 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 == .Lemma) with
@@ -199,6 +189,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
-- return true -- return true
| Message.request id "loadDoc" params => | Message.request id "loadDoc" params =>
let p ← parseParams LoadDocParams (toJson params) let p ← parseParams LoadDocParams (toJson params)
let s ← get
let c ← read let c ← read
let some doc ← getInventoryItem? p.name p.type let some doc ← getInventoryItem? p.name p.type
| do | do
@@ -214,7 +205,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
| Message.notification "$/game/setInventory" params => | Message.notification "$/game/setInventory" params =>
let p := (← parseParams SetInventoryParams (toJson params)) let p := (← parseParams SetInventoryParams (toJson params))
let s ← get let s ← get
set {s with inventory := p.inventory, difficulty := p.difficulty} set {s with inventory := p.inventory, checkEnabled := p.checkEnabled}
let st ← read let st ← read
let workers ← st.fileWorkersRef.get let workers ← st.fileWorkersRef.get
for (_, fw) in workers do for (_, fw) in workers do
-50
View File
@@ -1,50 +0,0 @@
import Lean
open Lean Meta Elab Tactic Command
syntax showOptArg := atomic(" (" (&"strict" <|> &"verbose") " := " withoutPosition(term) ")")
/--
Lists all available options that can be used with `set_option`.
Use `set_option (verbose := true)` to display more than just the option names.
The form `#show_option id` will show only options that begin with `id`.
Copied from `#help option` from mathlib.
-/
elab "#show_option" verbose:(ppSpace showOptArg)? id:(ppSpace Parser.rawIdent)? : command => do
let id := id.map (·.raw.getId.toString false)
let mut decls : Lean.RBMap _ _ compare := {}
for (name, decl) in show Lean.RBMap .. from ← getOptionDecls do
let name := name.toString false
if let some id := id then
if !id.isPrefixOf name then
continue
decls := decls.insert name decl
let mut msg := Format.nil
let opts ← getOptions
if decls.isEmpty then
match id with
| some id => throwError "no options start with {id}"
| none => throwError "no options found (!)"
for (name, decl) in decls do
let mut msg1 := match decl.defValue with
| .ofString val => s!"String := {repr val}"
| .ofBool val => s!"Bool := {repr val}"
| .ofName val => s!"Name := {repr val}"
| .ofNat val => s!"Nat := {repr val}"
| .ofInt val => s!"Int := {repr val}"
| .ofSyntax val => s!"Syntax := {repr val}"
if let some val := opts.find name then
msg1 := s!"{msg1} (currently: {val})"
msg := match verbose with
| some opt =>
match opt with
| `(showOptArg| (verbose := true)) =>
msg ++ .nest 2 (f!"option {name} : {msg1}" ++ .line ++ decl.descr) ++ .line ++ .line
| _ =>
msg ++ (f!"{name}" ++ .line )
| none =>
msg ++ (f!"{name}" ++ .line )
logInfo msg
+2 -2
View File
@@ -91,7 +91,7 @@ def createEnv (gameDir : String) (module : String) : IO Environment := do
-- Set the search path -- Set the search path
Lean.searchPathRef.set paths Lean.searchPathRef.set paths
let env ← importModules #[{ module := `Init : Import }, { module := module : Import }] {} 0 let env ← importModules [{ module := `Init : Import }, { module := module : Import }] {} 0
return env return env
def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
@@ -114,7 +114,7 @@ def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
game := gameName, game := gameName,
gameDir := gameDir, gameDir := gameDir,
inventory := #[] inventory := #[]
difficulty := 0 checkEnabled := false
} }
let initRequest ← i.readLspRequestAs "initialize" InitializeParams let initRequest ← i.readLspRequestAs "initialize" InitializeParams
-- We misuse the `rootUri` field to the gameName -- We misuse the `rootUri` field to the gameName
+40 -49
View File
@@ -8,21 +8,28 @@ import * as jsonrpcserver from 'vscode-ws-jsonrpc/server';
import os from 'os'; import os from 'os';
import anonymize from 'ip-anonymize'; import anonymize from 'ip-anonymize';
// import { importTrigger, importStatus } from './import.mjs' // import { importTrigger, importStatus } from './import.mjs'
// import fs from 'fs'
/** /** Preloaded games. The keys refer to the docker tags of the virtual machines.
* The number `queueLength` determines how many instances of the docker container
* get started before any user shows up to have them up and running immediately.
* The values `name`, `module`, and `dir` are just used for development where we
* use a project directory instead of a docker container.
*/ */
const games = { const games = {
"g/hhu-adam/robo": { "g/hhu-adam/robo": {
// module: "Game", // The lean module's name. Defaults to "Game"
// name: "Adam", // For the `Game "Adam"` tag in the games. Defaults to "MyGame"
dir: "Robo", dir: "Robo",
queueLength: 5 queueLength: 5
}, },
"g/hhu-adam/nng4": { "g/hhu-adam/nng4": {
// module: "Game",
// name: "NNG",
dir: "NNG4", dir: "NNG4",
queueLength: 5 queueLength: 5
}, },
"g/hhu-adam/nng4-old": { "g/local/game": {
dir: "NNG4-OLD", dir: "game",
queueLength: 0 queueLength: 0
} }
} }
@@ -54,66 +61,51 @@ const isDevelopment = environment === 'development'
/** 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 = {}
function tag(owner, repo) { function startServerProcess(tag) {
return `g/${owner.toLowerCase()}/${repo.toLowerCase()}` if (! games[tag]?.dir) {
} console.error(`Unknown game: ${tag}`)
function startServerProcess(owner, repo) {
let game_dir = (owner == 'local') ?
repo : games[tag(owner, repo)]?.dir
if (owner == 'local') {
if(!isDevelopment) {
console.error(`No local games in production mode.`)
return
}
// TODO: This test does not work
// if (!fs.existsSync(path.join("../", game_dir))) {
// console.error(`Game folder does not exists: ${game_dir}`)
// return
// }
}
if (!game_dir) {
console.error(`Unknown game: ${tag(owner, repo)}`)
return return
} }
let serverProcess let serverProcess
if (isDevelopment) { if (isDevelopment) {
let args = ["--server", path.join("../../../../", game_dir)] let args = ["--server", path.join("../../../../", games[tag].dir)]
if (games[tag].module) {
args.push(games[tag].module)
if (games[tag].name) { args.push(games[tag].name) }
}
serverProcess = cp.spawn("./gameserver", args, serverProcess = cp.spawn("./gameserver", args,
{ cwd: path.join(__dirname, "./build/bin/") }) { cwd: path.join(__dirname, "./build/bin/") })
} else { } else {
serverProcess = cp.spawn("./bubblewrap.sh", serverProcess = cp.spawn("./bubblewrap.sh",
[game_dir], [games[tag].dir],
{ cwd: __dirname }) { cwd: __dirname })
} }
serverProcess.on('error', error => serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`) console.error(`Launching Lean Server failed: ${error}`)
) );
if (serverProcess.stderr !== null) { if (serverProcess.stderr !== null) {
serverProcess.stderr.on('data', data => serverProcess.stderr.on('data', data =>
console.error(`Lean Server: ${data}`) console.error(`Lean Server: ${data}`)
) );
} }
return serverProcess return serverProcess
} }
/** start Lean Server processes to refill the queue */ /** start Lean Server processes to refill the queue */
function fillQueue(owner, repo) { function fillQueue(tag) {
while (queue[tag(owner, repo)].length < games[tag(owner, repo)].queueLength) { while (queue[tag].length < games[tag].queueLength) {
const serverProcess = startServerProcess(tag(owner, repo)) const serverProcess = startServerProcess(tag)
queue[tag(owner, repo)].push(serverProcess) queue[tag].push(serverProcess)
} }
} }
// if (!isDevelopment) { // Don't use queue in development if (!isDevelopment) { // Don't use queue in development
// for (let tag in games) { for (let tag in games) {
// queue[tag] = [] queue[tag] = []
// fillQueue(tag) fillQueue(tag)
// } }
// } }
const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)$/ const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)$/
@@ -122,19 +114,18 @@ wss.addListener("connection", function(ws, req) {
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 = `g/${owner.toLowerCase()}/${repo.toLowerCase()}` const tag = `g/${owner.toLowerCase()}/${repo.toLowerCase()}`
// // TODO if (isDevelopment && process.env.DEV_CONTAINER) {
// if (isDevelopment && process.env.DEV_CONTAINER) { tag = `g/local/game`
// tag = `g/local/game` }
// }
let ps; let ps;
if (!queue[tag(owner, repo)] || queue[tag(owner, repo)].length == 0) { if (!queue[tag] || queue[tag].length == 0) {
ps = startServerProcess(owner, repo) ps = startServerProcess(tag)
} else { } else {
ps = queue[tag(owner, repo)].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(owner, repo) fillQueue(tag)
} }
socketCounter += 1; socketCounter += 1;
+1 -4
View File
@@ -1,4 +1 @@
{"version": 6, {"version": 5, "packagesDir": "lake-packages", "packages": []}
"packagesDir": "lake-packages",
"packages": [],
"name": "GameServer"}
+1 -1
View File
@@ -1 +1 @@
leanprover/lean4:v4.1.0 leanprover/lean4:v4.0.0
+2 -5
View File
@@ -13,16 +13,13 @@ Level 1
Statement foo.bar : 5 ≤ 7 := by Statement foo.bar : 5 ≤ 7 := by
simp simp
Game "Test"
World "TestW"
Level 2 -- should warn if set to `1`
-- Shows warning on `foo.bar₂`: -- Shows warning on `foo.bar₂`:
Statement foo.bar2 : 3 ≤ 7 := by Statement foo.bar2 : 3 ≤ 7 := by
simp simp
NewLemma foo.bar NewLemma foo.baz
DisabledTactic tauto DisabledTactic tauto