Merge branch 'dev'

pull/118/head
Jon Eugster 3 years ago
commit f5eb185eb2

@ -183,6 +183,16 @@ The installation instructions are not yet tested on Mac/Windows. Comments very w
After editing some files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`.
Now you can reload your browser to see the changes.
### Errors
* If you don't get the pop-up, you might have disabled them and you can reenable it by
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
you might have deleted stuff from docker via your shell. Try deleting the container and image
explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
container. (this will again take some time)
## Without Dev Containers
Install `nvm`:
```

@ -21,6 +21,16 @@
<i>You need to enable JavaScript to use the Lean Game Server, as it is built
using React.</i>
</p>
<p>
<strong>Impressum:</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>
</noscript>
<script src="bundle.js"></script>

@ -1,26 +0,0 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { Outlet, useParams } from "react-router-dom";
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './reset.css';
import './app.css';
export const GameIdContext = React.createContext<string>(undefined);
function App() {
const params = useParams();
return (
<div className="app">
<GameIdContext.Provider value={"g/" + params.owner + "/" + params.repo}>
<Outlet />
</GameIdContext.Provider>
</div>
)
}
export default App

@ -2,6 +2,7 @@
:root {
--clr-primary: #1976d2;
--clr-primary-dark: #104a83;
--clr-light-gray: #ddd;
--clr-dark-gray: #aaa;
--clr-darker-gray: #555;
@ -73,6 +74,10 @@ body {
background: white;
}
.btn-inverted:hover {
color: var(--clr-primary-dark);
}
.btn-disabled, .btn[disabled] {
color: var(--clr-dark-gray);
background: var(--clr-light-gray);
@ -106,7 +111,8 @@ em {
flex-direction: row;
justify-content: space-between;
padding: 1.1em;
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5))
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5));
z-index: 2;
}
.app-bar-title, .app-bar-subtitle {
@ -114,7 +120,8 @@ em {
font-weight: 500;
font-size: 1.3rem;
display: inline-block;
margin: 0 1em;
margin: 0;
/* margin: 0 1em; */
}
.app-content {

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react';
import App from './App';
import App from './app';
test('renders learn react link', () => {
render(<App />);

@ -0,0 +1,36 @@
import * as React from 'react';
import { Outlet, useParams } from "react-router-dom";
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './reset.css';
import './app.css';
import { MobileContext } from './components/infoview/context';
import { useWindowDimensions } from './window_width';
export const GameIdContext = React.createContext<string>(undefined);
function App() {
const params = useParams()
const gameId = "g/" + params.owner + "/" + params.repo
// TODO: Make mobileLayout be changeable in settings
// TODO: Handle resize Events
const {width, height} = useWindowDimensions()
const [mobile, setMobile] = React.useState(width < 800)
return (
<div className="app">
<GameIdContext.Provider value={gameId}>
<MobileContext.Provider value={{mobile, setMobile}}>
<Outlet />
</MobileContext.Provider>
</GameIdContext.Provider>
</div>
)
}
export default App

@ -1,15 +0,0 @@
import * as React from 'react';
import { Link, LinkProps } from "react-router-dom";
export interface ButtonProps extends LinkProps {
disabled?: boolean
inverted?: boolean
}
export function Button(props: ButtonProps) {
if (props.disabled) {
return <span className={`btn btn-disabled ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
} else {
return <Link className={`btn ${props.inverted ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
}
}

@ -1,131 +0,0 @@
import * as React from 'react'
import { Button } from './Button'
import { GameIdContext } from '../App';
import { useStore } from 'react-redux';
import { useAppDispatch, useAppSelector } from '../hooks';
import { useSelector } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
import { deleteProgress, selectProgress, loadProgress, GameProgressState } from '../state/progress';
const downloadFile = ({ data, fileName, fileType }) => {
const blob = new Blob([data], { type: fileType })
const a = document.createElement('a')
a.download = fileName
a.href = window.URL.createObjectURL(blob)
const clickEvt = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
a.dispatchEvent(clickEvt)
a.remove()
}
function GameMenu() {
const [file, setFile] = React.useState<File>();
const gameId = React.useContext(GameIdContext)
const store = useStore()
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()
const downloadProgress = (e) => {
e.preventDefault()
downloadFile({
data: JSON.stringify(gameProgress),
fileName: `lean4game-${gameId}-${new Date().toLocaleDateString()}.json`,
fileType: 'text/json',
})
};
const handleFileChange = (e) => {
if (e.target.files) {
setFile(e.target.files[0]);
}
};
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()
}
return <nav className="game-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>
{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 only affects your saved proofs, no levels are ever locked.
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>
}
export default GameMenu

@ -1,111 +0,0 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import './inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
import Markdown from './Markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo } from '../state/api';
import { GameIdContext } from '../App';
export function Inventory({levelInfo, setInventoryDoc } :
{
levelInfo: LevelInfo,
setInventoryDoc: (inventoryDoc: {name: string, type: string}) => void,
}) {
// TODO: This seems like a useless wrapper to me
function openDoc(name, type) {
setInventoryDoc({name, type})
}
return (
<div className="inventory">
{/* TODO: Click on Tactic: show info
TODO: click on paste icon -> paste into command line */}
<h2>Tactics</h2>
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} />
<h2>Definitions</h2>
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} />
<h2>Lemmas</h2>
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc}
defaultTab={levelInfo?.lemmaTab} level={levelInfo}/>
</div>
)
}
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined} :
{
items: InventoryTile[],
docType: string,
openDoc(name: string, type: string): void,
defaultTab? : string,
level? : LevelInfo,
}) {
// TODO: `level` is only used in the `useEffect` below to check if a new level has
// been loaded. Is there a better way to observe this?
const categorySet = new Set<string>()
for (let item of items) {
categorySet.add(item.category)
}
const categories = Array.from(categorySet).sort()
const [tab, setTab] = useState(defaultTab);
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (defaultTab) {
setTab(defaultTab)
}}, [level])
return <>
{categories.length > 1 &&
<div className="tab-bar">
{categories.map((cat) =>
<div className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`} onClick={() => { setTab(cat) }}>{cat}</div>)}
</div>}
<div className="inventory-list">
{ [...items].sort(
// Sort entries `available > disabled > locked`.
(x, y) => +x.locked - +y.locked || +x.disabled - +y.disabled
).map(item => {
if ((tab ?? categories[0]) == item.category) {
return <InventoryItem key={item.name} showDoc={() => {openDoc(item.name, docType)}}
name={item.name} displayName={item.displayName} locked={item.locked}
disabled={item.disabled} newly={item.new}/>
}
}) }
</div>
</>
}
function InventoryItem({name, displayName, locked, disabled, newly, showDoc}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
const title = locked ? "Not unlocked yet" :
disabled ? "Not available in this level" : ""
const handleClick = () => {
if (!locked && !disabled) {
showDoc()
}
}
return <div className={`item ${className}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
}
export function Documentation({name, type}) {
const gameId = React.useContext(GameIdContext)
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
return <>
<h2 className="doc">{doc.data?.displayName}</h2>
<p><code>{doc.data?.statement}</code></p>
{/* <code>docstring: {doc.data?.docstring}</code> */}
<Markdown>{doc.data?.content}</Markdown>
</>
}

@ -1,429 +0,0 @@
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { InfoviewApi } from '@leanprover/infoview'
import { Link, useParams } from 'react-router-dom';
import { Box, CircularProgress, FormControlLabel, FormGroup, Switch, IconButton } from '@mui/material';
import MuiDrawer from '@mui/material/Drawer';
import Grid from '@mui/material/Unstable_Grid2';
import {Inventory, Documentation} from './Inventory';
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
import 'lean4web/client/src/editor/vscode.css';
import 'lean4web/client/src/editor/infoview.css';
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { InfoProvider } from 'lean4web/client/src/editor/infoview';
import 'lean4web/client/src/editor/infoview.css'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import './level.css'
import { Button } from './Button'
import { ConnectionContext, useLeanClient } from '../connection';
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api';
import { changedSelection, codeEdited, selectCode, selectSelections, progressSlice, selectCompleted } from '../state/progress';
import { useAppDispatch, useAppSelector } from '../hooks';
import { useStore } from 'react-redux';
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts';
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection';
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event';
import { Main } from './infoview/main'
import type { Location } from 'vscode-languageserver-protocol';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
import { styled, useTheme, Theme, CSSObject } from '@mui/material/styles';
import Markdown from './Markdown';
import Split from 'react-split'
import { Alert } from '@mui/material';
import { GameIdContext } from '../App';
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(null as any);
export const InputModeContext = React.createContext<{
commandLineMode: boolean,
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
commandLineInput: string,
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
}>({
commandLineMode: true,
setCommandLineMode: () => {},
commandLineInput: "",
setCommandLineInput: () => {},
});
function Level() {
const params = useParams();
const levelId = parseInt(params.levelId)
const worldId = params.worldId
useLoadWorldFiles(worldId)
if (levelId == 0) {
return <Introduction worldId={worldId} />
} else {
return <PlayableLevel worldId={worldId} levelId={levelId} />
}
}
function PlayableLevel({worldId, levelId}) {
const codeviewRef = useRef<HTMLDivElement>(null)
const introductionPanelRef = useRef<HTMLDivElement>(null)
const gameId = React.useContext(GameIdContext)
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
const [commandLineMode, setCommandLineMode] = useState(true)
const [commandLineInput, setCommandLineInput] = useState("")
const [canUndo, setCanUndo] = useState(initialCode.trim() !== "")
const theme = useTheme();
useEffect(() => {
// Scroll to top when loading a new level
introductionPanelRef.current!.scrollTo(0,0)
// Reset command line input when loading a new level
setCommandLineInput("")
}, [levelId])
React.useEffect(() => {
if (!commandLineMode) {
// Delete last input attempt from command line
editor.executeEdits("command-line", [{
range: editor.getSelection(),
text: "",
forceMoveMarkers: false
}]);
editor.focus()
}
}, [commandLineMode])
const handleUndo = () => {
const endPos = editor.getModel().getFullModelRange().getEndPosition()
let range
console.log(endPos.column)
if (endPos.column === 1) {
range = monaco.Selection.fromPositions(
new monaco.Position(endPos.lineNumber - 1, 1),
endPos
)
} else {
range = monaco.Selection.fromPositions(
new monaco.Position(endPos.lineNumber, 1),
endPos
)
}
editor.executeEdits("undo-button", [{
range,
text: "",
forceMoveMarkers: false
}]);
}
const gameInfo = useGetGameInfoQuery({game: gameId})
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const dispatch = useAppDispatch()
const onDidChangeContent = (code) => {
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
setCanUndo(code.trim() !== "")
}
const onDidChangeSelection = (monacoSelections) => {
const selections = monacoSelections.map(
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
}
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
const {editor, infoProvider, editorConnection} =
useLevelEditor(worldId, levelId, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
// 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])
// if this is set to a pair `(name, type)` then the according doc will be open.
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
const levelTitle = <>{levelId && `Level ${levelId}`}{level?.data?.title && `: ${level?.data?.title}`}</>
return <>
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
<LevelAppBar isLoading={level.isLoading} levelTitle={levelTitle} worldId={worldId} levelId={levelId} />
<Split minSize={0} snapOffset={200} sizes={[50, 25, 25]} className={`app-content level ${level.isLoading ? 'hidden' : ''}`}>
<div className="exercise-panel">
<div ref={introductionPanelRef} className="introduction-panel">
{level?.data?.introduction &&
<div className="message info">
<Markdown>{level?.data?.introduction}</Markdown>
</div>
}
</div>
<div className="exercise">
<Markdown>
{(level?.data?.statementName ?
`**Theorem** \`${level?.data?.statementName}\`: `
:
level?.data?.descrText && "**Exercise**: ")
+ `${level?.data?.descrText}`
}
</Markdown>
<div className={`statement ${commandLineMode ? 'hidden' : ''}`}><code>{level?.data?.descrFormat}</code></div>
<div ref={codeviewRef} className={`codeview ${commandLineMode ? 'hidden' : ''}`}></div>
</div>
<div className="input-mode-switch">
{commandLineMode && <button className="btn" onClick={handleUndo} disabled={!canUndo}><FontAwesomeIcon icon={faRotateLeft} /> Undo</button>}
<FormGroup>
<FormControlLabel control={<Switch onChange={(ev) => { setCommandLineMode(!commandLineMode) }} />} label="Editor mode" />
</FormGroup>
</div>
<EditorContext.Provider value={editorConnection}>
<MonacoEditorContext.Provider value={editor}>
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
{editorConnection && <Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />}
</InputModeContext.Provider>
</MonacoEditorContext.Provider>
</EditorContext.Provider>
{completed && <div className="conclusion">
{level?.data?.conclusion?.trim() &&
<div className="message info">
<Markdown>{level?.data?.conclusion}</Markdown>
</div>
}
{levelId >= gameInfo.data?.worldSize[worldId] ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} /></Button>}
</div>}
</div>
<div className="inventory-panel">
{!level.isLoading &&
<Inventory levelInfo={level?.data} setInventoryDoc={setInventoryDoc} />}
</div>
<div className="doc-panel">
{inventoryDoc && <Documentation name={inventoryDoc.name} type={inventoryDoc.type} />}
</div>
</Split>
</>
}
export default Level
function Introduction({worldId}) {
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
return <>
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Einführung" worldId={worldId} levelId={0} />
<div style={gameInfo.isLoading ? {display: "none"} : null} className="exercise-panel">
<div className="introduction-panel">
<div className="message info">
<Markdown>
{gameInfo.data?.worlds.nodes[worldId].introduction}
</Markdown>
</div>
</div>
<div className="conclusion">
{0 == gameInfo.data?.worldSize[worldId] ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}>
Start&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>}
</div>
</div>
</>
}
function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
<div>
<Button to={`/${gameId}`}><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>
<Button disabled={levelId <= 0} inverted={true}
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
><FontAwesomeIcon icon={faArrowLeft} />&nbsp;Previous</Button>
<Button disabled={levelId >= gameInfo.data?.worldSize[worldId]} inverted={true}
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}
>Next&nbsp;<FontAwesomeIcon icon={faArrowRight} /></Button>
</div>
</div>
}
function useLevelEditor(worldId: string, levelId: number, codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
const connection = React.useContext(ConnectionContext)
const gameId = React.useContext(GameIdContext)
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
// Create Editor
useEffect(() => {
const editor = monaco.editor.create(codeviewRef.current!, {
glyphMargin: true,
quickSuggestions: false,
lightbulb: {
enabled: true
},
unicodeHighlight: {
ambiguousCharacters: false,
},
automaticLayout: true,
minimap: {
enabled: false
},
lineNumbersMinChars: 3,
'semanticHighlighting.enabled': true,
theme: 'vs-code-theme-converted'
})
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
const editorApi = infoProvider.getApi()
const editorEvents: EditorEvents = {
initialize: new EventEmitter(),
gotServerNotification: new EventEmitter(),
sentClientNotification: new EventEmitter(),
serverRestarted: new EventEmitter(),
serverStopped: new EventEmitter(),
changedCursorLocation: new EventEmitter(),
changedInfoviewConfig: new EventEmitter(),
runTestScript: new EventEmitter(),
requestedAction: new EventEmitter(),
};
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
const infoviewApi: InfoviewApi = {
initialize: async l => editorEvents.initialize.fire(l),
gotServerNotification: async (method, params) => {
editorEvents.gotServerNotification.fire([method, params]);
},
sentClientNotification: async (method, params) => {
editorEvents.sentClientNotification.fire([method, params]);
},
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
serverStopped: async serverStoppedReason => {
editorEvents.serverStopped.fire(serverStoppedReason)
},
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
requestedAction: async action => editorEvents.requestedAction.fire(action),
// See https://rollupjs.org/guide/en/#avoiding-eval
// eslint-disable-next-line @typescript-eslint/no-implied-eval
runTestScript: async script => new Function(script)(),
getInfoviewHtml: async () => document.body.innerHTML,
};
const ec = new EditorConnection(editorApi, editorEvents);
setEditorConnection(ec)
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
setEditor(editor)
setInfoProvider(infoProvider)
setInfoviewApi(infoviewApi)
return () => { infoProvider.dispose(); editor.dispose() }
}, [])
const {leanClient, leanClientStarted} = useLeanClient(gameId)
// Create model when level changes
useEffect(() => {
if (editor && leanClientStarted) {
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
let model = monaco.editor.getModel(uri)
if (!model) {
model = monaco.editor.createModel(initialCode, 'lean4', uri)
}
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
editor.setModel(model)
if (initialSelections) {
editor.setSelections(initialSelections)
}
infoviewApi.serverRestarted(leanClient.initializeResult)
infoProvider.openPreview(editor, infoviewApi)
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
}
}, [editor, levelId, connection, leanClientStarted])
return {editor, infoProvider, editorConnection}
}
/** Open all files in this world on the server so that they will load faster when accessed */
function useLoadWorldFiles(worldId) {
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const store = useStore()
useEffect(() => {
if (gameInfo.data) {
const models = []
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
let model = monaco.editor.getModel(uri)
if (model) {
models.push(model)
} else {
const code = selectCode(gameId, worldId, levelId)(store.getState())
models.push(monaco.editor.createModel(code, 'lean4', uri))
}
}
return () => { for (let model of models) { model.dispose() } }
}
}, [gameInfo.data, worldId])
}

@ -1,61 +0,0 @@
import { faShield } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as React from 'react'
function PrivacyPolicyPopup ({handleClose}) {
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>Privacy Policy &amp; Impressum</h2>
<p>Our server collects metadata (such as IP address, browser, operating system)
and the data that the user enters into the editor. The data is used to
compute the Lean output and display it to the user. The information will be stored
as long as the user stays on our website and will be deleted immediately afterwards.
We keep logs to improve our software, but the contained data is anonymized.</p>
<p>We do not use cookies, but your game progress is stored in the browser
as site data. Your game progress is not saved on the server; if you delete
your browser storage, it is completely gone.
</p>
<p>Our server is located in Germany.</p>
<p><strong>Contact information:</strong><br />
Jon Eugster<br />
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br />
Universitätsstr. 1<br />
40225 Düsseldorf<br />
Germany<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>
</div>
}
const PrivacyPolicy: React.FC = () => {
const [open, setOpen] = React.useState(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<span>
<div className="privacy" onClick={handleOpen} title="Privacy Policy &amp; Impressum">
<FontAwesomeIcon icon={faShield} />
<p className="p1">legal</p>
<p className="p2">notes</p>
</div>
{open?
<PrivacyPolicyPopup handleClose={handleClose} />: null}
</span>
)
}
// export default PrivacyPolicy
export {
PrivacyPolicy,
PrivacyPolicyPopup,
}

@ -1,163 +0,0 @@
import * as React from 'react';
import { useState, useEffect, useRef } from 'react';
import './welcome.css'
import cytoscape, { LayoutOptions } from 'cytoscape'
import klay from 'cytoscape-klay';
import { useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import Split from 'react-split'
import GameMenu from './GameMenu';
import {PrivacyPolicy} from './PrivacyPolicy';
cytoscape.use( klay );
import { Box, Typography, CircularProgress } from '@mui/material';
import { useGetGameInfoQuery } from '../state/api';
import { Link } from 'react-router-dom';
import Markdown from './Markdown';
import { selectCompleted } from '../state/progress';
import { GameIdContext } from '../App';
import { Button } from './Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser } from '@fortawesome/free-solid-svg-icons'
const N = 24 // max number of levels per world
const R = 800 // radius of a world
const r = 110 // radius of a level
const s = 100 // global scale
const padding = 2000 // padding of the graphic (on a different scale)
function LevelIcon({ worldId, levelId, position }) {
const gameId = React.useContext(GameIdContext)
const completed = useSelector(selectCompleted(gameId, worldId,levelId))
const x = s * position.x + Math.sin(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
const y = s * position.y - Math.cos(levelId * 2 * Math.PI / N) * (R + 1.2*r + 2*Math.floor((levelId - 1)/N))
// TODO: relative positioning?
return (
<Link to={`/${gameId}/world/${worldId}/level/${levelId}`}>
<circle fill={completed ? "green" :"#999"} cx={x} cy={y} r={r} />
</Link>
)
}
function Welcome() {
const navigate = useNavigate();
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const { nodes, bounds }: any = gameInfo.data ? computeWorldLayout(gameInfo.data?.worlds) : {nodes: []}
useEffect(() => {
if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title
}
}, [gameInfo.data?.title])
const svgElements = []
if (gameInfo.data) {
for (let i in gameInfo.data.worlds.edges) {
const edge = gameInfo.data.worlds.edges[i]
svgElements.push(
<line key={`pathway${i}`} x1={s*nodes[edge[0]].position.x} y1={s*nodes[edge[0]].position.y}
x2={s*nodes[edge[1]].position.x} y2={s*nodes[edge[1]].position.y} stroke="#1976d2" strokeWidth={s}/>
)
}
for (let id in nodes) {
let position: cytoscape.Position = nodes[id].position
for (let i = 1; i <= gameInfo.data.worldSize[id]; i++) {
svgElements.push(
<LevelIcon
key={`/${gameId}/world/${id}/level/${i}`}
position={position} worldId={id} levelId={i} />
)
}
svgElements.push(
<Link key={`world${id}`} to={`/${gameId}/world/${id}/level/0`}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill="#1976d2"/>
<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 +")"}>
<div>
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
{nodes[id].data.title ? nodes[id].data.title : id}
</p>
</div>
</foreignObject>
</Link>
)
}
}
return <div className="app-content ">
{ gameInfo.isLoading?
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
<CircularProgress />
</Box>
:
<Split className="welcome" minSize={200} sizes={[70, 30]}>
<div className="column">
<Typography variant="body1" component="div" className="welcome-text">
<Markdown>{gameInfo.data?.introduction}</Markdown>
</Typography>
</div>
<div className="column">
<GameMenu />
<Box textAlign='center' sx={{ m: 5 }}>
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${s*bounds.x2 - s*bounds.x1 + 2 * padding} ${s*bounds.y2 - s*bounds.y1 + 2 * padding}` : ''}>
{svgElements}
</svg>
</Box>
</div>
</Split>
}
<PrivacyPolicy />
</div>
}
export default Welcome
function computeWorldLayout(worlds) {
let elements = []
for (let id in worlds.nodes) {
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
}
for (let edge of worlds.edges) {
elements.push({
data: {
id: edge[0] + " --edge-to--> " + edge[1],
source: edge[0],
target: edge[1]
}
})
}
const cy = cytoscape({
container: null,
elements,
headless: true,
styleEnabled: false
})
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
let nodes = {}
cy.nodes().forEach((node, id) => {
nodes[node.id()] = {
position: node.position(),
data: node.data()
}
})
const bounds = cy.nodes().boundingBox()
return { nodes, bounds }
}

@ -0,0 +1,15 @@
import * as React from 'react';
import { Link, LinkProps } from "react-router-dom";
export interface ButtonProps extends LinkProps {
disabled?: boolean
inverted?: string // Apparently "inverted" in DOM cannot be `boolean` but must be `inverted`
}
export function Button(props: ButtonProps) {
if (props.disabled) {
return <span className={`btn btn-disabled ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</span>
} else {
return <Link className={`btn ${props.inverted === "true" ? 'btn-inverted' : ''}`} {...props}>{props.children}</Link>
}
}

@ -0,0 +1,45 @@
import { GameHint } from "./infoview/rpc_api";
import * as React from 'react';
import Markdown from './markdown';
export function Hint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
<Markdown>{hint.text}</Markdown>
</div>
}
export function HiddenHint({hint, step, selected, toggleSelection} : {hint: GameHint, step: number, selected: number, toggleSelection: any}) {
return <div className={`message warning step-${step}` + (step == selected ? ' selected' : '')} onClick={toggleSelection}>
<Markdown>{hint.text}</Markdown>
</div>
}
export function Hints({hints, showHidden, step, selected, toggleSelection} : {hints: GameHint[], showHidden: boolean, step: number, selected: number, toggleSelection: any}) {
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
// TODO: Should not use index as key.
return <>
{openHints.map((hint, j) => <Hint key={`hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
{showHidden && hiddenHints.map((hint, j) => <HiddenHint key={`hidden-hint-${step}-${j}`} hint={hint} step={step} selected={selected} toggleSelection={toggleSelection}/>)}
</>
}
export function DeletedHint({hint} : {hint: GameHint}) {
return <div className="message information deleted-hint">
<Markdown>{hint.text}</Markdown>
</div>
}
export function DeletedHints({hints} : {hints: GameHint[]}) {
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
// TODO: Should not use index as key.
return <>
{openHints.map((hint, i) => <DeletedHint key={`deleted-hint-${i}`} hint={hint} />)}
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
</>
}

@ -1,196 +0,0 @@
import * as React from 'react'
import { useRef, useState, useEffect } from 'react'
import { LspDiagnosticsContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
import { InputModeContext, MonacoEditorContext } from '../Level'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
import { Registry } from 'monaco-textmate' // peer dependency
import { wireTmGrammars } from 'monaco-editor-textmate'
import * as lightPlusTheme from 'lean4web/client/src/lightPlus.json'
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
import languageConfig from 'lean4/language-configuration.json';
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
// register Monaco languages
monaco.languages.register({
id: 'lean4cmd',
extensions: ['.leancmd']
})
// map of monaco "language id's" to TextMate scopeNames
const grammars = new Map()
grammars.set('lean4', 'source.lean')
grammars.set('lean4cmd', 'source.lean')
const registry = new Registry({
getGrammarDefinition: async (scopeName) => {
if (scopeName === 'source.lean') {
return {
format: 'json',
content: JSON.stringify(leanSyntax)
}
} else if (scopeName === 'source.lean.markdown') {
return {
format: 'json',
content: JSON.stringify(leanMarkdownSyntax)
}
} else {
return {
format: 'json',
content: JSON.stringify(codeblockSyntax)
}
}
}
});
wireTmGrammars(monaco, registry, grammars)
let config: any = { ...languageConfig }
config.autoClosingPairs = config.autoClosingPairs.map(
pair => { return {'open': pair[0], 'close': pair[1]} }
)
monaco.languages.setLanguageConfiguration('lean4cmd', config);
export function CommandLine() {
/** Reference to the hidden multi-line editor */
const editor = React.useContext(MonacoEditorContext)
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
const [processing, setProcessing] = useState(false)
const { commandLineMode, commandLineInput, setCommandLineInput } = React.useContext(InputModeContext)
const inputRef = useRef()
// Run the command
const runCommand = React.useCallback(() => {
if (processing) return;
const pos = editor.getPosition()
editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions(
pos,
editor.getModel().getFullModelRange().getEndPosition()
),
text: commandLineInput + "\n",
forceMoveMarkers: false
}]);
editor.setPosition(pos)
setProcessing(true)
}, [commandLineInput, editor])
useEffect(() => {
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
oneLineEditor.setValue(commandLineInput)
}
}, [commandLineInput])
// React when answer from the server comes back
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (params.uri == editor.getModel().uri.toString()) {
setProcessing(false)
if (!hasErrorsOrWarnings(params.diagnostics)) {
setCommandLineInput("")
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
}
}
}, []);
useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, {
value: commandLineInput,
language: "lean4cmd",
quickSuggestions: false,
lightbulb: {
enabled: true
},
unicodeHighlight: {
ambiguousCharacters: false,
},
automaticLayout: true,
minimap: {
enabled: false
},
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
'semanticHighlighting.enabled': true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollbar: {
vertical: 'hidden',
horizontalScrollbarSize: 3
},
overviewRulerBorder: false,
theme: 'vs-code-theme-converted',
contextmenu: false
})
setOneLineEditor(myEditor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
}, [])
useEffect(() => {
if (!oneLineEditor) return
// Ensure that our one-line editor can only have a single line
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
const value = oneLineEditor.getValue()
setCommandLineInput(value)
const newValue = value.replace(/[\n\r]/g, '')
if (value != newValue) {
oneLineEditor.setValue(newValue)
}
})
return () => { l.dispose() }
}, [oneLineEditor, setCommandLineInput])
useEffect(() => {
if (!oneLineEditor) return
// Run command when pressing enter
const l = oneLineEditor.onKeyUp((ev) => {
if (ev.code === "Enter") {
runCommand()
}
})
return () => { l.dispose() }
}, [oneLineEditor, runCommand])
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
ev.preventDefault()
runCommand()
}
return <div className="command-line">
<form onSubmit={handleSubmit}>
<div className="command-line-input-wrapper">
<div ref={inputRef} className="command-line-input" />
</div>
<button type="submit" disabled={processing} className="btn btn-inverted"><FontAwesomeIcon icon={faWandMagicSparkles} /> Execute</button>
</form>
</div>
}
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
been completed.*/
function hasErrorsOrWarnings(diags) {
return diags.some(
(d) =>
!d.message.startsWith("unsolved goals") &&
(d.severity == DiagnosticSeverity.Error || d.severity == DiagnosticSeverity.Warning)
)
}

@ -0,0 +1,341 @@
import * as React from 'react'
import { useRef, useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { Registry } from 'monaco-textmate' // peer dependency
import { wireTmGrammars } from 'monaco-editor-textmate'
import { DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
import { useServerNotificationEffect } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter';
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider';
import * as leanSyntax from 'lean4web/client/src/syntaxes/lean.json'
import * as leanMarkdownSyntax from 'lean4web/client/src/syntaxes/lean-markdown.json'
import * as codeblockSyntax from 'lean4web/client/src/syntaxes/codeblock.json'
import languageConfig from 'lean4/language-configuration.json';
import { InteractiveDiagnostic, getInteractiveDiagnostics } from '@leanprover/infoview-api';
import { Diagnostic } from 'vscode-languageserver-types';
import { DocumentPosition } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { DeletedChatContext, InputModeContext, MonacoEditorContext, ProofContext, ProofStep } from './context'
import { goalsToString } from './goals'
import { GameHint, InteractiveGoals } from './rpc_api'
/* We register a new language `leancmd` that looks like lean4, but does not use the lsp server. */
// register Monaco languages
monaco.languages.register({
id: 'lean4cmd',
extensions: ['.leancmd']
})
// map of monaco "language id's" to TextMate scopeNames
const grammars = new Map()
grammars.set('lean4', 'source.lean')
grammars.set('lean4cmd', 'source.lean')
const registry = new Registry({
getGrammarDefinition: async (scopeName) => {
if (scopeName === 'source.lean') {
return {
format: 'json',
content: JSON.stringify(leanSyntax)
}
} else if (scopeName === 'source.lean.markdown') {
return {
format: 'json',
content: JSON.stringify(leanMarkdownSyntax)
}
} else {
return {
format: 'json',
content: JSON.stringify(codeblockSyntax)
}
}
}
});
wireTmGrammars(monaco, registry, grammars)
let config: any = { ...languageConfig }
config.autoClosingPairs = config.autoClosingPairs.map(
pair => { return {'open': pair[0], 'close': pair[1]} }
)
monaco.languages.setLanguageConfiguration('lean4cmd', config);
/** The input field */
export function CommandLine({proofPanelRef}: {proofPanelRef: React.MutableRefObject<HTMLDivElement>}) {
/** Reference to the hidden multi-line editor */
const editor = React.useContext(MonacoEditorContext)
const model = editor.getModel()
const uri = model.uri.toString()
const [oneLineEditor, setOneLineEditor] = useState<monaco.editor.IStandaloneCodeEditor>(null)
const [processing, setProcessing] = useState(false)
const {commandLineInput, setCommandLineInput} = React.useContext(InputModeContext)
const inputRef = useRef()
// The context storing all information about the current proof
const {proof, setProof} = React.useContext(ProofContext)
// state to store the last batch of deleted messages
const {setDeletedChat} = React.useContext(DeletedChatContext)
// TODO: does the position matter at all?
const rpcSess = useRpcSessionAtPos({uri: uri, line: 1, character: 1})
/** Load all goals an messages of the current proof (line-by-line) and save
* the retrieved information into context (`ProofContext`)
*/
const loadAllGoals = React.useCallback(() => {
let goalCalls = []
let msgCalls = []
// For each line of code ask the server for the goals and the messages on this line
for (let i = 0; i < model.getLineCount(); i++) {
goalCalls.push(
rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp({line: i, character: 0, uri: uri}))
)
msgCalls.push(
getInteractiveDiagnostics(rpcSess, {start: i, end: i+1})
)
}
// Wait for all these requests to be processed before saving the results
Promise.all(goalCalls).then((steps : InteractiveGoals[]) => {
Promise.all(msgCalls).then((diagnostics : [InteractiveDiagnostic[]]) => {
let tmpProof : ProofStep[] = []
let goalCount = 0
steps.map((goals, i) => {
// The first step has an empty command and therefore also no error messages
// Usually there is a newline at the end of the editors content, so we need to
// display diagnostics from potentally two lines in the last step.
let messages = i ? (i == steps.length - 1 ? diagnostics.slice(i-1).flat() : diagnostics[i-1]) : []
// Filter out the 'unsolved goals' message
messages = messages.filter((msg) => {
return !("append" in msg.message &&
"text" in msg.message.append[0] &&
msg.message.append[0].text === "unsolved goals")
})
if (typeof goals == 'undefined') {
tmpProof.push({
command: i ? model.getLineContent(i) : '',
goals: [],
hints: [],
errors: messages
} as ProofStep)
console.debug('goals is undefined')
return
}
// If the number of goals reduce, show a message
if (goals.goals.length && goalCount > goals.goals.length) {
messages.unshift({
range: {
start: {
line: i-1,
character: 0,
},
end: {
line: i-1,
character: 0,
}},
severity: DiagnosticSeverity.Information,
message: {
text: 'intermediate goal solved 🎉'
}
})
}
goalCount = goals.goals.length
// with no goals there will be no hints.
let hints : GameHint[] = goals.goals.length ? goals.goals[0].hints : []
console.debug(`Command (${i}): `, i ? model.getLineContent(i) : '')
console.debug(`Goals: (${i}): `, goalsToString(goals)) //
console.debug(`Hints: (${i}): `, hints)
console.debug(`Errors: (${i}): `, messages)
tmpProof.push({
// the command of the line above. Note that `getLineContent` starts counting
// at `1` instead of `zero`. The first ProofStep will have an empty command.
command: i ? model.getLineContent(i) : '',
// TODO: store correct data
goals: goals.goals,
// only need the hints of the active goals in chat
hints: hints,
// errors and messages from the server
errors: messages
} as ProofStep)
})
// Save the proof to the context
setProof(tmpProof)
console.debug('updated proof')
proofPanelRef.current?.lastElementChild?.scrollIntoView()
})
})
}, [editor, rpcSess, uri, model, proofPanelRef])
// Run the command
const runCommand = React.useCallback(() => {
if (processing) {return}
// TODO: Desired logic is to only reset this after a new *error-free* command has been entered
setDeletedChat([])
const pos = editor.getPosition()
if (commandLineInput) {
setProcessing(true)
editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions(
pos,
editor.getModel().getFullModelRange().getEndPosition()
),
text: commandLineInput.trim() + "\n",
forceMoveMarkers: false
}])
}
editor.setPosition(pos)
}, [commandLineInput, editor])
useEffect(() => {
if (oneLineEditor && oneLineEditor.getValue() !== commandLineInput) {
oneLineEditor.setValue(commandLineInput)
}
}, [commandLineInput])
// React when answer from the server comes back
useServerNotificationEffect('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => {
if (params.uri == uri) {
setProcessing(false)
loadAllGoals()
if (!hasErrors(params.diagnostics)) {
setCommandLineInput("")
editor.setPosition(editor.getModel().getFullModelRange().getEndPosition())
}
} else {
// console.debug(`expected uri: ${uri}, got: ${params.uri}`)
// console.debug(params)
}
// TODO: This is the wrong place apparently. Where do wee need to load them?
// TODO: instead of loading all goals every time, we could only load the last one
// loadAllGoals()
}, [uri]);
useEffect(() => {
const myEditor = monaco.editor.create(inputRef.current!, {
value: commandLineInput,
language: "lean4cmd",
quickSuggestions: false,
lightbulb: {
enabled: true
},
unicodeHighlight: {
ambiguousCharacters: false,
},
automaticLayout: true,
minimap: {
enabled: false
},
lineNumbers: 'off',
tabSize: 2,
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
'semanticHighlighting.enabled': true,
overviewRulerLanes: 0,
hideCursorInOverviewRuler: true,
scrollbar: {
vertical: 'hidden',
horizontalScrollbarSize: 3
},
overviewRulerBorder: false,
theme: 'vs-code-theme-converted',
contextmenu: false
})
setOneLineEditor(myEditor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), myEditor.getModel(), myEditor)
return () => {abbrevRewriter.dispose(); myEditor.dispose()}
}, [])
useEffect(() => {
if (!oneLineEditor) return
// Ensure that our one-line editor can only have a single line
const l = oneLineEditor.getModel().onDidChangeContent((e) => {
const value = oneLineEditor.getValue()
setCommandLineInput(value)
const newValue = value.replace(/[\n\r]/g, '')
if (value != newValue) {
oneLineEditor.setValue(newValue)
}
})
return () => { l.dispose() }
}, [oneLineEditor, setCommandLineInput])
useEffect(() => {
if (!oneLineEditor) return
// Run command when pressing enter
const l = oneLineEditor.onKeyUp((ev) => {
if (ev.code === "Enter") {
runCommand()
}
})
return () => { l.dispose() }
}, [oneLineEditor, runCommand])
// BUG: Causes `file closed` error
//TODO: Intention is to run once when loading, does that work?
useEffect(() => {
console.debug(`time to update: ${uri} \n ${rpcSess}`)
console.debug(rpcSess)
loadAllGoals()
}, [rpcSess])
/** Process the entered command */
const handleSubmit : React.FormEventHandler<HTMLFormElement> = (ev) => {
ev.preventDefault()
runCommand()
}
return <div className="command-line">
<form onSubmit={handleSubmit}>
<div className="command-line-input-wrapper">
<div ref={inputRef} className="command-line-input" />
</div>
<button type="submit" disabled={processing} className="btn btn-inverted">
<FontAwesomeIcon icon={faWandMagicSparkles} /> Execute
</button>
</form>
</div>
}
/** Checks whether the diagnostics contain any errors or warnings to check whether the level has
been completed.*/
export function hasErrors(diags: Diagnostic[]) {
return diags.some(
(d) =>
!d.message.startsWith("unsolved goals") &&
(d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
)
}
// TODO: Didn't manage to unify this with the one above
export function hasInteractiveErrors (diags: InteractiveDiagnostic[]) {
return (typeof diags !== 'undefined') && diags.some(
(d) => (d.severity == DiagnosticSeverity.Error ) // || d.severity == DiagnosticSeverity.Warning
)
}

@ -0,0 +1,113 @@
/**
* @fileOverview This file contains the the react contexts used in the project.
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
null as any)
export type InfoStatus = 'updating' | 'error' | 'ready';
/** One step of the proof */
export type ProofStep = {
/** The command in this step */
command : string
/** List of goals *after* this command */
goals: InteractiveGoal[] // TODO: Add correct type
/** Story relevant messages */
hints: GameHint[] // TODO: Add correct type
/** Errors and warnings */
errors: InteractiveDiagnostic[] // TODO: Add correct type
}
/** The context storing the proof step-by-step for the command line mode */
export const ProofContext = React.createContext<{
/** The proof consists of multiple steps that are processed one after the other.
* In particular multi-line terms like `match`-statements will not be supported.
*
* Note that the first step will always have `null` as command
*/
proof: ProofStep[],
setProof: React.Dispatch<React.SetStateAction<Array<ProofStep>>>
}>({
proof: [],
setProof: () => {} // TODO: implement me
})
export interface ProofStateProps {
// pos: DocumentPosition;
status: InfoStatus;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
termGoal?: InteractiveTermGoal;
error?: string;
// userWidgets: UserWidgetInstance[];
// rpcSess: RpcSessionAtPos;
// triggerUpdate: () => Promise<void>;
}
export const ProofStateContext = React.createContext<{
proofState : ProofStateProps,
setProofState: React.Dispatch<React.SetStateAction<ProofStateProps>>
}>({
proofState : {
status: 'updating',
messages: [],
goals: undefined,
termGoal: undefined,
error: undefined},
setProofState: () => {},
})
export const MobileContext = React.createContext<{
mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>
}>({
mobile : false,
setMobile: () => {},
})
export const WorldLevelIdContext = React.createContext<{
worldId : string,
levelId: number
}>({
worldId : null,
levelId: 0,
})
/** Context to keep highlight selected proof step and corresponding chat messages. */
export const SelectionContext = React.createContext<{
selectedStep : number,
setSelectedStep: React.Dispatch<React.SetStateAction<number>>
}>({
selectedStep : undefined,
setSelectedStep: () => {}
})
/** Context for deleted Hints that are visible just a bit after they've been deleted */
export const DeletedChatContext = React.createContext<{
deletedChat : GameHint[],
setDeletedChat: React.Dispatch<React.SetStateAction<Array<GameHint>>>
showHelp : Set<number>,
setShowHelp: React.Dispatch<React.SetStateAction<Set<number>>>
}>({
deletedChat: undefined,
setDeletedChat: () => {},
showHelp: undefined,
setShowHelp: () => {}
})
export const InputModeContext = React.createContext<{
commandLineMode: boolean,
setCommandLineMode: React.Dispatch<React.SetStateAction<boolean>>,
commandLineInput: string,
setCommandLineInput: React.Dispatch<React.SetStateAction<string>>
}>({
commandLineMode: true,
setCommandLineMode: () => {},
commandLineInput: "",
setCommandLineInput: () => {},
});

@ -1,15 +1,16 @@
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx */
/**
* @fileOverview
*
* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/goals.tsx
*/
import * as React from 'react'
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { InteractiveHypothesisBundle_nonAnonymousNames, MVarId, TaggedText_stripTags } from '@leanprover/infoview-api'
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
import { EditorContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { Locations, LocationsContext, SelectableLocation } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpcApi';
import { Hints } from './hints';
import { CommandLine } from './CommandLine';
import { InputModeContext } from '../Level';
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { WithTooltipOnHover } from '../../../../node_modules/lean4-infoview/src/infoview/tooltips';
import { InputModeContext } from './context';
import { InteractiveGoal, InteractiveGoals, InteractiveHypothesisBundle } from './rpc_api';
/** Returns true if `h` is inaccessible according to Lean's default name rendering. */
function isInaccessibleName(h: string): boolean {
@ -137,6 +138,9 @@ interface ProofDisplayProps {
export const Goal = React.memo((props: GoalProps) => {
const { goal, filter, showHints, commandLine } = props
// TODO: Apparently `goal` can be `undefined`
if (!goal) {return <></>}
const filteredList = getFilteredHypotheses(goal.hyps, filter);
const hyps = filter.reverse ? filteredList.slice().reverse() : filteredList;
const locs = React.useContext(LocationsContext)
@ -156,7 +160,7 @@ export const Goal = React.memo((props: GoalProps) => {
// if (props.goal.isInserted) cn += 'b--inserted '
// if (props.goal.isRemoved) cn += 'b--removed '
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 assumptionHyps = hyps.filter(hyp => hyp.isAssumption)
const {commandLineMode} = React.useContext(InputModeContext)
@ -170,9 +174,9 @@ export const Goal = React.memo((props: GoalProps) => {
{!commandLine && assumptionHyps.length > 0 &&
<div className="hyp-group"><div className="hyp-group-title">Assumptions:</div>
{assumptionHyps.map((h, i) => <Hyp hyp={h} mvarId={goal.mvarId} key={i} />)}</div> }
{commandLine && commandLineMode && <CommandLine />}
{/* {commandLine && commandLineMode && <CommandLine />} */}
{!filter.reverse && goalLi}
{showHints && hints}
{/* {showHints && hints} */}
</div>
})
@ -231,6 +235,7 @@ export const OtherGoals = React.memo((props: GoalProps2) => {
</>
})
// TODO: deprecated
export const ProofDisplay = React.memo((props : ProofDisplayProps) => {
const { proof } = props
const steps = proof.match(/.+/g)

@ -1,27 +0,0 @@
import { GameHint } from "./rpcApi";
import * as React from 'react';
import { Alert, FormControlLabel, Switch } from '@mui/material';
import Markdown from '../Markdown';
function Hint({hint} : {hint: GameHint}) {
return <div className="message info"><Markdown>{hint.text}</Markdown></div>
}
export function Hints({hints} : {hints: GameHint[]}) {
const [showHints, setShowHints] = React.useState(false);
const openHints = hints.filter(hint => !hint.hidden)
const hiddenHints = hints.filter(hint => hint.hidden)
return <>
{openHints.map(hint => <Hint hint={hint} />)}
{hiddenHints.length > 0 &&
<FormControlLabel
control={<Switch checked={showHints} onChange={() => setShowHints((prev) => !prev)} />}
label="I need help!"
/>}
{showHints && hiddenHints.map(hint => <Hint hint={hint} />)}
</>
}

@ -1,53 +1,50 @@
/* Mostly copied from https://github.com/leanprover/vscode-lean4/blob/master/lean4-infoview/src/infoview/info.tsx */
import * as React from 'react';
import type { Location, Diagnostic } from 'vscode-languageserver-protocol';
import { goalsToString, Goal, MainAssumptions, OtherGoals, FilteredGoals, ProofDisplay } from './goals'
import * as React from 'react'
import { CircularProgress } from '@mui/material'
import type { Location, Diagnostic } from 'vscode-languageserver-protocol'
import { getInteractiveTermGoal, InteractiveDiagnostic, UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api'
import { basename, DocumentPosition, RangeHelpers, useEvent, usePausableState, discardMethodNotFound,
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { AllMessages, lspDiagToInteractive, MessagesList } from './messages';
import { getInteractiveTermGoal, InteractiveDiagnostic,
UserWidgetInstance, Widget_getWidgets, RpcSessionAtPos, isRpcError,
RpcErrorCode, getInteractiveDiagnostics, InteractiveTermGoal } from '@leanprover/infoview-api';
import { InteractiveGoal, InteractiveGoals } from './rpcApi';
mapRpcError, useAsyncWithTrigger, PausableProps } from '../../../../node_modules/lean4-infoview/src/infoview/util'
import { ConfigContext, EditorContext, LspDiagnosticsContext, ProgressContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
import { PanelWidgetDisplay } from '../../../../node_modules/lean4-infoview/src/infoview/userWidget'
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation';
import { InteractiveCode } from '../../../../node_modules/lean4-infoview/src/infoview/interactiveCode'
import { CircularProgress } from '@mui/material';
import { InputModeContext, MonacoEditorContext } from '../Level'
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
import { GoalsLocation, Locations, LocationsContext } from '../../../../node_modules/lean4-infoview/src/infoview/goalLocation'
import { AllMessages, lspDiagToInteractive } from './messages'
import { goalsToString, Goal, MainAssumptions, OtherGoals, ProofDisplay } from './goals'
import { InteractiveGoals } from './rpc_api'
import { MonacoEditorContext, ProofStateProps, InfoStatus, ProofContext } from './context'
type InfoStatus = 'updating' | 'error' | 'ready';
type InfoKind = 'cursor' | 'pin';
// TODO: All about pinning could probably be removed
type InfoKind = 'cursor' | 'pin'
interface InfoPinnable {
kind: InfoKind;
kind: InfoKind
/** Takes an argument for caching reasons, but should only ever (un)pin itself. */
onPin: (pos: DocumentPosition) => void;
onPin: (pos: DocumentPosition) => void
}
interface InfoStatusBarProps extends InfoPinnable, PausableProps {
pos: DocumentPosition;
status: InfoStatus;
triggerUpdate: () => Promise<void>;
pos: DocumentPosition
status: InfoStatus
triggerUpdate: () => Promise<void>
}
const InfoStatusBar = React.memo((props: InfoStatusBarProps) => {
const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props;
const { kind, onPin, status, pos, isPaused, setPaused, triggerUpdate } = props
const ec = React.useContext(EditorContext);
const ec = React.useContext(EditorContext)
const statusColTable: {[T in InfoStatus]: string} = {
'updating': 'gold ',
'error': 'dark-red ',
'ready': '',
}
const statusColor = statusColTable[status];
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`;
const isPinned = kind === 'pin';
const statusColor = statusColTable[status]
const locationString = `${basename(pos.uri)}:${pos.line+1}:${pos.character}`
const isPinned = kind === 'pin'
return (
<summary style={{transition: 'color 0.5s ease'}} className={'mv2 pointer ' + statusColor}>
@ -67,36 +64,36 @@ const InfoStatusBar = React.memo((props: InfoStatusBarProps) => {
title={isPinned ? 'unpin' : 'pin'} />
<a className={'link pointer mh2 dim codicon ' + (isPaused ? 'codicon-debug-continue ' : 'codicon-debug-pause ')}
data-id='toggle-paused'
onClick={e => { e.preventDefault(); setPaused(!isPaused); }}
onClick={e => { e.preventDefault(); setPaused(!isPaused) }}
title={isPaused ? 'continue updating' : 'pause updating'} />
<a className='link pointer mh2 dim codicon codicon-refresh'
data-id='update'
onClick={e => { e.preventDefault(); void triggerUpdate(); }}
onClick={e => { e.preventDefault(); void triggerUpdate() }}
title='update'/>
</span>
</summary>
);
)
})
interface InfoDisplayContentProps extends PausableProps {
pos: DocumentPosition;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
termGoal?: InteractiveTermGoal;
error?: string;
userWidgets: UserWidgetInstance[];
triggerUpdate: () => Promise<void>;
proof? : string;
pos: DocumentPosition
messages: InteractiveDiagnostic[]
goals?: InteractiveGoals
termGoal?: InteractiveTermGoal
error?: string
userWidgets: UserWidgetInstance[]
triggerUpdate: () => Promise<void>
proof? : string
}
const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props;
const {pos, messages, goals, termGoal, error, userWidgets, triggerUpdate, isPaused, setPaused, proof} = props
const hasWidget = userWidgets.length > 0;
const hasError = !!error;
const hasMessages = messages.length !== 0;
const hasWidget = userWidgets.length > 0
const hasError = !!error
const hasMessages = messages.length !== 0
const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget;
const nothingToShow = !hasError && !goals && !termGoal && !hasMessages && !hasWidget
const [selectedLocs, setSelectedLocs] = React.useState<GoalsLocation[]>([])
React.useEffect(() => setSelectedLocs([]), [pos.uri, pos.line, pos.character])
@ -121,13 +118,12 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
{hasError &&
<div className='error' key='errors'>
Error updating:{' '}{error}.
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>{' '}Try again.</a>
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>{' '}Try again.</a>
</div>}
<LocationsContext.Provider value={locs}>
<div className="goals-section">
{ goals && goals.goals.length > 0 && <>
<MainAssumptions filter={goalFilter} key='mainGoal' goals={goals.goals} />
<ProofDisplay proof={proof}/>
<OtherGoals filter={goalFilter} goals={goals.goals} />
</>}
</div>
@ -149,8 +145,8 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
isPaused ?
/* Adding {' '} to manage string literals properly: https://reactjs.org/docs/jsx-in-depth.html#string-literals-1 */
<span>Updating is paused.{' '}
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate(); }}>Refresh</a>
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false); }}>resume updating</a>
<a className='link pointer dim' onClick={e => { e.preventDefault(); void triggerUpdate() }}>Refresh</a>
{' '}or <a className='link pointer dim' onClick={e => { e.preventDefault(); setPaused(false) }}>resume updating</a>
{' '}to see information.
</span> :
<><CircularProgress /><div>Loading goal...</div></>)}
@ -167,49 +163,49 @@ const InfoDisplayContent = React.memo((props: InfoDisplayContentProps) => {
})
interface InfoDisplayProps {
pos: DocumentPosition;
status: InfoStatus;
messages: InteractiveDiagnostic[];
goals?: InteractiveGoals;
termGoal?: InteractiveTermGoal;
error?: string;
userWidgets: UserWidgetInstance[];
rpcSess: RpcSessionAtPos;
triggerUpdate: () => Promise<void>;
pos: DocumentPosition,
status: InfoStatus,
messages: InteractiveDiagnostic[],
goals?: InteractiveGoals,
termGoal?: InteractiveTermGoal,
error?: string,
userWidgets: UserWidgetInstance[],
rpcSess: RpcSessionAtPos,
triggerUpdate: () => Promise<void>,
}
/** Displays goal state and messages. Can be paused. */
function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
function InfoDisplay(props0: ProofStateProps & InfoDisplayProps & InfoPinnable) {
// Used to update the paused state *just once* if it is paused,
// but a display update is triggered
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false);
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0);
const [shouldRefresh, setShouldRefresh] = React.useState<boolean>(false)
const [{ isPaused, setPaused }, props, propsRef] = usePausableState(false, props0)
if (shouldRefresh) {
propsRef.current = props0;
setShouldRefresh(false);
propsRef.current = props0
setShouldRefresh(false)
}
const triggerDisplayUpdate = async () => {
await props0.triggerUpdate();
setShouldRefresh(true);
};
await props0.triggerUpdate()
setShouldRefresh(true)
}
const {kind, goals, rpcSess} = props;
const {kind, goals, rpcSess} = props
const ec = React.useContext(EditorContext);
const ec = React.useContext(EditorContext)
// If we are the cursor infoview, then we should subscribe to
// some commands from the editor extension
const isCursor = kind === 'cursor';
const isCursor = kind === 'cursor'
useEvent(ec.events.requestedAction, act => {
if (!isCursor) return;
if (act.kind !== 'copyToComment') return;
if (goals) void ec.copyToComment(goalsToString(goals));
}, [goals]);
if (!isCursor) return
if (act.kind !== 'copyToComment') return
if (goals) void ec.copyToComment(goalsToString(goals))
}, [goals])
useEvent(ec.events.requestedAction, act => {
if (!isCursor) return;
if (act.kind !== 'togglePaused') return;
setPaused(isPaused => !isPaused);
});
if (!isCursor) return
if (act.kind !== 'togglePaused') return
setPaused(isPaused => !isPaused)
})
const editor = React.useContext(MonacoEditorContext)
@ -217,12 +213,12 @@ function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
<RpcContext.Provider value={rpcSess}>
{/* <details open> */}
{/* <InfoStatusBar {...props} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} /> */}
<div>
<div className="vscode-light">
<InfoDisplayContent {...props} proof={editor.getValue()} triggerUpdate={triggerDisplayUpdate} isPaused={isPaused} setPaused={setPaused} />
</div>
{/* </details> */}
</RpcContext.Provider>
);
)
}
/**
@ -230,7 +226,7 @@ function InfoDisplay(props0: InfoDisplayProps & InfoPinnable) {
* to avoid flickering when the cursor moved. Otherwise, the component is re-initialised and the
* goal states reset to `undefined` on cursor moves.
*/
export type InfoProps = InfoPinnable & { pos?: DocumentPosition };
export type InfoProps = InfoPinnable & { pos?: DocumentPosition }
/** Fetches info from the server and renders an {@link InfoDisplay}. */
export function Info(props: InfoProps) {
@ -239,22 +235,25 @@ export function Info(props: InfoProps) {
}
function InfoAtCursor(props: InfoProps) {
const ec = React.useContext(EditorContext);
const ec = React.useContext(EditorContext)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!);
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), []);
const pos = { uri: curLoc.uri, ...curLoc.range.start };
const [curLoc, setCurLoc] = React.useState<Location>(ec.events.changedCursorLocation.current!)
useEvent(ec.events.changedCursorLocation, loc => loc && setCurLoc(loc), [])
const pos = { uri: curLoc.uri, ...curLoc.range.start }
return <InfoAux {...props} pos={pos} />
}
function useIsProcessingAt(p: DocumentPosition): boolean {
const allProgress = React.useContext(ProgressContext);
const processing = allProgress.get(p.uri);
if (!processing) return false;
return processing.some(i => RangeHelpers.contains(i.range, p));
const allProgress = React.useContext(ProgressContext)
const processing = allProgress.get(p.uri)
if (!processing) return false
return processing.some(i => RangeHelpers.contains(i.range, p))
}
function InfoAux(props: InfoProps) {
const proofContext = React.useContext(ProofContext)
const config = React.useContext(ConfigContext)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -291,7 +290,7 @@ function InfoAux(props: InfoProps) {
// with e.g. a new `pos`.
type InfoRequestResult = Omit<InfoDisplayProps, 'triggerUpdate'>
const [state, triggerUpdateCore] = useAsyncWithTrigger(() => new Promise<InfoRequestResult>((resolve, reject) => {
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos));
const goalsReq = rpcSess.call('Game.getInteractiveGoals', DocumentPosition.toTdpp(pos))
const termGoalReq = getInteractiveTermGoal(rpcSess, DocumentPosition.toTdpp(pos))
const widgetsReq = Widget_getWidgets(rpcSess, pos).catch(discardMethodNotFound)
const messagesReq = getInteractiveDiagnostics(rpcSess, {start: pos.line, end: pos.line+1})
@ -405,10 +404,14 @@ function InfoAux(props: InfoProps) {
React.useEffect(() => {
if (state.state === 'notStarted')
void triggerUpdate()
else if (state.state === 'loading')
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
else if (state.state === 'loading') {
setDisplayProps(dp => ({ ...dp, status: 'updating' }))
}
else if (state.state === 'resolved') {
setDisplayProps({ ...state.value, triggerUpdate })
// if (state.value.goals?.goals?.length) {
// hintContext.setHints(state.value.goals.goals[0].hints)
// }
setDisplayProps({ ...state.value, triggerUpdate })
} else if (state.state === 'rejected' && state.error !== 'retry') {
// The code inside `useAsyncWithTrigger` may only ever reject with a `retry` exception.
console.warn('Unreachable code reached with error: ', state.error)

@ -4,8 +4,9 @@
padding: 5px 10px;
border-radius: 3px 3px 3px 3px;
}
.message.info {
.message.information, .message.info {
/* color: #059; */
color: #000;
background-color: #DDF6FF;
}
.message.warning {
@ -17,6 +18,12 @@
background-color: #FFBABA;
}
.message.deleted-hint {
background-color: #eee;
color: #777;
box-shadow: .0em .0em .5em .2em #eee;
}
.hyp-group {
margin-bottom: 0.3em;
}
@ -41,14 +48,14 @@
padding: 0.5em;
font-family: var(--ff-primary);
border-radius: 0.2em;
margin: 0.2em 0;
/* margin: 0.2em 0 0; */
}
.command-line form {
display: flex;
}
.command-line button {
.command-line button, .undo-button {
display: block;
white-space: nowrap;
flex: 0;
@ -64,7 +71,7 @@
flex-direction: column;
}
.command-line .command-line-input{
.command-line .command-line-input {
flex: 1;
}
@ -128,3 +135,41 @@
border-radius: 1em;
padding: 0.6em;
}
/* Push the goals to the bottom for now, until we insert the proof history above. */
.commandline-interface .content {
display: flex;
flex-direction: column;
scroll-behavior: smooth;
}
/* TODO this is in the wrong file */
.chat {
scroll-behavior: smooth;
}
.commandline-interface .content .tmp-pusher {
flex: 1;
}
.exercise .command {
background-color: #bbb;
padding: .5em;
border-radius: .2em;
margin-top: 2rem;
display: flex;
flex-direction: row;
}
.exercise .command-text {
flex: 1;
background-color: #fff;
font-family: "Droid Sans Mono", "monospace", monospace;
font-size: 14px;
padding: 0.4em .6em 0;
}
.undo-button {
color: #888;
}

@ -4,100 +4,477 @@ import * as React from 'react';
import type { DidCloseTextDocumentParams, DidChangeTextDocumentParams, Location, DocumentUri } from 'vscode-languageserver-protocol';
import 'tachyons/css/tachyons.css';
// import '@vscode/codicons/dist/codicon.ttf';
import '@vscode/codicons/dist/codicon.css';
import '../../../../node_modules/lean4-infoview/src/infoview/index.css';
import './infoview.css'
import { LeanFileProgressParams, LeanFileProgressProcessingInfo, defaultInfoviewConfig, EditorApi, InfoviewApi } from '@leanprover/infoview-api';
import { Infos } from './infos';
import { AllMessages, WithLspDiagnosticsContext } from './messages';
import { useClientNotificationEffect, useServerNotificationEffect, useEventResult, useServerNotificationState } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { EditorContext, ConfigContext, ProgressContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { WithRpcSessions } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { ServerVersion } from '../../../../node_modules/lean4-infoview/src/infoview/serverVersion';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDeleteLeft, faHome, faArrowRight, faArrowLeft, faRotateLeft } from '@fortawesome/free-solid-svg-icons'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { GameIdContext } from '../../app';
import { useAppDispatch, useAppSelector } from '../../hooks';
import { levelCompleted, selectCompleted } from '../../state/progress';
import { GameIdContext } from '../../App';
import { LevelInfo } from '../../state/api';
import { changedInventory, levelCompleted, selectCompleted, selectInventory } from '../../state/progress';
import Markdown from '../markdown';
import { Infos } from './infos';
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
import { Goal } from './goals';
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext } from './context';
import { CommandLine, hasErrors, hasInteractiveErrors } from './command_line';
import { InteractiveDiagnostic } from '@leanprover/infoview/*';
import { Button } from '../button';
import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api';
import { store } from '../../state/store';
import { Hints } from '../hints';
export function Main(props: {world: string, level: number}) {
const ec = React.useContext(EditorContext);
const gameId = React.useContext(GameIdContext)
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start.
*/
export function DualEditor({ level, codeviewRef, levelId, worldId, worldSize }) {
const ec = React.useContext(EditorContext)
const { commandLineMode } = React.useContext(InputModeContext)
return <>
<div className={commandLineMode ? 'hidden' : ''}>
<ExerciseStatement data={level} />
<div ref={codeviewRef} className={'codeview'}></div>
</div>
{ec ?
<DualEditorMain worldId={worldId} levelId={levelId} level={level} worldSize={worldSize} /> :
// TODO: Style this if relevant.
<>
<p>Editor is starting up...</p>
<CircularProgress />
</>
}
</>
}
const dispatch = useAppDispatch()
/** The part of the two editors that needs the editor connection first */
function DualEditorMain({ worldId, levelId, level, worldSize }: { worldId: string, levelId: number, level: LevelInfo, worldSize: number }) {
const ec = React.useContext(EditorContext)
const gameId = React.useContext(GameIdContext)
const { commandLineMode } = React.useContext(InputModeContext)
// Mark level as completed when server gives notification
useServerNotificationEffect(
'$/game/completed',
(params: any) => {
// Mark level as completed when server gives notification
const dispatch = useAppDispatch()
useServerNotificationEffect(
'$/game/completed',
(params: any) => {
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.uri) {
dispatch(levelCompleted({ game: gameId, world: worldId, level: levelId }))
if (ec.events.changedCursorLocation.current &&
ec.events.changedCursorLocation.current.uri === params.uri) {
dispatch(levelCompleted({game: gameId, world: props.world, level: props.level}))
}
},
[]
);
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
/* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
const [allProgress, _1] = useServerNotificationState(
'$/lean/fileProgress',
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
async (params: LeanFileProgressParams) => (allProgress) => {
const newProgress = new Map(allProgress);
return newProgress.set(params.textDocument.uri, params.processing);
},
[]
);
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.
let ret
if (!serverVersion) {
ret = <p>Waiting for Lean server to start...</p>
} else if (serverStoppedResult){
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
} else {
ret = <div className="infoview vscode-light">
{completed && <div className="level-completed">Level completed! 🎉</div>}
<Infos />
</div>
}
// On completion, add the names of all new items to the local storage
let newTiles = [
...level?.tactics,
...level?.lemmas,
...level?.definitions
].filter((tile) => tile.new).map((tile) => tile.name)
let inv: string[] = selectInventory(gameId)(store.getState())
// add new items and remove duplicates
let newInv = [...inv, ...newTiles].filter((item, i, array) => array.indexOf(item) == i)
return (
dispatch(changedInventory({ game: gameId, inventory: newInv }))
}
}, [level]
)
/* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
const [allProgress, _1] = useServerNotificationState(
'$/lean/fileProgress',
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
async (params: LeanFileProgressParams) => (allProgress) => {
const newProgress = new Map(allProgress);
return newProgress.set(params.textDocument.uri, params.processing);
}, [])
const serverVersion = useEventResult(ec.events.serverRestarted, result => new ServerVersion(result.serverInfo?.version ?? ''))
return <>
<ConfigContext.Provider value={config}>
<VersionContext.Provider value={serverVersion}>
<WithRpcSessions>
<WithLspDiagnosticsContext>
<ProgressContext.Provider value={allProgress}>
{ret}
</ProgressContext.Provider>
</WithLspDiagnosticsContext>
</WithRpcSessions>
</VersionContext.Provider>
<VersionContext.Provider value={serverVersion}>
<WithRpcSessions>
<WithLspDiagnosticsContext>
<ProgressContext.Provider value={allProgress}>
{commandLineMode ?
<CommandLineInterface world={worldId} level={levelId} data={level} worldSize={worldSize}/>
:
<Main key={`${worldId}/${levelId}`} world={worldId} level={levelId} />
}
</ProgressContext.Provider>
</WithLspDiagnosticsContext>
</WithRpcSessions>
</VersionContext.Provider>
</ConfigContext.Provider>
);
</>
}
/** The mathematical formulation of the statement, supporting e.g. Latex
* It takes three forms, depending on the precence of name and description:
* - Theorem xyz: description
* - Theorem xyz
* - Exercises: description
*/
function ExerciseStatement({ data }) {
if (!data?.descrText) { return <></> }
return <div className="exercise-statement"><Markdown>
{(data?.statementName ? `**Theorem** \`${data?.statementName}\`: ` : data?.descrText && "**Exercise**: ") + `${data?.descrText}`}
</Markdown></div>
}
// TODO: This is only used in `EditorInterface`
// while `CommandLineInterface` has this copy-pasted in.
export function Main(props: { world: string, level: number }) {
const ec = React.useContext(EditorContext);
const gameId = React.useContext(GameIdContext)
const completed = useAppSelector(selectCompleted(gameId, props.world, props.level))
/* Set up updates to the global infoview state on editor events. */
const config = useEventResult(ec.events.changedInfoviewConfig) ?? defaultInfoviewConfig;
const [allProgress, _1] = useServerNotificationState(
'$/lean/fileProgress',
new Map<DocumentUri, LeanFileProgressProcessingInfo[]>(),
async (params: LeanFileProgressParams) => (allProgress) => {
const newProgress = new Map(allProgress);
return newProgress.set(params.textDocument.uri, params.processing);
},
[]
);
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.
let ret
if (!serverVersion) {
ret = <p>Waiting for Lean server to start...</p>
} else if (serverStoppedResult) {
ret = <div><p>{serverStoppedResult.message}</p><p className="error">{serverStoppedResult.reason}</p></div>
} else {
ret = <div className="infoview vscode-light">
{completed && <div className="level-completed">Level completed! 🎉</div>}
<Infos />
</div>
}
return ret
}
const goalFilter = {
reverse: false,
showType: true,
showInstance: true,
showHiddenAssumption: true,
showLetValue: true
}
/** The display of a single entered lean command */
function Command({ command, deleteProof }: { command: string, deleteProof: any }) {
// The first step will always have an empty command
if (!command) { return <></> }
return <div className="command">
<div className="command-text">{command}</div>
<Button to="" className="undo-button btn btn-inverted" title="Delete this and future commands" onClick={deleteProof}>
<FontAwesomeIcon icon={faDeleteLeft} />&nbsp;Delete
</Button>
</div>
}
// const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
// const ec = React.useContext(EditorContext);
// const fname = escapeHtml(basename(uri));
// const {line, character} = diag.range.start;
// const loc: Location = { uri, range: diag.range };
// const text = TaggedText_stripTags(diag.message);
// const severityClass = diag.severity ? {
// [DiagnosticSeverity.Error]: 'error',
// [DiagnosticSeverity.Warning]: 'warning',
// [DiagnosticSeverity.Information]: 'information',
// [DiagnosticSeverity.Hint]: 'hint',
// }[diag.severity] : '';
// const title = `Line ${line+1}, Character ${character}`;
// // Hide "unsolved goals" messages
// let message;
// if ("append" in diag.message && "text" in diag.message.append[0] &&
// diag.message?.append[0].text === "unsolved goals") {
// message = diag.message.append[0]
// } else {
// message = diag.message
// }
// const { commandLineMode } = React.useContext(InputModeContext)
// return (
// // <details open>
// // <summary className={severityClass + ' mv2 pointer'}>{title}
// // <span className="fr">
// // <a className="link pointer mh2 dim codicon codicon-go-to-file"
// // onClick={e => { e.preventDefault(); void ec.revealLocation(loc); }}
// // title="reveal file location"></a>
// // <a className="link pointer mh2 dim codicon codicon-quote"
// // data-id="copy-to-comment"
// // onClick={e => {e.preventDefault(); void ec.copyToComment(text)}}
// // title="copy message to comment"></a>
// // <a className="link pointer mh2 dim codicon codicon-clippy"
// // onClick={e => {e.preventDefault(); void ec.api.copyToClipboard(text)}}
// // title="copy message to clipboard"></a>
// // </span>
// // </summary>
// <div className={severityClass + ' ml1 message'}>
// {!commandLineMode && <p className="mv2">{title}</p>}
// <pre className="font-code pre-wrap">
// <InteractiveMessage fmt={message} />
// </pre>
// </div>
// // </details>
// )
// }, fastIsEqual)
/** The tabs of goals that lean ahs after the command of this step has been processed */
function GoalsTab({ proofStep }: { proofStep: ProofStep }) {
const [selectedGoal, setSelectedGoal] = React.useState<number>(0)
if (!proofStep.goals.length) { return <></> }
return <div>
<div className="tab-bar">
{proofStep.goals.map((goal, i) => (
// TODO: Should not use index as key.
<div key={`proof-goal-${i}`} className={`tab ${i == (selectedGoal) ? "active" : ""}`} onClick={(ev) => { setSelectedGoal(i); ev.stopPropagation() }}>
{i ? `Goal ${i + 1}` : "Active Goal"}
</div>
))}
</div>
<div className="goal-tab vscode-light">
<Goal commandLine={false} filter={goalFilter} goal={proofStep.goals[selectedGoal]} />
</div>
</div>
}
/** The interface in command line mode */
export function CommandLineInterface(props: { world: string, level: number, data: LevelInfo, worldSize: number }) {
const ec = React.useContext(EditorContext)
const editor = React.useContext(MonacoEditorContext)
const gameId = React.useContext(GameIdContext)
const { proof } = React.useContext(ProofContext)
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
const {mobile} = React.useContext(MobileContext)
const proofPanelRef = React.useRef<HTMLDivElement>(null)
// React.useEffect(() => {
// console.debug('updated proof')
// // proofPanelRef.current?.lastElementChild?.scrollIntoView() //scrollTo(0,0)
// }, [proof])
/** Delete all proof lines starting from a given line.
* Note that the first line (i.e. deleting everything) is `1`!
*/
function deleteProof(line: number) {
return (ev) => {
let deletedChat: Array<GameHint> = []
proof.slice(line).map((step, i) => {
// Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
})
setDeletedChat(deletedChat)
editor.executeEdits("command-line", [{
range: monaco.Selection.fromPositions(
{ lineNumber: line, column: 1 },
editor.getModel().getFullModelRange().getEndPosition()
),
text: '',
forceMoveMarkers: false
}])
setSelectedStep(undefined)
ev.stopPropagation()
}
}
function toggleSelectStep(line: number) {
return (ev) => {
if (mobile) {return}
if (selectedStep == line) {
setSelectedStep(undefined)
console.debug(`unselected step`)
} else {
setSelectedStep(line)
console.debug(`step ${line} selected`)
}
}
}
// Scroll to element if selection changes
React.useEffect(() => {
if (typeof selectedStep !== 'undefined') {
Array.from(proofPanelRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
elem.scrollIntoView({ block: "center" })
})
}
}, [selectedStep])
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`?
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
} else {
tmp.add(k - withErr)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
return <div className="commandline-interface">
<div className="content" ref={proofPanelRef}>
<ExerciseStatement data={props.data} />
<div className="tmp-pusher"></div>
{proof.length ?
<>
{proof.map((step, i) => {
if (i == proof.length - 1 && hasInteractiveErrors(step.errors)) {
// if the last command contains an error, we only display the errors but not the
// entered command as it is still present in the command line.
// TODO: Should not use index as key.
return <div key={`proof-step-${i}`}>
<Errors errors={step.errors} commandLineMode={true} />
</div>
} else {
return <div key={`proof-step-${i}`} className={`step step-${i}` + (selectedStep == i ? ' selected' : '')} onClick={toggleSelectStep(i)}>
<Command command={step.command} deleteProof={deleteProof(i)} />
<Errors errors={step.errors} commandLineMode={true} />
{mobile && i == 0 && props.data?.introduction &&
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelectStep(0)}>
<Markdown>{props.data?.introduction}</Markdown>
</div>
}
{mobile && <>
<Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
{i == proof.length - 1 && hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button className="btn btn-help" to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
</>
}
<GoalsTab proofStep={step} />
{/* Show a message that there are no goals left */}
{!step.goals.length && (
<div className="message information">
{completed ?
<p>Level completed! 🎉</p> :
<p>
<b>no goals left</b><br />
<i>This probably means you solved the level with warnings or Lean encountered a parsing error.</i>
</p>
}
</div>
)}
</div>
}
})}
{mobile && completed &&
<div className="button-row">
{props.level >= props.worldSize ?
<Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button>
:
<Button to={`/${gameId}/world/${props.world}/level/${props.level + 1}`}>
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>
}
</div>
}
</>
: <CircularProgress />}
</div>
<CommandLine proofPanelRef={proofPanelRef} />
</div>
}

@ -1,22 +1,61 @@
import * as React from 'react';
import fastIsEqual from 'react-fast-compare';
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol';
import * as React from 'react'
import fastIsEqual from 'react-fast-compare'
import { Location, DocumentUri, Diagnostic, DiagnosticSeverity, PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
import { LeanDiagnostic, RpcErrorCode } from '@leanprover/infoview-api';
import { LeanDiagnostic, RpcErrorCode, getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api'
import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util';
import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts';
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing';
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer';
import { getInteractiveDiagnostics, InteractiveDiagnostic, TaggedText_stripTags } from '@leanprover/infoview-api';
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions';
import { InputModeContext } from '../Level';
import { basename, escapeHtml, usePausableState, useEvent, addUniqueKeys, DocumentPosition, useServerNotificationState, useEventResult } from '../../../../node_modules/lean4-infoview/src/infoview/util'
import { ConfigContext, EditorContext, LspDiagnosticsContext, VersionContext } from '../../../../node_modules/lean4-infoview/src/infoview/contexts'
import { Details } from '../../../../node_modules/lean4-infoview/src/infoview/collapsing'
import { InteractiveMessage } from '../../../../node_modules/lean4-infoview/src/infoview/traceExplorer'
import { RpcContext, useRpcSessionAtPos } from '../../../../node_modules/lean4-infoview/src/infoview/rpcSessions'
import { InputModeContext } from './context'
interface MessageViewProps {
uri: DocumentUri;
diag: InteractiveDiagnostic;
}
/** A list of messages (info/warning/error) that are produced after this command */
function Error({error, commandLineMode} : {error : InteractiveDiagnostic, commandLineMode : boolean}) {
// The first step will always have an empty command
const severityClass = error.severity ? {
[DiagnosticSeverity.Error]: 'error',
[DiagnosticSeverity.Warning]: 'warning',
[DiagnosticSeverity.Information]: 'information',
[DiagnosticSeverity.Hint]: 'hint',
}[error.severity] : '';
const {line, character} = error.range.start;
const title = `Line ${line+1}, Character ${character}`;
// Hide "unsolved goals" messages
let message;
if ("append" in error.message && "text" in error.message.append[0] &&
error.message?.append[0].text === "unsolved goals") {
message = error.message.append[0]
} else {
message = error.message
}
return <div className={severityClass + ' ml1 message'}>
{!commandLineMode && <p className="mv2">{title}</p>}
<pre className="font-code pre-wrap">
<InteractiveMessage fmt={message} />
</pre>
</div>
}
// TODO: Should not use index as key.
/** A list of messages (info/warning/error) that are produced after this command */
export function Errors ({errors, commandLineMode} : {errors : InteractiveDiagnostic[], commandLineMode : boolean}) {
return <div>
{errors.map((err, i) => (<Error key={`error-${i}`} error={err} commandLineMode={commandLineMode}/>))}
</div>
}
const MessageView = React.memo(({uri, diag}: MessageViewProps) => {
const ec = React.useContext(EditorContext);
const fname = escapeHtml(basename(uri));

@ -1,5 +1,8 @@
/* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts ` */
/**
* @fileOverview Defines the interface for the communication with the server.
*
* This file is based on `vscode-lean4/vscode-lean4/src/rpcApi.ts`
*/
import { ContextInfo, FVarId, CodeWithInfos, MVarId } from '@leanprover/infoview-api';
export interface GameHint {

@ -1,13 +1,22 @@
.inventory {
.inventory, .documentation {
padding: 0 1em 1em 1em;
}
.inventory h2 {
.inventory h2, .documentation h2, .documentation h1 {
font-size: 1.5em;
margin-top: 1em;
margin-bottom: .2em;
}
.documentation h1 {
font-weight: 900;
}
.documentation.hidden {
display: none;
transition: display 2s;
}
.inventory-list {
display: flex;
gap: .5em;
@ -20,17 +29,20 @@
padding: .1em .5em;
}
.inventory .item.locked,
.inventory .item.disabled {
.inventory .item.locked {
border: solid 1px #ccc;
color: #ccc;
}
.inventory .item.disabled {
color: #d92c2c;
}
.inventory .item.new {
background-color: rgb(255, 242, 190);
}
.inventory .item:not(.locked):not(.disabled) {
.inventory .item:not(.locked), .inventory .item.enabled {
cursor: pointer;
}
@ -55,15 +67,3 @@
color: black;
border-bottom: 0.3em solid var(--clr-primary);
}
.doc-panel {
padding: 0 1em;
}
.doc-panel h2 {
font-size: 1.5em;
margin-top: 1em;
margin-bottom: .2em;
font-weight: 900;
}

@ -0,0 +1,147 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import './inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faLockOpen, faBook, faHammer, faBan } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app';
import Markdown from './markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
import { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store';
import { useSelector } from 'react-redux';
export function Inventory({levelInfo, openDoc, enableAll=false} :
{
levelInfo: LevelInfo|InventoryOverview,
openDoc: (props: {name: string, type: string}) => void,
enableAll?: boolean,
}) {
return (
<div className="inventory">
{/* TODO: Click on Tactic: show info
TODO: click on paste icon -> paste into command line */}
<h2>Tactics</h2>
{levelInfo?.tactics &&
<InventoryList items={levelInfo?.tactics} docType="Tactic" openDoc={openDoc} enableAll={enableAll}/>
}
<h2>Definitions</h2>
{levelInfo?.definitions &&
<InventoryList items={levelInfo?.definitions} docType="Definition" openDoc={openDoc} enableAll={enableAll}/>
}
<h2>Lemmas</h2>
{levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/>
}
</div>
)
}
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} :
{
items: InventoryTile[],
docType: string,
openDoc(props: {name: string, type: string}): void,
defaultTab? : string,
level? : LevelInfo|InventoryOverview,
enableAll?: boolean,
}) {
// TODO: `level` is only used in the `useEffect` below to check if a new level has
// been loaded. Is there a better way to observe this?
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const categorySet = new Set<string>()
for (let item of items) {
categorySet.add(item.category)
}
const categories = Array.from(categorySet).sort()
const [tab, setTab] = useState(defaultTab)
// Add inventory items from local store as unlocked.
// Items are unlocked if they are in the local store, or if the server says they should be
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
// modi)
let inv: string[] = selectInventory(gameId)(store.getState())
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (defaultTab) {
setTab(defaultTab)
}}, [level])
return <>
{categories.length > 1 &&
<div className="tab-bar">
{categories.map((cat) =>
<div key={`category-${cat}`} className={`tab ${cat == (tab ?? categories[0]) ? "active": ""}`}
onClick={() => { setTab(cat) }}>{cat}</div>)}
</div>}
<div className="inventory-list">
{[...modifiedItems].sort(
// For lemas, sort entries `available > disabled > locked`
// otherwise alphabetically
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled)
).filter(item => ((tab ?? categories[0]) == item.category)).map((item, i) => {
return <InventoryItem key={`${item.category}-${item.name}`}
showDoc={() => {openDoc({name: item.name, type: docType})}}
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
disabled={item.disabled} newly={item.new} enableAll={enableAll}/>
})
}
</div>
</>
}
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : ""
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
const title = locked ? "Not unlocked yet" :
disabled ? "Not available in this level" : ""
const handleClick = () => {
if (enableAll || !locked) {
showDoc()
}
}
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div>
}
export function Documentation({name, type, handleClose}) {
const gameId = React.useContext(GameIdContext)
const doc = useLoadDocQuery({game: gameId, type: type, name: name})
return <div className="documentation">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<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>
}
/** The panel (on the welcome page) showing the user's inventory with tactics, definitions, and lemmas */
export function InventoryPanel({visible = true}) {
const gameId = React.useContext(GameIdContext)
const inventory = useLoadInventoryOverviewQuery({game: gameId})
// The inventory is overlayed by the doc entry of a clicked item
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
// Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() {setInventoryDoc(null)}
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
{inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
:
<Inventory levelInfo={inventory.data} openDoc={setInventoryDoc} enableAll={true}/>
}
</div>
}

@ -91,7 +91,7 @@ div.image {
.info {
margin-top: 5px;
margin-bottom: 15px;
width: calc(100% - 20px);
/* width: calc(100% - 20px); */
border-collapse: collapse;
}

@ -1,19 +1,18 @@
import * as React from 'react';
import { useNavigate } from "react-router-dom";
import { Link } from 'react-router-dom';
import Markdown from './Markdown';
import { useNavigate, Link } from "react-router-dom";
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import './LandingPage.css'
import {PrivacyPolicyPopup} from './PrivacyPolicy'
import './landing_page.css'
import coverRobo from '../assets/covers/formaloversum.png'
import bgImage from '../assets/bg.jpg'
import Markdown from './markdown';
import {PrivacyPolicyPopup} from './privacy_policy'
const flag = {
'Dutch': '🇳🇱',
'English': '🇬🇧',
@ -27,7 +26,6 @@ function GithubIcon({url='https://github.com'}) {
return <div className="github-link">
<a title="view the Lean game server on Github" href={url}>
<svg height="24" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="24" className="">
{/* <circle className="world-circle" cx="8" cy="8" r="8" fill="#fff"/> */}
<path fill="#fff" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
</a>
@ -105,18 +103,6 @@ function LandingPage() {
</header>
<div className="game-list">
<GameTile
title="Formaloversum"
gameId="g/hhu-adam/Robo"
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
description="
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
(Das Spiel befindet sich noch in der Entstehungsphase.)"
image={coverRobo}
language="German"
/>
<GameTile
title="Natural Number Game"
gameId="g/hhu-adam/NNG4"
@ -130,6 +116,18 @@ This is a good first introduction to Lean!"
language="English"
/>
<GameTile
title="Formaloversum"
gameId="g/hhu-adam/Robo"
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
description="
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
(Das Spiel befindet sich noch in der Entstehungsphase.)"
image={coverRobo}
language="German"
/>
</div>
<section>
<div className="wrapper">
@ -155,11 +153,13 @@ This is a good first introduction to Lean!"
a template.
</p>
<p>
There will be an option to load and run games through the server
directly by specifying a URL, but this is still in development.
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
encourage you to contact us for support creating your own game. The documentation is
not polished yet.
</p>
<p>
To add games to this page, you should get in contact as
To add games to this main page, you should get in contact as
games will need to be added manually.
</p>
</div>

@ -5,6 +5,13 @@
display: flex;
}
.level-mobile {
height: 100%;
flex: 1;
min-height: 0;
/* display: flex; */
}
.hidden {
display: none;
}
@ -24,18 +31,22 @@
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAeCAYAAADkftS9AAAAIklEQVQoU2M4c+bMfxAGAgYYmwGrIIiDjrELjpo5aiZeMwF+yNnOs5KSvgAAAABJRU5ErkJggg==');
}
.inventory-panel, .exercise-panel, .doc-panel {
.inventory-panel, .exercise-panel, .doc-panel, .introduction-panel {
height: 100%;
overflow: auto;
position: relative;
}
.introduction-panel, .infoview, .exercise {
.infoview {
padding-top: 1em;
padding-left: 1em;
padding-right: 1em;
padding-bottom: 0;
}
.infoview, .exercise-statement {
padding-left: .5em;
padding-right: .5em;
}
.conclusion {
padding: 1em;
}
@ -93,12 +104,12 @@ mjx-container[jax="CHTML"][display="true"] {
/* Styling tables for Markdown */
.introduction-panel table, .introduction-panel th, .introduction-panel td {
.chat-panel table, .chat-panel th, .chat-panel td {
/* border: 1px solid rgb(0, 0, 0, 0.12); */
border-collapse: collapse;
}
.introduction-panel th, .introduction-panel td {
.chat-panel th, .chat-panel td {
padding-left: .5em;
padding-right: .5em;
}
@ -155,7 +166,7 @@ td code {
border: 1px solid rgb(230, 122, 0);
}
.introduction-panel {
.chat-panel {
border: 1px solid rgb(192, 18, 178);
}
@ -171,3 +182,133 @@ td code {
border: 1px solid rgb(255, 0, 0);
margin: 1px;
} */
.exercise {
height: 100%;
}
.chat {
flex: 1;
overflow-y: scroll;
margin-left: .5em;
margin-right: .5em;
}
.chat-panel {
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.chat-panel .button-row {
/* width:100%; */
margin-left: .5em;
margin-right: .5em;
min-height: 2.5em;
border-top: 0.1em solid #aaa;
}
.chat-panel .btn {
margin-top: 1rem;
margin-bottom: 1rem;
margin-left: .5rem;
margin-right: .5rem;
}
/* .exercise-panel {
display: flex;
flex-flow: column;
height: 100%;
} */
.commandline-interface {
display: flex;
flex-flow: column;
height: 100%;
}
.command-line {
flex: 0 1 auto;
}
.commandline-interface .content {
flex: 1 1 auto;
overflow-y: scroll;
padding: .5em;
}
.exercise .step {
/* background-color: #e6f0f4; */
margin-top: 5px;
margin-bottom: 5px;
border-radius: 0.5em;
padding: 0.5em;
/* border: 3px dotted rgb(88, 131, 24); */
}
.exercise .step .btn-help {
margin-top: 10px;
}
.chat .message {
margin-left: .5em;
margin-right: .5em;
}
.exercise .step.selected, .chat .selected {
/* border: 3px solid #5191d1; */
box-shadow: .0em .0em .4em .1em var(--clr-primary);
}
.introduction-panel {
max-width: 900px;
width: 80%;
margin-left: auto;
margin-right: auto;
}
.impressum {
background-color: #fff;
width: 100%;
height: 100%;
position: absolute;
top: 0;
z-index: 1;
padding: .5rem;
}
.menu.dropdown {
position: absolute;
display: flex;
flex-direction: column;
right: 0;
top: 100%;
background-color: #fff;
z-index: 1;
border-top: 1px solid rgba(0, 0, 0, 0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: -.1rem .3rem .3rem 0 rgba(0, 0, 0, 0.1);
}
.menu.dropdown.hidden {
display: none;
}
#menu-btn {
padding: 0;
font-size: 1.3rem;
margin-left: .5em;
margin-right: 0;
}
#home-btn {
margin-right: .5em;
margin-left: 0;
}
.menu.dropdown .svg-inline--fa {
width: 1.8rem;
}

@ -0,0 +1,685 @@
import * as React from 'react'
import { useEffect, useState, useRef, useContext } from 'react'
import { useSelector, useStore } from 'react-redux'
import Split from 'react-split'
import { useParams } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBars, faBook, faCode, faXmark, faHome, faCircleInfo, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { CircularProgress } from '@mui/material'
import type { Location } from 'vscode-languageserver-protocol'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { AbbreviationProvider } from 'lean4web/client/src/editor/abbreviation/AbbreviationProvider'
import { AbbreviationRewriter } from 'lean4web/client/src/editor/abbreviation/rewriter/AbbreviationRewriter'
import { InfoProvider } from 'lean4web/client/src/editor/infoview'
import { LeanTaskGutter } from 'lean4web/client/src/editor/taskgutter'
import { InfoviewApi } from '@leanprover/infoview'
import { EditorContext } from '../../../node_modules/lean4-infoview/src/infoview/contexts'
import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-infoview/src/infoview/editorConnection'
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
import { GameIdContext } from '../app'
import { ConnectionContext, connection, useLeanClient } from '../connection'
import { useAppDispatch, useAppSelector } from '../hooks'
import { useGetGameInfoQuery, useLoadLevelQuery } from '../state/api'
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
selectHelp, selectDifficulty, selectInventory } from '../state/progress'
import { store } from '../state/store'
import { Button } from './button'
import Markdown from './markdown'
import {InventoryPanel} from './inventory'
import { hasInteractiveErrors } from './infoview/command_line'
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api'
import { DeletedHints, Hints } from './hints'
import { Impressum } from './privacy_policy'
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/500.css'
import '@fontsource/roboto/700.css'
import 'lean4web/client/src/editor/infoview.css'
import 'lean4web/client/src/editor/vscode.css'
import './level.css'
function Level() {
const params = useParams()
const levelId = parseInt(params.levelId)
const worldId = params.worldId
useLoadWorldFiles(worldId)
return <WorldLevelIdContext.Provider value={{worldId, levelId}}>
{levelId == 0 ? <Introduction /> : <PlayableLevel />}
</WorldLevelIdContext.Provider>
}
function ChatPanel({lastLevel}) {
const chatRef = useRef<HTMLDivElement>(null)
const {mobile} = useContext(MobileContext)
const gameId = useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const {proof, setProof} = useContext(ProofContext)
const {deletedChat, setDeletedChat, showHelp, setShowHelp} = useContext(DeletedChatContext)
const {selectedStep, setSelectedStep} = useContext(SelectionContext)
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
// If the last step has errors, we want to treat it as if it is part of the second-to-last step
let k = proof.length - 1
let withErr = hasInteractiveErrors(proof[k]?.errors) ? 1 : 0
function toggleSelection(line: number) {
return (ev) => {
console.debug('toggled selection')
if (selectedStep == line) {
setSelectedStep(undefined)
} else {
setSelectedStep(line)
}
}
}
function hasHiddenHints(i : number): boolean {
let step = proof[i]
// For example if the proof isn't loaded yet
if(!step) {return false}
return step.hints.some((hint) => hint.hidden)
}
const activateHiddenHints = (ev) => {
// If the last step (`k`) has errors, we want the hidden hints from the
// second-to-last step to be affected
if (!(proof.length)) {return}
// state must not be mutated, therefore we need to clone the set
let tmp = new Set(showHelp)
if (tmp.has(k - withErr)) {
tmp.delete(k - withErr)
} else {
tmp.add(k - withErr)
}
setShowHelp(tmp)
console.debug(`help: ${Array.from(tmp.values())}`)
}
useEffect(() => {
// TODO: For some reason this is always called twice
console.debug('scroll chat')
if (!mobile) {
chatRef.current!.lastElementChild?.scrollIntoView() //scrollTo(0,0)
}
}, [proof, showHelp])
// Scroll to element if selection changes
useEffect(() => {
if (typeof selectedStep !== 'undefined') {
Array.from(chatRef.current?.getElementsByClassName(`step-${selectedStep}`)).map((elem) => {
elem.scrollIntoView({block: "center"})
})
}
}, [selectedStep])
// useEffect(() => {
// // // Scroll to top when loading a new level
// // // TODO: Thats the wrong behaviour probably
// // chatRef.current!.scrollTo(0,0)
// }, [gameId, worldId, levelId])
return <div className="chat-panel">
<div ref={chatRef} className="chat">
{level?.data?.introduction &&
<div className={`message information step-0${selectedStep === 0 ? ' selected' : ''}`} onClick={toggleSelection(0)}>
<Markdown>{level?.data?.introduction}</Markdown>
</div>
}
{proof.map((step, i) => {
// It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them.
if (!(i == proof.length - 1 && withErr)) {
// TODO: Should not use index as key.
return <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)}/>
}
})}
<DeletedHints hints={deletedChat}/>
{completed &&
<>
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
Level completed! 🎉
</div>
{level?.data?.conclusion?.trim() &&
<div className={`message information step-${k}${selectedStep == k ? ' selected' : ''}`} onClick={toggleSelection(k)}>
<Markdown>{level?.data?.conclusion}</Markdown>
</div>
}
</>
}
</div>
<div className="button-row">
{completed && (lastLevel ?
<Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World
</Button> :
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>)
}
{hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
</div>
</div>
}
function ExercisePanel({codeviewRef, impressum, closeImpressum, visible=true}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
const gameInfo = useGetGameInfoQuery({game: gameId})
return <div className={`exercise-panel ${visible ? '' : 'hidden'}`}>
<div className="exercise">
<DualEditor level={level?.data} codeviewRef={codeviewRef} levelId={levelId} worldId={worldId} worldSize={gameInfo.data?.worldSize[worldId]}/>
</div>
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
</div>
}
function PlayableLevel() {
const codeviewRef = useRef<HTMLDivElement>(null)
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const {mobile} = React.useContext(MobileContext)
const difficulty = useSelector(selectDifficulty(gameId))
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
const inventory: Array<String> = useSelector(selectInventory(gameId))
const gameInfo = useGetGameInfoQuery({game: gameId})
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
// The state variables for the `ProofContext`
const [proof, setProof] = useState<Array<ProofStep>>([])
// When deleting the proof, we want to keep to old messages around until
// a new proof has been entered. e.g. to consult messages coming from dead ends
const [deletedChat, setDeletedChat] = useState<Array<GameHint>>([])
// A set of row numbers where help is displayed
const [showHelp, setShowHelp] = useState<Set<number>>(new Set())
// Only for mobile layout
const [pageNumber, setPageNumber] = useState(0)
const [commandLineMode, setCommandLineMode] = useState(true)
const [commandLineInput, setCommandLineInput] = useState("")
const lastLevel = levelId >= gameInfo.data?.worldSize[worldId]
const dispatch = useAppDispatch()
// impressum pop-up
const [impressum, setImpressum] = React.useState(false)
function closeImpressum() {setImpressum(false)}
function toggleImpressum() {setImpressum(!impressum)}
// When clicking on an inventory item, the inventory is overlayed by the item's doc.
// If this state is set to a pair `(name, type)` then the according doc will be open.
// Set `inventoryDoc` to `null` to close the doc
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
function closeInventoryDoc () {setInventoryDoc(null)}
const onDidChangeContent = (code) => {
dispatch(codeEdited({game: gameId, world: worldId, level: levelId, code}))
}
const onDidChangeSelection = (monacoSelections) => {
const selections = monacoSelections.map(
({selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}) =>
{return {selectionStartLineNumber, selectionStartColumn, positionLineNumber, positionColumn}})
dispatch(changedSelection({game: gameId, world: worldId, level: levelId, selections}))
}
const {editor, infoProvider, editorConnection} =
useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection)
/** Unused. Was implementing an undo button, which has been replaced by `deleteProof` inside
* `CommandLineInterface`.
*/
const handleUndo = () => {
const endPos = editor.getModel().getFullModelRange().getEndPosition()
let range
console.log(endPos.column)
if (endPos.column === 1) {
range = monaco.Selection.fromPositions(
new monaco.Position(endPos.lineNumber - 1, 1),
endPos
)
} else {
range = monaco.Selection.fromPositions(
new monaco.Position(endPos.lineNumber, 1),
endPos
)
}
editor.executeEdits("undo-button", [{
range,
text: "",
forceMoveMarkers: false
}]);
}
// Select and highlight proof steps and corresponding hints
// TODO: with the new design, there is no difference between the introduction and
// a hint at the beginning of the proof...
const [selectedStep, setSelectedStep] = useState<number>()
// if the user inventory changes, notify the server
useEffect(() => {
let leanClient = connection.getLeanClient(gameId)
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, checkEnabled: difficulty > 0})
}, [inventory])
useEffect(() => {
// TODO: That's a problem if the saved proof contains an error
// Reset command line input when loading a new level
setCommandLineInput("")
// Load the selected help steps from the store
setShowHelp(new Set(selectHelp(gameId, worldId, levelId)(store.getState())))
}, [gameId, worldId, levelId])
useEffect(() => {
if (!commandLineMode) {
// Delete last input attempt from command line
editor.executeEdits("command-line", [{
range: editor.getSelection(),
text: "",
forceMoveMarkers: false
}]);
editor.focus()
}
}, [commandLineMode])
useEffect(() => {
// Forget whether hidden hints are displayed for steps that don't exist yet
if (proof.length) {
console.debug(Array.from(showHelp))
setShowHelp(new Set(Array.from(showHelp).filter(i => (i < proof.length))))
}
}, [proof])
// save showed help in store
useEffect(() => {
if (proof.length) {
console.debug(`showHelp:\n ${showHelp}`)
dispatch(helpEdited({game: gameId, world: worldId, level: levelId, help: Array.from(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 <>
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
<DeletedChatContext.Provider value={{deletedChat, setDeletedChat, showHelp, setShowHelp}}>
<SelectionContext.Provider value={{selectedStep, setSelectedStep}}>
<InputModeContext.Provider value={{commandLineMode, setCommandLineMode, commandLineInput, setCommandLineInput}}>
<ProofContext.Provider value={{proof, setProof}}>
<EditorContext.Provider value={editorConnection}>
<MonacoEditorContext.Provider value={editor}>
<LevelAppBar
isLoading={level.isLoading}
levelTitle={`${mobile ? '' : 'Level '}${levelId} / ${gameInfo.data?.worldSize[worldId]}` +
(level?.data?.title && ` : ${level?.data?.title}`)}
toggleImpressum={toggleImpressum}
pageNumber={pageNumber} setPageNumber={setPageNumber} />
{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 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 />
</Split>
}
</MonacoEditorContext.Provider>
</EditorContext.Provider>
</ProofContext.Provider>
</InputModeContext.Provider>
</SelectionContext.Provider>
</DeletedChatContext.Provider>
</>
}
export default Level
/** The site with the introduction text of a world */
function Introduction() {
const gameId = React.useContext(GameIdContext)
const {worldId} = useContext(WorldLevelIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const [impressum, setImpressum] = React.useState(false)
const closeImpressum = () => {
setImpressum(false)
}
const toggleImpressum = () => {
setImpressum(!impressum)
}
return <>
<div style={gameInfo.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
<LevelAppBar isLoading={gameInfo.isLoading} levelTitle="Introduction" toggleImpressum={toggleImpressum}/>
<div style={gameInfo.isLoading ? {display: "none"} : null} className="introduction-panel">
<div className="content-wrapper">
<Markdown>
{gameInfo.data?.worlds.nodes[worldId].introduction}
</Markdown>
{impressum ? <Impressum handleClose={closeImpressum} /> : null}
</div>
{gameInfo.data?.worldSize[worldId] == 0 ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}>
Start&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>
}
</div>
</>
}
/** The top-navigation bar */
function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber = undefined, setPageNumber = undefined}) {
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
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 ?
<>
<div>
<span className="app-bar-title">
{levelTitle}
</span>
</div>
<div className="nav-btns">
{mobile && pageNumber == 0 ?
<Button to=""
title="show inventory" inverted="true" onClick={() => {setPageNumber(1)}}>
<FontAwesomeIcon icon={faBook}/>
</Button>
: pageNumber == 1 &&
<Button to=""
title="show inventory" inverted="true" onClick={() => {setPageNumber(0)}}>
<FontAwesomeIcon icon={faArrowLeft}/>
</Button>
}
<Button to="" 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>
</>
:
<>
<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"
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`} title="next level"
disabled={difficulty >= 2 && !(completed || levelId == 0)}
onClick={() => setNavOpen(false)}>
<FontAwesomeIcon icon={faArrowRight} />&nbsp;{levelId ? "Next" : "Start"}
</Button>
}
<Button disabled={levelId <= 0} inverted="true" to=""
onClick={(ev) => { setCommandLineMode(!commandLineMode); setNavOpen(false) }}
title="toggle Editor mode">
<FontAwesomeIcon icon={faCode} />
</Button>
<Button title="information, Impressum, privacy policy" inverted="true" to="" onClick={(ev) => {toggleImpressum(ev); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />
</Button>
</div>
</>
}
</div>
}
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
const connection = React.useContext(ConnectionContext)
const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext)
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
// Create Editor
useEffect(() => {
const editor = monaco.editor.create(codeviewRef.current!, {
glyphMargin: true,
quickSuggestions: false,
lightbulb: {
enabled: true
},
unicodeHighlight: {
ambiguousCharacters: false,
},
automaticLayout: true,
minimap: {
enabled: false
},
lineNumbersMinChars: 3,
'semanticHighlighting.enabled': true,
theme: 'vs-code-theme-converted'
})
const infoProvider = new InfoProvider(connection.getLeanClient(gameId))
const editorApi = infoProvider.getApi()
const editorEvents: EditorEvents = {
initialize: new EventEmitter(),
gotServerNotification: new EventEmitter(),
sentClientNotification: new EventEmitter(),
serverRestarted: new EventEmitter(),
serverStopped: new EventEmitter(),
changedCursorLocation: new EventEmitter(),
changedInfoviewConfig: new EventEmitter(),
runTestScript: new EventEmitter(),
requestedAction: new EventEmitter(),
};
// Challenge: write a type-correct fn from `Eventify<T>` to `T` without using `any`
const infoviewApi: InfoviewApi = {
initialize: async l => editorEvents.initialize.fire(l),
gotServerNotification: async (method, params) => {
editorEvents.gotServerNotification.fire([method, params]);
},
sentClientNotification: async (method, params) => {
editorEvents.sentClientNotification.fire([method, params]);
},
serverRestarted: async r => editorEvents.serverRestarted.fire(r),
serverStopped: async serverStoppedReason => {
editorEvents.serverStopped.fire(serverStoppedReason)
},
changedCursorLocation: async loc => editorEvents.changedCursorLocation.fire(loc),
changedInfoviewConfig: async conf => editorEvents.changedInfoviewConfig.fire(conf),
requestedAction: async action => editorEvents.requestedAction.fire(action),
// See https://rollupjs.org/guide/en/#avoiding-eval
// eslint-disable-next-line @typescript-eslint/no-implied-eval
runTestScript: async script => new Function(script)(),
getInfoviewHtml: async () => document.body.innerHTML,
};
const ec = new EditorConnection(editorApi, editorEvents);
setEditorConnection(ec)
editorEvents.initialize.on((loc: Location) => ec.events.changedCursorLocation.fire(loc))
setEditor(editor)
setInfoProvider(infoProvider)
setInfoviewApi(infoviewApi)
return () => { infoProvider.dispose(); editor.dispose() }
}, [])
const {leanClient, leanClientStarted} = useLeanClient(gameId)
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
// Create model when level changes
useEffect(() => {
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
if (!model) {
model = monaco.editor.createModel(initialCode, 'lean4', uri)
}
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
editor.setModel(model)
if (initialSelections) {
console.debug("Initial Selection: ", initialSelections)
// BUG: Somehow I get an `invalid arguments` bug here
// editor.setSelections(initialSelections)
}
}
}, [editor, levelId, connection, leanClientStarted])
useEffect(() => {
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
infoviewApi.serverRestarted(leanClient.initializeResult)
infoProvider.openPreview(editor, infoviewApi)
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); }
}
}, [editor, connection, leanClientStarted])
return {editor, infoProvider, editorConnection}
}
/** Open all files in this world on the server so that they will load faster when accessed */
function useLoadWorldFiles(worldId) {
const gameId = React.useContext(GameIdContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
const store = useStore()
useEffect(() => {
if (gameInfo.data) {
const models = []
for (let levelId = 1; levelId <= gameInfo.data.worldSize[worldId]; levelId++) {
const uri = monaco.Uri.parse(`file:///${worldId}/${levelId}`)
let model = monaco.editor.getModel(uri)
if (model) {
models.push(model)
} else {
const code = selectCode(gameId, worldId, levelId)(store.getState())
models.push(monaco.editor.createModel(code, 'lean4', uri))
}
}
return () => { for (let model of models) { model.dispose() } }
}
}, [gameInfo.data, worldId])
}

@ -5,7 +5,7 @@ import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import { Button, Dialog, DialogContent, DialogContentText, DialogActions } from '@mui/material';
import Markdown from './Markdown';
import Markdown from './markdown';
function Message({ isOpen, content, close }) {

@ -0,0 +1,106 @@
/**
* @fileOverview The impressum/privacy policy
*/
import { faShield } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as React from 'react'
/** Pop-up that is displayed when opening the privacy policy.
*
* `handleClose` is the function to close it again because it's open/closed state is
* controlled by the containing element.
*/
export function PrivacyPolicyPopup ({handleClose}: {handleClose: () => void}) {
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<h2>Privacy Policy &amp; Impressum</h2>
<p>
Our server collects metadata (such as IP address, browser, operating system)
and the data that the user enters into the editor. The data is used to
compute the Lean output and display it to the user. The information will be stored
as long as the user stays on our website and will be deleted immediately afterwards.
We keep logs to improve our software, but the contained data is anonymized.
</p>
<p>
We do not use cookies, but your game progress is stored in the browser
as site data. Your game progress is not saved on the server; if you delete
your browser storage, it is completely gone.
</p>
<p>Our server is located in Germany.</p>
<p>
<strong>Contact information:</strong><br />
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>
</div>
}
export const PrivacyPolicy: React.FC = () => {
const [open, setOpen] = React.useState(false)
const handleOpen = () => setOpen(true)
const handleClose = () => setOpen(false)
return (
<>
<div className="privacy" onClick={handleOpen} title="Privacy Policy &amp; Impressum">
<FontAwesomeIcon icon={faShield} />
<p className="p1">legal</p>
<p className="p2">notes</p>
</div>
{open ? <PrivacyPolicyPopup handleClose={handleClose} /> : null}
</>
)
}
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>
}

@ -17,10 +17,13 @@
.welcome .column {
height: 100%;
overflow: auto;
scroll-behavior: smooth;
}
.welcome-text {
padding: 20px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
}
i {
@ -54,16 +57,16 @@ h5, h6 {
/* SVG Graphic */
/***************/
svg .world-title-wrapper {
overflow: auto;
svg .world-title-wrapper, svg .level-title-wrapper div {
overflow: visible;
}
svg .world-title-wrapper div {
svg .world-title-wrapper div, svg .level-title-wrapper div {
width: 100%;
height: 100%;
}
svg .world-title-wrapper div {
svg .world-title-wrapper div, svg .level-title-wrapper div {
display: flex;
align-items:center;
justify-content:center;
@ -71,11 +74,36 @@ svg .world-title-wrapper div {
}
svg .world-title {
font-weight: 500;
svg .world-title, svg .level-title {
color: white;
margin: 0;
padding: 0;
text-align: center;
}
svg .world-title {
font-weight: 700;
}
svg .level-title {
font-weight: 400;
opacity: 0;
transition: opacity .3s;
}
svg .level:hover .level-title {
opacity: 1;
}
svg .disabled {
cursor: default;
}
.world-selection {
display: block;
margin-left: auto;
margin-right: auto;
margin-top: 2em;
}
/******************/
@ -87,7 +115,7 @@ svg .world-title {
height: 40px;
font-size: 25px;
border-radius: 20px;
position: absolute;
position: fixed;
right: 10px;
bottom: 10px;
display: flex;
@ -196,21 +224,41 @@ svg .world-title {
width: 100%;
}
.game-menu {
padding: .5em;
}
.game-menu .btn {
min-width: 5em;
text-align: center;
margin-left: .4em;
margin-right: .4em;
margin-bottom: .2em;
}
.modal a.download-link {
cursor: pointer;
font-style: italic;
text-decoration: underline dotted;
}
.mobile-nav {
padding-top: .5em;
padding-bottom: .5em;
position:relative;
height: 3em;
width: 100%;
}
.mobile-nav .btn-next, .mobile-nav .btn-previous {
position: absolute;
margin: 0;
}
.mobile-nav .btn-previous {
left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 0.3rem;
}
.mobile-nav .btn-next {
right: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: .3rem;
}
.mobile-nav .svg-inline--fa {
margin-left: 0.3rem;
margin-right: 0.3rem;
}

@ -0,0 +1,121 @@
import * as React from 'react'
import { useState, useEffect } from 'react'
import { useSelector } from 'react-redux'
import Split from 'react-split'
import { Box, Typography, CircularProgress } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faGlobe, faBook, faArrowRight, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'
import { useAppDispatch } from '../hooks'
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
import { useGetGameInfoQuery } from '../state/api'
import { Button } from './button'
import { MobileContext } from './infoview/context'
import { InventoryPanel } from './inventory'
import Markdown from './markdown'
import {PrivacyPolicy} from './privacy_policy'
import { WelcomeMenu, WorldTreePanel } from './world_tree'
import './welcome.css'
/** navigation to switch between pages on mobile */
function MobileNav({pageNumber, setPageNumber}:
{ pageNumber: number,
setPageNumber: any}) {
const gameId = React.useContext(GameIdContext)
const dispatch = useAppDispatch()
let prevText = {0 : null, 1: "Intro", 2: "Game"}[pageNumber]
let prevIcon = {0 : faGlobe, 1: null, 2: null}[pageNumber]
let prevTitle = {
0: "back to games selection",
1: "back to introduction",
2: "game tree"}[pageNumber]
let nextText = {0 : "Game", 1: null, 2: null}[pageNumber]
let nextIcon = {0 : null, 1: faBook, 2: null}[pageNumber]
let nextTitle = {
0: "game tree",
1: "inventory",
2: null}[pageNumber]
return <div className="mobile-nav">
{(prevText || prevTitle || prevIcon) &&
<Button className="btn btn-previous" to={pageNumber == 0 ? "/" : ""} title={prevTitle}
onClick={() => {pageNumber == 0 ? null : setPageNumber(pageNumber - 1)}}>
<FontAwesomeIcon icon={faArrowLeft} />
{prevIcon && <FontAwesomeIcon icon={prevIcon} />}
{prevText && `${prevText}`}
</Button>
}
{(nextText || nextTitle || nextIcon) &&
<Button className="btn btn-next" to=""
title={nextTitle} onClick={() => {
console.log(`page number: ${pageNumber}`)
setPageNumber(pageNumber+1);
dispatch(changedOpenedIntro({game: gameId, openedIntro: true}))}}>
{nextText && `${nextText}`}
{nextIcon && <FontAwesomeIcon icon={nextIcon} />}
<FontAwesomeIcon icon={faArrowRight}/>
</Button>
}
</div>
}
/** The panel showing the game's introduction text */
function IntroductionPanel({introduction}: { introduction: string}) {
const {mobile} = React.useContext(MobileContext)
return <div className="column">
<Typography variant="body1" component="div" className="welcome-text">
{!mobile && <WelcomeMenu />}
<Markdown>{introduction}</Markdown>
</Typography>
</div>
}
/** main page of the game showing amoung others the tree of worlds/levels */
function Welcome() {
const gameId = React.useContext(GameIdContext)
const {mobile} = React.useContext(MobileContext)
const gameInfo = useGetGameInfoQuery({game: gameId})
// On mobile, the intro page should only be shown the first time
const openedIntro = useSelector(selectOpenedIntro(gameId))
// On mobile, there are multiple pages to switch between
const [pageNumber, setPageNumber] = useState(openedIntro ? 1 : 0)
// set the window title
useEffect(() => {
if (gameInfo.data?.title) {
window.document.title = gameInfo.data.title
}
}, [gameInfo.data?.title])
return <div className="app-content">
{ gameInfo.isLoading?
<Box display="flex" alignItems="center" justifyContent="center" sx={{ height: "calc(100vh - 64px)" }}>
<CircularProgress />
</Box>
: mobile ?
<>
<MobileNav pageNumber={pageNumber} setPageNumber={setPageNumber} />
{(pageNumber == 0 ?
<IntroductionPanel introduction={gameInfo.data?.introduction} />
: pageNumber == 1 ?
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
:
<InventoryPanel />
)}
</>
:
<Split className="welcome" minSize={0} snapOffset={200} sizes={[40, 35, 25]}>
<IntroductionPanel introduction={gameInfo.data?.introduction} />
<WorldTreePanel worlds={gameInfo.data?.worlds} worldSize={gameInfo.data?.worldSize} />
<InventoryPanel />
</Split>
}
<PrivacyPolicy />
</div>
}
export default Welcome

@ -0,0 +1,47 @@
.world-selection-menu {
padding: .5em;
}
.world-selection-menu .btn, .welcome .btn {
min-width: 5em;
text-align: center;
margin-left: .4em;
margin-right: .4em;
margin-bottom: .2em;
}
.world-selection-menu .slider-wrap {
display: flex;
flex-direction: row;
width: 100%;
/* min-width: 16em; */
padding-left: 0.5em;
padding-right: 3em;
margin-left: auto;
margin-right: auto;
}
.difficulty-label {
font-size: 0.875em;
padding-top: 4px;
padding-right: 1em;
}
/* Test for mobile `title`s */
/* @media (pointer: coarse), (hover: none) {
[title] {
position: relative;
display: inline-flex;
justify-content: center;
}
[title]:focus::after {
content: attr(title);
position: absolute;
top: 90%;
color: #000;
background-color: #fff;
border: 1px solid;
width: fit-content;
padding: 3px;
}
} */

@ -0,0 +1,385 @@
/**
* @fileOverview Define the menu displayed with the tree of worlds on the welcome page
*/
import * as React from 'react'
import { Link } from 'react-router-dom'
import { useStore, useSelector } from 'react-redux'
import { Slider } from '@mui/material'
import cytoscape, { LayoutOptions } from 'cytoscape'
import klay from 'cytoscape-klay'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faGlobe, faArrowLeft } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from '../app'
import { useAppDispatch } from '../hooks'
import { deleteProgress, selectProgress, loadProgress, GameProgressState,
selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
import { store } from '../state/store'
import { Button } from './button'
import './world_tree.css'
// Settings for the world tree
cytoscape.use( klay )
const N = 18 // max number of levels per world
const R = 64 // radius of a world
const r = 12 // radius of a level
const s = 10 // global scale
const padding = R + 2*r // padding of the graphic (on a different scale)
const ds = .75 // scale the resulting svg image
// colours
const grey = '#999'
const lightgrey = '#bbb'
const green = 'green'
const lightgreen = '#139e13'
const blue = '#1976d2'
/** svg object for a level in the game tree */
export function LevelIcon({ world, level, position, completed, unlocked }:
{ world: string,
level: number,
position: cytoscape.Position,
completed: boolean,
unlocked: boolean,
}) {
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const x = s * position.x + Math.sin(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
const y = s * position.y - Math.cos(level * 2 * Math.PI / N) * (R + 1.2*r + 2.4*r*Math.floor((level - 1)/N))
const levelDisabled = (difficulty >= 2 && !(unlocked || completed))
return (
<Link to={levelDisabled ? '' : `/${gameId}/world/${world}/level/${level}`}
className={`level${levelDisabled ? ' disabled' : ''}`}>
<circle fill={completed ? lightgreen : unlocked? blue : grey} cx={x} cy={y} r={r} />
<foreignObject className="level-title-wrapper" x={x} y={y}
width={1.42*r} height={1.42*r} transform={"translate("+ -.71*r +","+ -.71*r +")"}>
<div>
<p className="level-title" style={{fontSize: Math.floor(r) + "px"}}>
{level}
</p>
</div>
</foreignObject>
</Link>
)
}
/** svg object of one world in the game tree */
export function WorldIcon({world, title, position, completedLevels, difficulty}:
{ world: string,
title: string,
position: cytoscape.Position,
completedLevels: any,
difficulty: number }) {
// index `0` indicates that all prerequisites are completed
let unlocked = completedLevels[0]
// indices `1`-`n` indicate that the corresponding level is completed
let completed = completedLevels.slice(1).every(Boolean)
// select the first non-completed level
let nextLevel: number = completedLevels.findIndex(c => !c)
if (nextLevel <= 1) {
// note: `findIndex` returns `-1` on failure, therefore the indices
// `-1, 0, 1` indicate all that the introduction should be shown
nextLevel = 0
}
let playable = difficulty <= 1 || completed || unlocked
const gameId = React.useContext(GameIdContext)
return <Link
to={playable ? `/${gameId}/world/${world}/level/${nextLevel}` : ''}
className={playable ? '' : 'disabled'}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill={completed ? green : unlocked ? blue : grey}/>
<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 +")"}>
<div className={unlocked && !completed ? "playable-world" : ''}>
<p className="world-title" style={{fontSize: Math.floor(R/4) + "px"}}>
{title ? title : world}
</p>
</div>
</foreignObject>
</Link>
}
/** svg object for a connection path between worlds in the game tree */
export function WorldPath({source, target, unlocked} : {source: any, target: any, unlocked: boolean}) {
return <line x1={s*source.position.x} y1={s*source.position.y}
x2={s*target.position.x} y2={s*target.position.y}
stroke={unlocked ? green : lightgrey} strokeWidth={s}/>
}
/** Download a file containing `data` */
const downloadFile = ({ data, fileName, fileType } :
{ data: string
fileName: string
fileType: string}) => {
const blob = new Blob([data], { type: fileType })
const a = document.createElement('a')
a.download = fileName
a.href = window.URL.createObjectURL(blob)
const clickEvt = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
a.dispatchEvent(clickEvt)
a.remove()
}
// TODO: I think this should be removed and that single button incorporated differently
/** Menu on desktop to go back to game server */
export function WelcomeMenu() {
return <nav className="world-selection-menu">
<Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button>
</nav>
}
/** 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 ? 'playground' : x == 1 ? 'explorer' : '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">difficulty:</span>
<Slider
title="Difficulties:&#10;- regular: 🔐 levels, 🔐 tactics&#10;- explorer: 🔓 levels, 🔐 tactics&#10;- playground: 🔓 levels, 🔓 tactics"
min={0} max={2}
aria-label="Mode"
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>
}
export function computeWorldLayout(worlds) {
let elements = []
for (let id in worlds.nodes) {
elements.push({ data: { id: id, title: worlds.nodes[id].title } })
}
for (let edge of worlds.edges) {
elements.push({
data: {
id: edge[0] + " --edge-to--> " + edge[1],
source: edge[0],
target: edge[1]
}
})
}
const cy = cytoscape({
container: null,
elements,
headless: true,
styleEnabled: false
})
const layout = cy.layout({name: "klay", klay: {direction: "DOWN", nodePlacement: "LINEAR_SEGMENTS"}} as LayoutOptions).run()
let nodes = {}
cy.nodes().forEach((node, id) => {
nodes[node.id()] = {
position: node.position(),
data: node.data()
}
})
const bounds = cy.nodes().boundingBox()
return { nodes, bounds }
}
export function WorldTreePanel({worlds, worldSize}:
{ worlds: any,
worldSize: any}) {
const gameId = React.useContext(GameIdContext)
const difficulty = useSelector(selectDifficulty(gameId))
const {nodes, bounds}: any = worlds ? computeWorldLayout(worlds) : {nodes: []}
// scroll to playable world
React.useEffect(() => {
let elems = Array.from(document.getElementsByClassName("playable-world"))
if (elems.length) {
// it seems that the last element is the one furthest up in the tree
// TODO: I think they appear in random order. Check there position and select the lowest one
// of these positions to scroll to.
let elem = elems[0]
console.debug(`scrolling to ${elem.textContent}`)
elem.scrollIntoView({block: "center"})
}
}, [worlds, worldSize])
let svgElements = []
// for each `worldId` as index, this contains a list of booleans with indices
// 0, 1, …, n. Index `0` will be set to `false` if any dependency is not completely solved.
// Indices `1, …, n` indicate if the corresponding level is completed
var completed = {}
if (worlds && worldSize) {
// Fill `completed` with the level data.
for (let worldId in nodes) {
completed[worldId] = Array.from({ length: worldSize[worldId] + 1 }, (_, i) => {
// index `0` starts off as `true` but can be set to `false` by any edge with non-completed source
return i == 0 || selectCompleted(gameId, worldId, i)(store.getState())
})
}
// draw all connecting paths
for (let i in worlds.edges) {
const edge = worlds.edges[i]
let sourceCompleted = completed[edge[0]].slice(1).every(Boolean)
// if the origin world is not completed, mark the target world as non-playable
if (!sourceCompleted) {completed[edge[1]][0] = false}
svgElements.push(
<WorldPath source={nodes[edge[0]]} target={nodes[edge[1]]} unlocked={sourceCompleted}/>
)
}
// draw the worlds and levels
for (let worldId in nodes) {
let position: cytoscape.Position = nodes[worldId].position
svgElements.push(
<WorldIcon world={worldId}
title={nodes[worldId].data.title || worldId}
position={position}
completedLevels={completed[worldId]}
difficulty={difficulty}
key={`${gameId}-${worldId}`} />
)
for (let i = 1; i <= worldSize[worldId]; i++) {
svgElements.push(
<LevelIcon
world={worldId}
level={i}
position={position}
completed={completed[worldId][i]} unlocked={completed[worldId][i-1]}
key={`${gameId}-${worldId}-${i}`} />
)
}
}
}
let dx = bounds ? s*(bounds.x2 - bounds.x1) + 2*padding : null
return <div className="column">
<WorldSelectionMenu />
<svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink"
width={bounds ? `${ds * dx}` : ''}
viewBox={bounds ? `${s*bounds.x1 - padding} ${s*bounds.y1 - padding} ${dx} ${s*(bounds.y2 - bounds.y1) + 2 * padding}` : ''}
className="world-selection"
>
{svgElements}
</svg>
</div>
}

@ -1,9 +1,10 @@
/**
* @fileOverview todo
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { LeanClient } from 'lean4web/client/src/editor/leanclient';
import * as React from 'react';
import { useState } from 'react';
export class Connection {
private game: string = undefined // We only keep a connection to a single game at a time
@ -54,7 +55,7 @@ export const ConnectionContext = React.createContext(null);
export const useLeanClient = (gameId) => {
const leanClient = connection.getLeanClient(gameId)
const [leanClientStarted, setLeanClientStarted] = useState(leanClient.isStarted())
const [leanClientStarted, setLeanClientStarted] = React.useState(leanClient.isStarted())
React.useEffect(() => {
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })

@ -1,6 +1,6 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import App from './app';
import { ConnectionContext, connection } from './connection'
import { store } from './state/store';
import { Provider } from 'react-redux';
@ -9,10 +9,10 @@ import {
RouterProvider,
Route,
} from "react-router-dom";
import ErrorPage from './ErrorPage';
import Welcome from './components/Welcome';
import LandingPage from './components/LandingPage';
import Level from './components/Level';
import ErrorPage from './components/error_page';
import Welcome from './components/welcome';
import LandingPage from './components/landing_page';
import Level from './components/level';
import { monacoSetup } from 'lean4web/client/src/monacoSetup';
import { redirect } from 'react-router-dom';

@ -1,3 +1,6 @@
/**
* @fileOverview Define API of the server-client communication
*/
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { Connection } from '../connection'
@ -33,6 +36,14 @@ export interface LevelInfo {
statementName: null|string
}
/** Used to display the inventory on the welcome page */
export interface InventoryOverview {
tactics: InventoryTile[],
lemmas: InventoryTile[],
definitions: InventoryTile[],
lemmaTab: null,
}
interface Doc {
name: string,
displayName: string,
@ -71,6 +82,9 @@ export const apiSlice = createApi({
loadLevel: builder.query<LevelInfo, {game: string, world: string, level: number}>({
query: ({game, world, level}) => {return {game, method: "loadLevel", params: {world, level}}},
}),
loadInventoryOverview: builder.query<InventoryOverview, {game: string}>({
query: ({game}) => {return {game, method: "loadInventoryOverview", params: {}}},
}),
loadDoc: builder.query<Doc, {game: string, name: string, type: "lemma"|"tactic"}>({
query: ({game, name, type}) => {return {game, method: "loadDoc", params: {name, type}}},
}),
@ -79,4 +93,4 @@ export const apiSlice = createApi({
// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetGameInfoQuery, useLoadLevelQuery, useLoadDocQuery } = apiSlice
export const { useGetGameInfoQuery, useLoadLevelQuery, useLoadDocQuery, useLoadInventoryOverviewQuery } = apiSlice

@ -1,19 +0,0 @@
const KEY = "game_progress";
export function loadState() {
try {
const serializedState = localStorage.getItem(KEY);
if (!serializedState) return undefined;
return JSON.parse(serializedState);
} catch (e) {
return undefined;
}
}
export async function saveState(state: any) {
try {
const serializedState = JSON.stringify(state);
localStorage.setItem(KEY, serializedState);
} catch (e) {
// Ignore
}
}

@ -0,0 +1,33 @@
/**
* @fileOverview Load/save the state to the local browser store
*/
const KEY = "game_progress";
/** Load from browser storage */
export function loadState() {
try {
const serializedState = localStorage.getItem(KEY);
if (!serializedState) return undefined;
let x = JSON.parse(serializedState);
// Complatibilty because `state.level` has been renamed to `x.games`.
// TODO: Does this work?
if (x.level) {
x.games = x.level
x.level = undefined
}
return x
} catch (e) {
return undefined;
}
}
/** Save to browser storage */
export async function saveState(state: any) {
try {
const serializedState = JSON.stringify(state)
localStorage.setItem(KEY, serializedState);
} catch (e) {
// Ignore
}
}

@ -1,15 +1,11 @@
/**
* @fileOverview Defines the user progress which is loaded from the browser store and kept
*/
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import { loadState } from "./localStorage";
import { loadState } from "./local_storage";
import { WorkDoneProgressBegin } from 'vscode-languageserver-protocol';
export interface GameProgressState {
[world: string] : {[level: number]: LevelProgressState}
}
interface ProgressState {
level: {[game: string]: GameProgressState}
}
interface Selection {
selectionStartLineNumber: number,
selectionStartColumn: number,
@ -19,21 +15,56 @@ interface Selection {
interface LevelProgressState {
code: string,
selections: Selection[],
completed: boolean
completed: boolean,
help: number[], // A set of rows where hidden hints have been displayed
}
interface WorldProgressState {
[world: string] : {[level: number]: LevelProgressState},
}
export interface GameProgressState {
inventory: string[],
difficulty: number,
openedIntro: boolean,
data: WorldProgressState
}
const initialProgressState = loadState() ?? { level: {} } as ProgressState
const initalLevelProgressState = {code: "", completed: false} as LevelProgressState
/**
* Currently we have three difficulties:
*
* | lock tactics | lock levels |
* --|--------------|-------------|
* 0 | no | no |
* 1 | yes | no |
* 2 | yes | yes |
*/
const DEFAULT_DIFFICULTY = 2
function addLevelProgress(state, action: PayloadAction<{game: string, world: string, level: number}>) {
if (!state.level[action.payload.game]) {
state.level[action.payload.game] = {}
/** The progress made on all lean4-games */
interface ProgressState {
games: {[game: string]: GameProgressState}
}
const initialProgressState: ProgressState = loadState() ?? { games: {} }
// TODO: There was some weird unreproducible bug with removing `as LevelProgressState` here...
const initalLevelProgressState: LevelProgressState = {code: "", completed: false, selections: [], help: []}
/** Add an empty skeleton with progress for the current game */
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
if (!state.games[action.payload.game]) {
state.games[action.payload.game] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
}
if (!state.level[action.payload.game][action.payload.world]) {
state.level[action.payload.game][action.payload.world] = {}
}
/** Add an empty skeleton with progress for the current level */
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addGameProgress(state, action)
if (!state.games[action.payload.game].data[action.payload.world]) {
state.games[action.payload.game].data[action.payload.world] = {}
}
if (!state.level[action.payload.game][action.payload.world][action.payload.level]) {
state.level[action.payload.game][action.payload.world][action.payload.level] = {...initalLevelProgressState}
if (!state.games[action.payload.game].data[action.payload.world][action.payload.level]) {
state.games[action.payload.game].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
}
}
@ -41,60 +72,128 @@ export const progressSlice = createSlice({
name: 'progress',
initialState: initialProgressState,
reducers: {
codeEdited(state, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
/** put edited code in the state and set completed to false */
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
addLevelProgress(state, action)
state.level[action.payload.game][action.payload.world][action.payload.level].code = action.payload.code
state.level[action.payload.game][action.payload.world][action.payload.level].completed = false
state.games[action.payload.game].data[action.payload.world][action.payload.level].code = action.payload.code
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = false
},
changedSelection(state, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
/** TODO: docstring */
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
addLevelProgress(state, action)
state.level[action.payload.game][action.payload.world][action.payload.level].selections = action.payload.selections
state.games[action.payload.game].data[action.payload.world][action.payload.level].selections = action.payload.selections
},
levelCompleted(state, action: PayloadAction<{game: string, world: string, level: number}>) {
/** mark level as completed */
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action)
state.level[action.payload.game][action.payload.world][action.payload.level].completed = true
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = true
},
/** Set the list of rows where help is displayed */
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
addLevelProgress(state, action)
console.debug(`!setting help to: ${action.payload.help}`)
state.games[action.payload.game].data[action.payload.world][action.payload.level].help = action.payload.help
},
/** delete all progress for this game */
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
state.games[action.payload.game] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
},
deleteProgress(state, action: PayloadAction<{game: string}>) {
state.level[action.payload.game] = {}
/** delete progress for this level */
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
addLevelProgress(state, action)
state.games[action.payload.game].data[action.payload.world][action.payload.level] = initalLevelProgressState
},
loadProgress(state, action: PayloadAction<{game: string, data:GameProgressState}>) {
/** load progress, e.g. from external import */
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
console.debug(`setting data to:\n ${action.payload.data}`)
state.level[action.payload.game] = action.payload.data
state.games[action.payload.game] = action.payload.data
},
/** set the current inventory */
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
addGameProgress(state, action)
state.games[action.payload.game].inventory = action.payload.inventory
},
/** set the difficulty */
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
addGameProgress(state, action)
state.games[action.payload.game].difficulty = action.payload.difficulty
},
/** set the difficulty */
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) {
addGameProgress(state, action)
state.games[action.payload.game].openedIntro = action.payload.openedIntro
},
}
})
/** if the level does not exist, return default values */
export function selectLevel(game: string, world: string, level: number) {
return (state) =>{
if (!state.progress.level[game]) { return initalLevelProgressState }
if (!state.progress.level[game][world]) { return initalLevelProgressState }
if (!state.progress.level[game][world][level]) { return initalLevelProgressState }
return state.progress.level[game][world][level]
if (!state.progress.games[game]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world]) { return initalLevelProgressState }
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState }
return state.progress.games[game].data[world][level]
}
}
/** return the code of the current level */
export function selectCode(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).code
}
}
/** return the current inventory */
export function selectInventory(game: string) {
return (state) => {
if (!state.progress.games[game]) { return [] }
return state.progress.games[game].inventory
}
}
/** return the code of the current level */
export function selectHelp(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).help
}
}
/** return the selections made in the current level */
export function selectSelections(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).selections
}
}
/** return whether the current level is clompleted */
export function selectCompleted(game: string, world: string, level: number) {
return (state) => {
return selectLevel(game, world, level)(state).completed
}
}
/** return progress for the current game if it exists */
export function selectProgress(game: string) {
return (state) => {
return state.progress.level[game] ?? null
return state.progress.games[game] ?? null
}
}
/** return difficulty for the current game if it exists */
export function selectDifficulty(game: string) {
return (state) => {
return state.progress.games[game]?.difficulty ?? DEFAULT_DIFFICULTY
}
}
/** return whether the intro has been read */
export function selectOpenedIntro(game: string) {
return (state) => {
return state.progress.games[game]?.openedIntro
}
}
export const { changedSelection, codeEdited, levelCompleted, deleteProgress, loadProgress } = progressSlice.actions
/** Export actions to modify the progress */
export const { changedSelection, codeEdited, levelCompleted, deleteProgress,
deleteLevelProgress, loadProgress, helpEdited, changedInventory, changedOpenedIntro,
changedDifficulty } = progressSlice.actions

@ -1,10 +1,14 @@
/**
* @fileOverview configure the store and save the state periodically to the browser storage
*/
import { configureStore } from '@reduxjs/toolkit';
import { debounce } from "debounce";
import { connection } from '../connection'
import thunkMiddleware from 'redux-thunk'
import { apiSlice } from './api'
import { progressSlice } from './progress'
import { saveState } from "./localStorage";
import { debounce } from "debounce";
import { saveState } from "./local_storage";
export const store = configureStore({
reducer: {
@ -20,10 +24,11 @@ export const store = configureStore({
}).concat(apiSlice.middleware),
});
// Save progress in local storage
/**
* Save progress in local storage once each 800ms.
* This is for better performance when multiple changes occur in a short time
*/
store.subscribe(
// we use debounce to save the state once each 800ms
// for better performances in case multiple changes occur in a short time
debounce(() => {
saveState(store.getState()[progressSlice.name]);
}, 800)

@ -0,0 +1,21 @@
import {useState, useEffect} from 'react'
function getWindowDimensions() {
const {innerWidth: width, innerHeight: height } = window
return {width, height}
}
export function useWindowDimensions() {
const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions())
useEffect(() => {
function handleResize() {
setWindowDimensions(getWindowDimensions())
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return windowDimensions
}

11140
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -8,6 +8,7 @@
"@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^4.5.8",
"@leanprover/infoview": "^0.4.3",
"@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1",
"@reduxjs/toolkit": "^1.9.1",
@ -26,6 +27,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^8.0.4",
"react-native": "^0.72.3",
"react-redux": "^8.0.5",
"react-router-dom": "^6.5.0",
"react-split": "^2.0.14",
@ -47,6 +49,7 @@
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@redux-devtools/core": "^3.13.1",
"@testing-library/react": "^13.4.0",
"@types/debounce": "^1.2.1",
"babel-loader": "^8.3.0",
"concurrently": "^7.6.0",
"css-loader": "^6.7.3",

@ -4,6 +4,12 @@ open Lean Meta Elab Command
set_option autoImplicit false
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
register_option lean4game.showDependencyReasons : Bool := {
defValue := false
descr := "show reasons for calculated world dependencies."
}
/-! # Game metadata -/
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
@ -49,26 +55,6 @@ elab "Conclusion" t:str : command => do
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString}
| .Game => modifyCurGame fun game => pure {game with conclusion := t.getString}
/-! ## World Paths -/
/-- The worlds of a game are joint by paths. These are defined with the syntax
`Path World₁ → World₂ → World₃`. -/
def Parser.path := Parser.sepBy1Indent Parser.ident "→"
/-- The worlds of a game are joint by paths. These are defined with the syntax
`Path World₁ → World₂ → World₃`. -/
elab "Path" s:Parser.path : command => do
let mut last : Option Name := none
for stx in s.raw.getArgs.getEvenElems do
let some l := last
| do
last := some stx.getId
continue
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with edges := game.worlds.edges.push (l, stx.getId)}}
last := some stx.getId
/-! # Inventory
@ -80,12 +66,17 @@ in the first level and get enabled during the game.
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning. -/
def checkInventoryDoc (type : InventoryType) (name : Ident)
warning.
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
I used this workaround, because I needed a new name (with correct namespace etc)
to be used, and I don't know how to create a new ident with same position but different name.
-/
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
(template : Option String := none) : CommandElabM Unit := do
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
let env ← getEnv
let n := name.getId
let n := name
-- Find a key with matching `(type, name)`.
match (inventoryTemplateExt.getState env).findIdx?
(fun x => x.name == n && x.type == type) with
@ -98,18 +89,18 @@ def checkInventoryDoc (type : InventoryType) (name : Ident)
-- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name.getId
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else "" })
logWarningAt name (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
-- Add the default documentation
| some s =>
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name.getId
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := s })
logInfoAt name (m!"Missing {type} Documentation: {name}, used provided default (e.g. " ++
logInfoAt ref (m!"Missing {type} Documentation: {name}, used provided default (e.g. " ++
m!"statement description) instead. If you want to write your own description, add " ++
m!"`{type}Doc {name}` somewhere above this statement.")
@ -282,6 +273,38 @@ elab "LemmaTab" category:str : command =>
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO
-- TODO: Reuse the following code for checking available tactics in user code:
structure UsedInventory where
(tactics : HashSet Name := {})
(definitions : HashSet Name := {})
(lemmas : HashSet Name := {})
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do
match stx with
| .missing => return acc
| .node info kind args =>
if kind == `tacticHint__ || kind == `tacticBranch_ then return acc
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc
| .atom info val =>
-- ignore syntax elements that do not start with a letter
-- and ignore some standard keywords
let allowed := ["with", "fun", "at", "only", "by"]
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
return {acc with tactics := acc.tactics.insert val}
else
return acc
| .ident info rawVal val preresolved =>
let ns ←
try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error
return ← ns.foldlM (fun acc n => do
if let some (.thmInfo ..) := (← getEnv).find? n then
return {acc with lemmas := acc.lemmas.insertMany ns}
else
return {acc with definitions := acc.definitions.insertMany ns}
) acc
/-- Define the statement of the current level. -/
elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : command => do
let lvlIdx ← getCurLevelIdx
@ -298,6 +321,12 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
let defaultDeclName : Ident := mkIdent <| (← getCurGame).name ++ (← getCurWorld).name ++
("level" ++ toString lvlIdx : String)
-- Collect all used tactics/lemmas in the sample proof:
let usedInventory ← match val with
| `(Parser.Command.declVal| := $proof:term) => do
collectUsedInventory proof
| _ => throwError "expected `:=`"
-- Add theorem to context.
match statementName with
| some name =>
@ -314,13 +343,13 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
let thmStatement ← `(theorem $defaultDeclName $sig $val)
elabCommand thmStatement
-- Check that statement has a docs entry.
checkInventoryDoc .Lemma name (template := descr)
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
else
let thmStatement ← `( theorem $name $sig $val)
elabCommand thmStatement
-- Check that statement has a docs entry.
checkInventoryDoc .Lemma name (template := descr)
checkInventoryDoc .Lemma name (name := fullName) (template := descr)
| none =>
let thmStatement ← `(theorem $defaultDeclName $sig $val)
@ -378,7 +407,10 @@ elab "Statement" statementName:ident ? descr:str ? sig:declSig val:declVal : com
| none => default
| some name => currNamespace ++ name.getId
descrFormat := (Format.join [head, " ", st, " := by"]).pretty 10
hints := hints }
hints := hints
tactics := {level.tactics with used := usedInventory.tactics.toArray}
definitions := {level.definitions with used := usedInventory.definitions.toArray}
lemmas := {level.lemmas with used := usedInventory.lemmas.toArray} }
/-! # Hints -/
@ -572,7 +604,7 @@ def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option S
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for this tactic, consider adding one " ++
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
@ -587,15 +619,117 @@ def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
match (newArrows.find? id, decendants.find? id) with
| (some _, some _) => return (newArrows, decendants)
| _ =>
let mut newArr := newArrows
let mut desc := decendants
desc := desc.insert id {} -- mark as worked in case of loops
newArr := newArr.insert id {} -- mark as worked in case of loops
let children := arrows.find! id
let mut trimmedChildren := children
let mut theseDescs := children
for child in children do
(newArr, desc) := removeTransitiveAux child arrows newArr desc
let childDescs := desc.find! child
theseDescs := theseDescs.insertMany childDescs
for d in childDescs do
trimmedChildren := trimmedChildren.erase d
desc := desc.insert id theseDescs
newArr := newArr.insert id trimmedChildren
return (newArr, desc)
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
let mut newArr := {}
let mut desc := {}
for id in arrows.toArray.map Prod.fst do
(newArr, desc) := removeTransitiveAux id arrows newArr desc
if (desc.find! id).contains id then
logError <| m!"Loop at {id}. " ++
m!"This should not happen and probably means that `findLoops` has a bug."
-- DEBUG:
-- for ⟨x, hx⟩ in desc.toList do
-- m := m ++ m!"{x}: {hx.toList}\n"
-- logError m
return newArr
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
For performance reason it returns a HashSet of visited
nodes as well. This is filled with all nodes ever looked at as they cannot be
part of a loop anymore. -/
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
(path : Array Name := #[]) (visited : HashSet Name := {}) :
Array Name × HashSet Name := Id.run do
let mut visited := visited
match path.getIdx? node with
| some i =>
-- Found a loop: `node` is already the iᵗʰ element of the path
return (path.extract i path.size, visited.insert node)
| none =>
for successor in arrows.findD node {} do
-- If we already visited the successor, it cannot be part of a loop anymore
if visited.contains successor then
continue
-- Find any loop involving `successor`
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
visited := visited.insert successor
-- No loop found in the dependants of `successor`
if loop.isEmpty then
continue
-- Found a loop, return it
return (loop, visited)
return (#[], visited.insert node)
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
let mut visited : HashSet Name := {}
for node in arrows.toArray.map (·.1) do
-- Skip a node if it was already visited
if visited.contains node then
continue
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
-- search starting from `node`
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
visited := moreVisited
if !loop.isEmpty then
return loop.toList
return []
/-- The worlds of a game are joint by dependencies. These are
automatically computed but can also be defined with the syntax
`Dependency World₁ → World₂ → World₃`. -/
def Parser.dependency := Parser.sepBy1Indent Parser.ident "→"
/-- Manually add a dependency between two worlds.
Normally, the dependencies are computed automatically by the
tactics & lemmas used in the example
proof and the ones introduced by `NewLemma`/`NewTactic`.
Use the command `Dependency World₁ → World₂` to add a manual edge to the graph,
for example if the only dependency between the worlds is given by
the narrative. -/
elab "Dependency" s:Parser.dependency : command => do
let mut last : Option Name := none
for stx in s.raw.getArgs.getEvenElems do
let some l := last
| do
last := some stx.getId
continue
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with edges := game.worlds.edges.push (l, stx.getId)}}
last := some stx.getId
/-- Build the game. This command will precompute various things about the game, such as which
tactics are available in each level etc. -/
elab "MakeGame" : command => do
let game ← getCurGame
-- Check for loops in world graph
if game.worlds.hasLoops then
throwError "World graph must not contain loops! Check your `Path` declarations."
let env ← getEnv
-- Now create The doc entries from the templates
@ -607,7 +741,7 @@ elab "MakeGame" : command => do
| "" =>
-- If documentation is missing, try using the docstring instead.
match docstring with
| some ds => s!"*(lean docstring)*\\\n{ds}" -- TODO `\n` does not work in markdown
| some ds => s!"*(lean docstring)*\\\n{ds}"
| none => "(missing)"
| template =>
-- TODO: Process content template.
@ -627,10 +761,131 @@ elab "MakeGame" : command => do
content := content
})
-- For each `worldId` this contains a set of items used in this world
let mut usedItemsInWorld : HashMap Name (HashSet Name) := {}
-- For each `worldId` this contains a set of items newly defined in this world
let mut newItemsInWorld : HashMap Name (HashSet Name) := {}
-- Calculate which "items" are used/new in which world
for (worldId, world) in game.worlds.nodes.toArray do
let mut usedItems : HashSet Name := {}
let mut newItems : HashSet Name := {}
for inventoryType in #[.Tactic, .Definition, .Lemma] do
for (levelId, level) in world.levels.toArray do
usedItems := usedItems.insertMany (level.getInventory inventoryType).used
newItems := newItems.insertMany (level.getInventory inventoryType).new
-- if the previous level was named, we need to add it as a new lemma
if inventoryType == .Lemma then
match levelId with
| 0 => pure ()
| 1 => pure () -- level ids start with 1, so we need to skip 1, too
| i₀ + 1 =>
match (world.levels.find! (i₀)).statementName with
| .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s =>
let name := Name.str pre s
newItems := newItems.insert name
if inventoryType == .Lemma then
-- if the last level was named, we need to add it as a new lemma
let i₀ := world.levels.size
match (world.levels.find! (i₀)).statementName with
| .anonymous => pure ()
| .num _ _ => panic "Did not expect to get a numerical statement name!"
| .str pre s =>
let name := Name.str pre s
newItems := newItems.insert name
usedItemsInWorld := usedItemsInWorld.insert worldId usedItems
newItemsInWorld := newItemsInWorld.insert worldId newItems
-- DEBUG: print new/used items
-- logInfo m!"{worldId} uses: {usedItems.toList}"
-- logInfo m!"{worldId} introduces: {newItems.toList}"
/- for each "item" this is a HashSet of `worldId`s that introduce this item -/
let mut worldsWithNewItem : HashMap Name (HashSet Name) := {}
for (worldId, world) in game.worlds.nodes.toArray do
for newItem in newItemsInWorld.find! worldId do
worldsWithNewItem := worldsWithNewItem.insert newItem $
(worldsWithNewItem.findD newItem {}).insert worldId
-- For each `worldId` this is a HashSet of `worldId`s that this world depends on.
let mut worldDependsOnWorlds : HashMap Name (HashSet Name) := {}
-- For a pair of `worldId`s `(id₁, id₂)` this is a HasSet of "items" why `id₁` depends on `id₂`.
let mut dependencyReasons : HashMap (Name × Name) (HashSet Name) := {}
-- Calculate world dependency graph `game.worlds`
for (dependentWorldId, _dependentWorld) in game.worlds.nodes.toArray do
let mut dependsOnWorlds : HashSet Name := {}
-- Adding manual dependencies that were specified via the `Dependency` command.
for (sourceId, targetId) in game.worlds.edges do
if targetId = dependentWorldId then
dependsOnWorlds := dependsOnWorlds.insert sourceId
for usedItem in usedItemsInWorld.find! dependentWorldId do
match worldsWithNewItem.find? usedItem with
| none => logWarning m!"No world introducing {usedItem}, but required by {dependentWorldId}"
| some worldIds =>
-- Only need a new dependency if the world does not introduce an item itself
if !worldIds.contains dependentWorldId then
-- Add all worlds as dependencies which introduce this item
-- TODO: Could do something more clever here.
dependsOnWorlds := dependsOnWorlds.insertMany worldIds
-- Store the dependency reasons for debugging
for worldId in worldIds do
let tmp := (dependencyReasons.findD (dependentWorldId, worldId) {}).insert usedItem
dependencyReasons := dependencyReasons.insert (dependentWorldId, worldId) tmp
worldDependsOnWorlds := worldDependsOnWorlds.insert dependentWorldId dependsOnWorlds
-- Debugging: show all dependency reasons if the option `lean4game.showDependencyReasons` is set
if lean4game.showDependencyReasons.get (← getOptions) then
for (world, dependencies) in worldDependsOnWorlds.toArray do
if dependencies.isEmpty then
logInfo m!"Dependencies of '{world}': none"
else
let mut msg := m!"Dependencies of '{world}':"
for dep in dependencies do
match dependencyReasons.find? (world, dep) with
| none =>
msg := msg ++ m!"\n· '{dep}': no reason found (manually added?)"
| some items =>
msg := msg ++ m!"\n· '{dep}' because of:\n {items.toList}"
logInfo msg
-- Check graph for loops and remove transitive edges
let loop := findLoops worldDependsOnWorlds
if loop != [] then
logError m!"Loop: Dependency graph has a loop: {loop}"
for i in [:loop.length] do
let w1 := loop[i]!
let w2 := loop[if i == loop.length - 1 then 0 else i + 1]!
match dependencyReasons.find? (w1, w2) with
-- This should not happen. Could use `find!` again...
| none => logError m!"Did not find a reason why {w1} depends on {w2}."
| some items =>
logError m!"{w1} depends on {w2} because of {items.toList}."
else
worldDependsOnWorlds ← removeTransitive worldDependsOnWorlds
for (dependentWorldId, worldIds) in worldDependsOnWorlds.toArray do
modifyCurGame fun game =>
pure {game with worlds := {game.worlds with
edges := game.worlds.edges.append (worldIds.toArray.map fun wid => (wid, dependentWorldId))}}
-- Apparently we need to reload `game` to get the changes to `game.worlds` we just made
let game ← getCurGame
-- Compute which inventory items are available in which level:
for inventoryType in #[.Tactic, .Definition, .Lemma] do
let mut newItemsInWorld : HashMap Name (HashSet Name) := {}
-- Which items are introduced in which world?
let mut lemmaStatements : HashMap (Name × Nat) Name := {}
-- TODO: I believe `newItemsInWorld` has way to many elements in it which we iterate over
-- e.g. we iterate over `ring` for `Lemma`s as well, but so far that seems to cause no problems
let mut allItems : HashSet Name := {}
for (worldId, world) in game.worlds.nodes.toArray do
let mut newItems : HashSet Name := {}
@ -670,12 +925,20 @@ elab "MakeGame" : command => do
let Availability₀ : HashMap Name InventoryTile :=
HashMap.ofList $
← allItems.toList.mapM fun item => do
let data := (← getInventoryItem? item inventoryType).get!
-- TODO: BUG, panic at `get!` in vscode
return (item, {
name := item
displayName := data.displayName
category := data.category })
-- Using a match statement because the error message of `Option.get!` is not helpful.
match (← getInventoryItem? item inventoryType) with
| none =>
-- Note: we did have a panic here before because lemma statement and doc entry
-- had mismatching namespaces
logError m!"There is no inventory item ({inventoryType}) for: {item}."
panic s!"Inventory item {item} not found!"
| some data =>
return (item, {
name := item
displayName := data.displayName
category := data.category })
-- Availability after a given world
let mut itemsInWorld : HashMap Name (HashMap Name InventoryTile) := {}
@ -683,6 +946,7 @@ elab "MakeGame" : command => do
-- Unlock all items from previous worlds
let mut items := Availability₀
let predecessors := game.worlds.predecessors worldId
-- logInfo m!"Predecessors: {predecessors.toArray.map fun (a) => (a)}"
for predWorldId in predecessors do
for item in newItemsInWorld.find! predWorldId do
let data := (← getInventoryItem? item inventoryType).get!
@ -740,7 +1004,6 @@ elab "MakeGame" : command => do
return level.setComputedInventory inventoryType itemsArray
/-! # Debugging tools -/
-- /-- Print current game for debugging purposes. -/

@ -202,6 +202,8 @@ structure LevelId where
deriving Inhabited
structure InventoryInfo where
/-- inventory items used by the main sample solution of this level -/
used : Array Name
/-- new inventory items introduced by this level -/
new : Array Name
/-- inventory items exceptionally forbidden in this level -/
@ -218,6 +220,7 @@ def getCurLevelId [MonadError m] : m LevelId := do
/-- Instance to make GameLevel Repr work -/
instance : Repr Elab.Command.Scope := ⟨fun s _ => repr s.currNamespace⟩
structure GameLevel where
index: Nat
/-- The title of the level. -/

@ -74,13 +74,21 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
let allowed := ["with", "fun", "at", "only"]
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
match levelParams.tactics.find? (fun t => t.name.toString == val) with
| none => addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
match levelParams.tactics.find? (·.name.toString == val) with
| none =>
-- Note: This case means that the tactic will never be introduced in the game.
match levelParams.inventory.find? (· == val) with
| none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
| some _ => pure () -- tactic is in the inventory, allow it.
| some tac =>
if tac.locked then
addErrorMessage info s!"You have not unlocked the tactic '{val}' yet!"
match levelParams.inventory.find? (· == val) with
| none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
| some _ => pure () -- tactic is in the inventory, allow it.
else if tac.disabled then
addErrorMessage 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 =>
let ns ←
try resolveGlobalConst (mkIdent val)
@ -90,17 +98,17 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
| return () -- not a theroem -> ignore
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions
match lemmasAndDefs.find? (fun l => l.name == n) with
| none => addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
| some lem =>
if lem.locked then
addErrorMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
else if lem.disabled then
addErrorMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addErrorMessage (info : SourceInfo) (s : MessageData) :=
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addWarningMessage (info : SourceInfo) (s : MessageData) :=
modify fun st => { st with
messages := st.messages.add {
fileName := inputCtx.fileName
severity := MessageSeverity.error
severity := MessageSeverity.warning
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
data := s
}
@ -108,7 +116,8 @@ where addErrorMessage (info : SourceInfo) (s : MessageData) :=
open Elab Meta Expr in
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
(couldBeEndSnap : Bool) (levelParams : Game.DidOpenLevelParams) (initParams : Lsp.InitializeParams): IO Snapshot := do
(couldBeEndSnap : Bool) (levelParams : Game.DidOpenLevelParams)
(initParams : Lsp.InitializeParams) : IO Snapshot := do
-- Recognize end snap
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
let endSnap : Snapshot := {
@ -161,7 +170,8 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
parseResultRef.set (tacticStx, cmdParserState)
-- Check for forbidden tactics
findForbiddenTactics inputCtx levelParams tacticStx
if levelParams.checkEnabled then
findForbiddenTactics inputCtx levelParams tacticStx
-- Insert invisible `skip` command to make sure we always display the initial goal
let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[]
@ -300,14 +310,21 @@ where
end Elab
structure GameWorkerState :=
(levelParams : Game.DidOpenLevelParams)
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
section Updates
/-- Given the new document, updates editable doc state. -/
def updateDocument (newMeta : DocumentMeta) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
def updateDocument (newMeta : DocumentMeta) : GameWorkerM Unit := do
let s ← get
let levelParams := s.levelParams
let ctx ← read
let oldDoc := (←get).doc
let oldDoc := (← StateT.lift get).doc
oldDoc.cancelTk.set
let initHeaderStx := (← get).initHeaderStx
let initHeaderStx := (← StateT.lift get).initHeaderStx
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
let cancelTk ← CancelToken.new
let headSnapTask := oldDoc.cmdSnaps.waitHead?
@ -347,7 +364,8 @@ section Updates
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams ctx
(startAfterMs := ctx.initParams.editDelay.toUInt32)
modify fun st => { st with doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk } }
StateT.lift <| modify fun st => { st with
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
end Updates
@ -359,7 +377,8 @@ section Initialization
fileMap := default
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
(levelParams : Game.DidOpenLevelParams) (initParams : InitializeParams): IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
(levelParams : Game.DidOpenLevelParams) (initParams : InitializeParams) :
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
let out ← IO.Process.output
{ cwd := levelParams.gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
@ -447,10 +466,10 @@ end Initialization
section NotificationHandling
def handleDidChange (levelParams : Game.DidOpenLevelParams) (p : DidChangeTextDocumentParams) : WorkerM Unit := do
def handleDidChange (p : DidChangeTextDocumentParams) : GameWorkerM Unit := do
let docId := p.textDocument
let changes := p.contentChanges
let oldDoc := (←get).doc
let oldDoc := (← StateT.lift get).doc
let some newVersion ← pure docId.version?
| throwServerError "Expected version number"
if newVersion ≤ oldDoc.meta.version then
@ -458,28 +477,28 @@ section NotificationHandling
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
else if ¬ changes.isEmpty then
let newDocText := foldDocumentChanges changes oldDoc.meta.text
updateDocument ⟨docId.uri, newVersion, newDocText⟩ levelParams
updateDocument ⟨docId.uri, newVersion, newDocText⟩
end NotificationHandling
section MessageHandling
def handleNotification (method : String) (params : Json) (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
parseParams paramType params >>= handler
def handleNotification (method : String) (params : Json) : GameWorkerM Unit := do
let handle := fun paramType [FromJson paramType] (handler : paramType → GameWorkerM Unit) =>
(StateT.lift <| parseParams paramType params) >>= handler
match method with
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange levelParams)
| "$/cancelRequest" => handle CancelParams handleCancelRequest
| "$/lean/rpc/release" => handle RpcReleaseParams handleRpcRelease
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams handleRpcKeepAlive
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
| _ => throwServerError s!"Got unsupported notification method: {method}"
end MessageHandling
section MainLoop
partial def mainLoop (levelParams : Game.DidOpenLevelParams) : WorkerM Unit := do
partial def mainLoop : GameWorkerM Unit := do
let ctx ← read
let mut st ← get
let mut st ← StateT.lift get
let msg ← ctx.hIn.readLspMessage
let filterFinishedTasks (acc : PendingRequestMap) (id : RequestID) (task : Task (Except IO.Error Unit))
: IO PendingRequestMap := do
@ -502,16 +521,25 @@ section MainLoop
set st
match msg with
| Message.request id method (some params) =>
-- TODO: What's this error message?
if method == "Game.getInteractiveGoals" then throwServerError "HELLO"
handleRequest id method (toJson params)
mainLoop levelParams
mainLoop
| Message.notification "exit" none =>
let doc := st.doc
doc.cancelTk.set
return ()
| Message.notification "$/game/setInventory" params =>
let p := (← parseParams Game.SetInventoryParams (toJson params))
let s ← get
set {s with levelParams := {s.levelParams with
inventory := p.inventory,
checkEnabled := p.checkEnabled}}
mainLoop
| Message.notification method (some params) =>
handleNotification method (toJson params) levelParams
mainLoop levelParams
handleNotification method (toJson params)
mainLoop
| _ => throwServerError "Got invalid JSON-RPC message"
end MainLoop
@ -533,11 +561,15 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
let _ ← IO.setStderr e
try
let (ctx, st) ← initializeWorker meta i o e initParams.param opts levelParams
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) (mainLoop levelParams)
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
StateT.run (s := {levelParams := levelParams}) <| (mainLoop)
return (0 : UInt32)
catch e =>
IO.eprintln e
publishDiagnostics meta #[{ range := ⟨⟨0, 0⟩, ⟨0, 0⟩⟩, severity? := DiagnosticSeverity.error, message := e.toString }] o
publishDiagnostics meta #[{
range := ⟨⟨0, 0⟩, ⟨0, 0⟩⟩,
severity? := DiagnosticSeverity.error,
message := e.toString }] o
return (1 : UInt32)
def workerMain (opts : Options) : IO UInt32 := do

@ -6,6 +6,8 @@ structure GameServerState :=
(env : Lean.Environment)
(game : Name)
(gameDir : String)
(inventory : Array String)
(checkEnabled : Bool)
abbrev GameServerM := StateT GameServerState Server.Watchdog.ServerM
@ -51,6 +53,13 @@ structure LevelInfo where
statementName : Option String
deriving ToJson, FromJson
structure InventoryOverview where
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
lemmaTab : Option String
deriving ToJson, FromJson
structure LoadLevelParams where
world : Name
level : Nat
@ -63,6 +72,9 @@ structure DidOpenLevelParams where
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
inventory : Array String
/-- if true the server gives warnings for used tactics/lemmas that are not unlocked. -/
checkEnabled : Bool
deriving ToJson, FromJson
structure LoadDocParams where
@ -70,6 +82,11 @@ structure LoadDocParams where
type : InventoryType
deriving ToJson, FromJson
structure SetInventoryParams where
inventory : Array String
checkEnabled : Bool
deriving ToJson, FromJson
def handleDidOpenLevel (params : Json) : GameServerM Unit := do
let p ← parseParams _ params
let m := p.textDocument
@ -83,15 +100,18 @@ def handleDidOpenLevel (params : Json) : GameServerM Unit := do
c.hLog.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}"
c.hLog.flush
-- Send an extra notification to the file worker to inform it about the level data
let s ← get
fw.stdin.writeLspNotification {
method := "$/game/didOpenLevel"
param := {
uri := m.uri
gameDir := (← get).gameDir
gameDir := s.gameDir
levelModule := lvl.module
tactics := lvl.tactics.tiles
lemmas := lvl.lemmas.tiles
definitions := lvl.definitions.tiles
inventory := s.inventory
checkEnabled := s.checkEnabled
: DidOpenLevelParams
}
}
@ -141,7 +161,7 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
return true
| Message.request id "loadDoc" params =>
let p ← parseParams LoadDocParams (toJson params)
-- let s ← get
let s ← get
let c ← read
let some doc ← getInventoryItem? p.name p.type
| do
@ -154,6 +174,39 @@ partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do
-- name := doc.name.toString }
c.hOut.writeLspResponse ⟨id, ToJson.toJson doc⟩
return true
| Message.notification "$/game/setInventory" params =>
let p := (← parseParams SetInventoryParams (toJson params))
let s ← get
set {s with inventory := p.inventory, checkEnabled := p.checkEnabled}
let st ← read
let workers ← st.fileWorkersRef.get
for (_, fw) in workers do
fw.stdin.writeLspMessage msg
return true
| Message.request id "loadInventoryOverview" _ =>
let s ← get
let some game ← getGame? s.game
| return false
-- All Levels have the same tiles, so we just load them from level 1 of an arbitrary world
-- and reset `new`, `disabled` and `unlocked`
match game.worlds.nodes.toList with
| [] => return false
| ⟨worldId, _⟩ :: _ =>
let some lvl ← getLevel? {game := s.game, world := worldId, level := 1}
| do return false
let inventory : InventoryOverview := {
tactics := lvl.tactics.tiles.map
({ · with locked := true, disabled := false, new := false }),
lemmas := lvl.lemmas.tiles.map
({ · with locked := true, disabled := false, new := false }),
definitions := lvl.definitions.tiles.map
({ · with locked := true, disabled := false, new := false }),
lemmaTab := none
}
let c ← read
c.hOut.writeLspResponse ⟨id, ToJson.toJson inventory⟩
return true
| _ => return false
| _ => return false

@ -23,41 +23,6 @@ instance : EmptyCollection (Graph α β) := ⟨default⟩
def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=
{g with nodes := g.nodes.insert a b}
/-- Check if graph contains loops -/
partial def Graph.hasLoops (g : Graph α β) (visited0 : HashSet α := {}): Bool := Id.run do
let mut visited : HashSet α := visited0
let all : Array α := g.nodes.toArray.map (·.1)
-- find some node that we haven't visited
let some x := all.find? fun x => ¬ visited.contains x
| return false -- We have visted all nodes and found no loops
visited := visited.insert x
match visitSuccessors x x visited with -- visit all recursive successors of x
| some visited' => visited := visited'
| none => return true -- none means a loop has been found
g.hasLoops visited -- continue looking for unvisited nodes
where
visitSuccessors (x : α) (x0 : α) (visited0 : HashSet α) : Option (HashSet α) := Id.run do
let mut visited : HashSet α := visited0
let directSuccessors := (g.edges.filter (·.1 == x)).map (·.2)
for y in directSuccessors do
if y == x0 then
return none -- loop found
if visited.contains y then
continue -- no loop possible here because the visited nodes do not lead to x0
visited := visited.insert y
match visitSuccessors y x0 visited with
| some visited' => visited := visited'
| none => return none
return some visited
-- #eval Graph.hasLoops ⟨HashMap.ofList [(2,2), (1,1)], #[(2,1)]⟩
partial def Graph.predecessors (g : Graph α β) (x : α) (acc : HashSet α := {}) : HashSet α := Id.run do
let mut res := acc
let directPredecessors := (g.edges.filter (·.2 == x)).map (·.1)

@ -109,7 +109,13 @@ def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
let i ← maybeTee "wdIn.txt" false i
let o ← maybeTee "wdOut.txt" true o
let e ← maybeTee "wdErr.txt" true e
let state := {env := ← createEnv gameDir module, game := gameName, gameDir := gameDir}
let state := {
env := ← createEnv gameDir module,
game := gameName,
gameDir := gameDir,
inventory := #[]
checkEnabled := false
}
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
-- We misuse the `rootUri` field to the gameName
let rootUri? := gameName

@ -1 +1 @@
leanprover/lean4:nightly-2023-03-09
leanprover/lean4:nightly-2023-06-20

@ -0,0 +1,18 @@
import GameServer.Commands
open Lean
-- E → A → B → C → A and
-- F → G → F
open HashMap in
def testArrows : HashMap Name (HashSet Name) :=
ofList [("a", (HashSet.empty.insert "b": HashSet Name).insert "d"),
("b", (HashSet.empty.insert "c": HashSet Name)),
("c", (HashSet.empty.insert "a": HashSet Name)),
("d", {}),
("f", (HashSet.empty.insert "g": HashSet Name)),
("g", (HashSet.empty.insert "f": HashSet Name)),
("e", (HashSet.empty.insert "a": HashSet Name).insert "f")]
-- some permutation of ``[`c, `a, `b]`` or ``[`f, `g]``
#eval findLoops testArrows
Loading…
Cancel
Save