Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d689c7ec86 | ||
|
|
16c979a6c2 | ||
|
|
698a88c545 | ||
|
|
3b660c5185 | ||
|
|
ebb8c98145 | ||
|
|
4abf05b77e | ||
|
|
df423aaace | ||
|
|
a67dcb306f | ||
|
|
37582e04d4 | ||
|
|
9d4a6df139 | ||
|
|
26202e5f36 | ||
|
|
ed017fa605 | ||
|
|
36bc52c960 | ||
|
|
e085f2f106 | ||
|
|
e975c455fe | ||
|
|
c006be5a9f | ||
|
|
49379a0930 | ||
|
|
03076b0161 | ||
|
|
bfb60ffedd | ||
|
|
ca576542ba | ||
|
|
fd5e507541 | ||
|
|
c103eeacfa | ||
|
|
e277a48749 | ||
|
|
16ff701518 | ||
|
|
20ca385e14 | ||
|
|
5a38543e3b | ||
|
|
a4a6f2e725 | ||
|
|
cc89e78e4c | ||
|
|
c533a635c2 | ||
|
|
ab98eaa3ba | ||
|
|
7114a8c4cb | ||
|
|
b7eb15184d | ||
|
|
892c70165a | ||
|
|
8929813e48 | ||
|
|
5d88cd6739 | ||
|
|
d16956da9b | ||
|
|
e15e5af126 | ||
|
|
8c93b3c5b3 | ||
|
|
cd1d212a3c | ||
|
|
406c2799b1 | ||
|
|
e579071a3b | ||
|
|
8cdac88b5a | ||
|
|
7882958f54 | ||
|
|
c4f4dbdc6b | ||
|
|
5f52e23f29 | ||
|
|
b067dea6e7 | ||
|
|
4edf67f0d6 | ||
|
|
004f81835f |
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
GameSkeleton
|
||||||
|
HiddenTactic
|
||||||
|
subgoals
|
||||||
|
KaTex
|
||||||
|
gameserver
|
||||||
|
lakefile
|
||||||
|
Zulip
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Lean 4 Game
|
# Lean 4 Game
|
||||||
|
|
||||||
This is the source code for a Lean 4 game platform hosted at [adam.math.hhu.de](https://adam.math.hhu.de).
|
This is the source code for a Lean game platform hosted at [adam.math.hhu.de](https://adam.math.hhu.de).
|
||||||
|
|
||||||
## Creating a Game
|
## Creating a Game
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ Please follow the tutorial [Creating a Game](doc/create_game.md). In particular,
|
|||||||
* Step 5: [How to Run Games Locally](doc/running_locally.md)
|
* Step 5: [How to Run Games Locally](doc/running_locally.md)
|
||||||
* Step 7: [How to Update an existing Game](doc/update_game.md)
|
* Step 7: [How to Update an existing Game](doc/update_game.md)
|
||||||
* Step 8: [How to Publishing a Game](doc/publish_game.md)
|
* Step 8: [How to Publishing a Game](doc/publish_game.md)
|
||||||
|
* [Troubleshooting](doc/troubleshoot.md)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -28,7 +29,9 @@ should be up-to-date:
|
|||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
not written yet
|
not fully written yet.
|
||||||
|
|
||||||
|
* [Server](doc/DOCUMENTATION.md): describes the server part (i.e. the content of `server/` und `relay/`).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -40,7 +43,8 @@ Providing the use access to a Lean instance running on the server is a severe se
|
|||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
The project is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
|
The project has pimarily been developed by Alexander Bentkamp and Jon Eugster.
|
||||||
|
|
||||||
|
It is based on ideas from the [Lean Game Maker](https://github.com/mpedramfar/Lean-game-maker) and the [Natural Number Game
|
||||||
(NNG)](https://www.ma.imperial.ac.uk/~buzzard/xena/natural_number_game/)
|
(NNG)](https://www.ma.imperial.ac.uk/~buzzard/xena/natural_number_game/)
|
||||||
by Kevin Buzzard and Mohammad Pedramfar.
|
by Kevin Buzzard and Mohammad Pedramfar, and on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4).
|
||||||
The project is based on Patrick Massot's prototype: [NNG4](https://github.com/PatrickMassot/NNG4).
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
var stderrBuffer = ""
|
|
||||||
var messageBuffer = []
|
|
||||||
var initialized = false;
|
|
||||||
var flushing = false;
|
|
||||||
|
|
||||||
var headerMode = true;
|
|
||||||
var header="";
|
|
||||||
var re = /Content-Length: (\d+)\r\n/i;
|
|
||||||
var contentLength = 0;
|
|
||||||
var content = []
|
|
||||||
var utf8decoder = new TextDecoder();
|
|
||||||
|
|
||||||
|
|
||||||
function flushMessageBuffer(){
|
|
||||||
if (initialized && !flushing) {
|
|
||||||
while(messageBuffer.length > 0) {
|
|
||||||
flushing = true;
|
|
||||||
var msg = messageBuffer.shift();
|
|
||||||
console.log(`Send message: ${msg}`);
|
|
||||||
Module.ccall('send_message', 'void', ['string'], [msg]);
|
|
||||||
console.log(`Message done: ${msg}`);
|
|
||||||
}
|
|
||||||
flushing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var Module = {
|
|
||||||
"arguments": ["--worker"],
|
|
||||||
"preRun": [function() {
|
|
||||||
function stdin() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stdout(asciiCode) {
|
|
||||||
if (headerMode) {
|
|
||||||
header += String.fromCharCode(asciiCode)
|
|
||||||
if (header.endsWith('\r\n\r\n')) {
|
|
||||||
const found = header.match(re)
|
|
||||||
if (found == null) { console.error(`Invalid header: ${header}`) }
|
|
||||||
contentLength = parseInt(found[1])
|
|
||||||
content = []
|
|
||||||
headerMode = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content.push(asciiCode)
|
|
||||||
if (content.length == contentLength) {
|
|
||||||
const message = utf8decoder.decode(new Uint8Array(content))
|
|
||||||
console.log(`Server: ${message}`)
|
|
||||||
postMessage(message);
|
|
||||||
headerMode = true
|
|
||||||
header = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stderr(asciiCode) {
|
|
||||||
stderrBuffer += String.fromCharCode(asciiCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
FS.init(stdin, stdout, stderr);
|
|
||||||
}],
|
|
||||||
"noInitialRun": true,
|
|
||||||
"onRuntimeInitialized": () => {
|
|
||||||
Module.ccall('main', 'void', [], []);
|
|
||||||
initialized = true;
|
|
||||||
if (stderrBuffer !== "") {
|
|
||||||
console.log(stderrBuffer);
|
|
||||||
stderrBuffer = ""
|
|
||||||
}
|
|
||||||
flushMessageBuffer();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
importScripts("server.js")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
onmessage = (ev) => {
|
|
||||||
console.log(`Client: ${ev.data}`)
|
|
||||||
messageBuffer.push(ev.data);
|
|
||||||
flushMessageBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
if (stderrBuffer !== "") {
|
|
||||||
console.log(stderrBuffer);
|
|
||||||
stderrBuffer = ""
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
setTimeout(() =>{
|
|
||||||
|
|
||||||
},2000)
|
|
||||||
+6
-22
@@ -8,40 +8,24 @@ import '@fontsource/roboto/700.css';
|
|||||||
|
|
||||||
import './css/reset.css';
|
import './css/reset.css';
|
||||||
import './css/app.css';
|
import './css/app.css';
|
||||||
import { MobileContext } from './components/infoview/context';
|
import { PreferencesContext} from './components/infoview/context';
|
||||||
import { useMobile } from './hooks';
|
import UsePreferences from "./state/hooks/use_preferences"
|
||||||
import { AUTO_SWITCH_THRESHOLD, getWindowDimensions} from './state/preferences';
|
|
||||||
|
|
||||||
export const GameIdContext = React.createContext<string>(undefined);
|
export const GameIdContext = React.createContext<string>(undefined);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { mobile, setMobile, lockMobile, setLockMobile } = useMobile();
|
|
||||||
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const gameId = "g/" + params.owner + "/" + params.repo
|
const gameId = "g/" + params.owner + "/" + params.repo
|
||||||
|
|
||||||
const automaticallyAdjustLayout = () => {
|
const {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences} = UsePreferences()
|
||||||
const {width} = getWindowDimensions()
|
|
||||||
setMobile(width < AUTO_SWITCH_THRESHOLD)
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(()=>{
|
|
||||||
if (!lockMobile){
|
|
||||||
void automaticallyAdjustLayout()
|
|
||||||
window.addEventListener('resize', automaticallyAdjustLayout)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', automaticallyAdjustLayout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [lockMobile])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<GameIdContext.Provider value={gameId}>
|
<GameIdContext.Provider value={gameId}>
|
||||||
<MobileContext.Provider value={{mobile, setMobile, lockMobile, setLockMobile}}>
|
<PreferencesContext.Provider value={{mobile, layout, isSavePreferences, setLayout, setIsSavePreferences}}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</MobileContext.Provider>
|
</PreferencesContext.Provider>
|
||||||
</GameIdContext.Provider>
|
</GameIdContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
|
|||||||
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
|
faArrowRight, faArrowLeft, faXmark, faBars, faCode,
|
||||||
faCircleInfo, faTerminal, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons'
|
faCircleInfo, faTerminal, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { GameIdContext } from "../app"
|
import { GameIdContext } from "../app"
|
||||||
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
|
import { InputModeContext, PreferencesContext, WorldLevelIdContext } from "./infoview/context"
|
||||||
import { GameInfo, useGetGameInfoQuery } from '../state/api'
|
import { GameInfo, useGetGameInfoQuery } from '../state/api'
|
||||||
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
|
import { changedOpenedIntro, selectCompleted, selectDifficulty, selectProgress } from '../state/progress'
|
||||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||||
@@ -162,7 +162,7 @@ export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpres
|
|||||||
}) {
|
}) {
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const gameProgress = useAppSelector(selectProgress(gameId))
|
const gameProgress = useAppSelector(selectProgress(gameId))
|
||||||
const {mobile, setMobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
const [navOpen, setNavOpen] = React.useState(false)
|
const [navOpen, setNavOpen] = React.useState(false)
|
||||||
|
|
||||||
return <div className="app-bar">
|
return <div className="app-bar">
|
||||||
@@ -212,7 +212,7 @@ export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=
|
|||||||
}) {
|
}) {
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
|
const {worldId, levelId} = React.useContext(WorldLevelIdContext)
|
||||||
const {mobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
const [navOpen, setNavOpen] = React.useState(false)
|
const [navOpen, setNavOpen] = React.useState(false)
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
const completed = useAppSelector(selectCompleted(gameId, worldId, levelId))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as React from 'react';
|
|||||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
|
||||||
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
|
import { InteractiveDiagnostic, InteractiveTermGoal } from '@leanprover/infoview-api';
|
||||||
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
|
import { GameHint, InteractiveGoal, InteractiveGoals } from './rpc_api';
|
||||||
|
import { PreferencesState } from '../../state/preferences';
|
||||||
|
|
||||||
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
|
export const MonacoEditorContext = React.createContext<monaco.editor.IStandaloneCodeEditor>(
|
||||||
null as any)
|
null as any)
|
||||||
@@ -62,18 +63,18 @@ export const ProofStateContext = React.createContext<{
|
|||||||
setProofState: () => {},
|
setProofState: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export interface IMobileContext {
|
export interface IPreferencesContext extends PreferencesState{
|
||||||
mobile : boolean,
|
mobile: boolean, // The variables that actually control the page 'layout' can only be changed through layout.
|
||||||
setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
|
setLayout: React.Dispatch<React.SetStateAction<PreferencesState["layout"]>>;
|
||||||
lockMobile: boolean,
|
setIsSavePreferences: React.Dispatch<React.SetStateAction<Boolean>>;
|
||||||
setLockMobile: React.Dispatch<React.SetStateAction<Boolean>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileContext = React.createContext<IMobileContext>({
|
export const PreferencesContext = React.createContext<IPreferencesContext>({
|
||||||
mobile: false,
|
mobile: false,
|
||||||
setMobile: () => {},
|
layout: "auto",
|
||||||
lockMobile: false,
|
isSavePreferences: false,
|
||||||
setLockMobile: () => {}
|
setLayout: () => {},
|
||||||
|
setIsSavePreferences: () => {}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const WorldLevelIdContext = React.createContext<{
|
export const WorldLevelIdContext = React.createContext<{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import Markdown from '../markdown';
|
|||||||
import { Infos } from './infos';
|
import { Infos } from './infos';
|
||||||
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
|
import { AllMessages, Errors, WithLspDiagnosticsContext } from './messages';
|
||||||
import { Goal } from './goals';
|
import { Goal } from './goals';
|
||||||
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context';
|
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext, ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './context';
|
||||||
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter';
|
import { Typewriter, hasErrors, hasInteractiveErrors } from './typewriter';
|
||||||
import { InteractiveDiagnostic } from '@leanprover/infoview/*';
|
import { InteractiveDiagnostic } from '@leanprover/infoview/*';
|
||||||
import { Button } from '../button';
|
import { Button } from '../button';
|
||||||
@@ -349,7 +349,7 @@ export function TypewriterInterface({props}) {
|
|||||||
const [disableInput, setDisableInput] = React.useState<boolean>(false)
|
const [disableInput, setDisableInput] = React.useState<boolean>(false)
|
||||||
const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
|
const [loadingProgress, setLoadingProgress] = React.useState<number>(0)
|
||||||
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
|
const { setDeletedChat, showHelp, setShowHelp } = React.useContext(DeletedChatContext)
|
||||||
const {mobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
const { proof } = React.useContext(ProofContext)
|
const { proof } = React.useContext(ProofContext)
|
||||||
const { setTypewriterInput } = React.useContext(InputModeContext)
|
const { setTypewriterInput } = React.useContext(InputModeContext)
|
||||||
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
|
const { selectedStep, setSelectedStep } = React.useContext(SelectionContext)
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ function LandingPage() {
|
|||||||
"leanprover-community/nng4",
|
"leanprover-community/nng4",
|
||||||
"hhu-adam/robo",
|
"hhu-adam/robo",
|
||||||
"djvelleman/stg4",
|
"djvelleman/stg4",
|
||||||
"miguelmarco/STG4",
|
"miguelmarco/stg4",
|
||||||
|
"trequetrum/lean4game-logic",
|
||||||
]
|
]
|
||||||
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
|
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { Button } from './button'
|
|||||||
import Markdown from './markdown'
|
import Markdown from './markdown'
|
||||||
import {InventoryPanel} from './inventory'
|
import {InventoryPanel} from './inventory'
|
||||||
import { hasInteractiveErrors } from './infoview/typewriter'
|
import { hasInteractiveErrors } from './infoview/typewriter'
|
||||||
import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContext,
|
import { DeletedChatContext, InputModeContext, PreferencesContext, MonacoEditorContext,
|
||||||
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
|
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
|
||||||
import { DualEditor } from './infoview/main'
|
import { DualEditor } from './infoview/main'
|
||||||
import { GameHint } from './infoview/rpc_api'
|
import { GameHint } from './infoview/rpc_api'
|
||||||
@@ -74,7 +74,7 @@ function Level() {
|
|||||||
|
|
||||||
function ChatPanel({lastLevel}) {
|
function ChatPanel({lastLevel}) {
|
||||||
const chatRef = useRef<HTMLDivElement>(null)
|
const chatRef = useRef<HTMLDivElement>(null)
|
||||||
const {mobile} = useContext(MobileContext)
|
const {mobile} = useContext(PreferencesContext)
|
||||||
const gameId = useContext(GameIdContext)
|
const gameId = useContext(GameIdContext)
|
||||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
const level = useLoadLevelQuery({game: gameId, world: worldId, level: levelId})
|
||||||
@@ -215,7 +215,7 @@ function PlayableLevel({impressum, setImpressum}) {
|
|||||||
const codeviewRef = useRef<HTMLDivElement>(null)
|
const codeviewRef = useRef<HTMLDivElement>(null)
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
const {worldId, levelId} = useContext(WorldLevelIdContext)
|
||||||
const {mobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
@@ -441,7 +441,7 @@ function PlayableLevel({impressum, setImpressum}) {
|
|||||||
function IntroductionPanel({gameInfo}) {
|
function IntroductionPanel({gameInfo}) {
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const {worldId} = useContext(WorldLevelIdContext)
|
const {worldId} = useContext(WorldLevelIdContext)
|
||||||
const {mobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
|
|
||||||
let text: Array<string> = gameInfo.data?.worlds.nodes[worldId].introduction.split(/\n(\s*\n)+/)
|
let text: Array<string> = gameInfo.data?.worlds.nodes[worldId].introduction.split(/\n(\s*\n)+/)
|
||||||
|
|
||||||
@@ -468,7 +468,7 @@ export default Level
|
|||||||
/** The site with the introduction text of a world */
|
/** The site with the introduction text of a world */
|
||||||
function Introduction({impressum, setImpressum}) {
|
function Introduction({impressum, setImpressum}) {
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const {mobile} = useContext(MobileContext)
|
const {mobile} = useContext(PreferencesContext)
|
||||||
|
|
||||||
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,42 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { Input, Typography } from '@mui/material'
|
import { Input, Typography } from '@mui/material'
|
||||||
import Markdown from '../markdown'
|
import Markdown from '../markdown'
|
||||||
import Switch from '@mui/material/Switch';
|
import { Switch, Button, ButtonGroup } from '@mui/material';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Slider from '@mui/material/Slider';
|
||||||
|
|
||||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
|
||||||
import { IMobileContext } from "../infoview/context"
|
import { IPreferencesContext } from "../infoview/context"
|
||||||
|
|
||||||
interface PreferencesPopupProps extends IMobileContext{
|
interface PreferencesPopupProps extends Omit<IPreferencesContext, 'mobile'> {
|
||||||
handleClose: () => void
|
handleClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PreferencesPopup({ layout, setLayout, isSavePreferences, setIsSavePreferences, handleClose }: PreferencesPopupProps) {
|
||||||
|
|
||||||
|
const marks = [
|
||||||
|
{
|
||||||
|
value: 0,
|
||||||
|
label: 'Mobile',
|
||||||
|
key: "mobile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 1,
|
||||||
|
label: 'Auto',
|
||||||
|
key: "auto"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 2,
|
||||||
|
label: 'Desktop',
|
||||||
|
key: "desktop"
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlerChangeLayout = (_: Event, value: number) => {
|
||||||
|
setLayout(marks[value].key as IPreferencesContext["layout"])
|
||||||
|
}
|
||||||
|
|
||||||
export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile, handleClose }: PreferencesPopupProps) {
|
|
||||||
return <div className="modal-wrapper">
|
return <div className="modal-wrapper">
|
||||||
<div className="modal-backdrop" onClick={handleClose} />
|
<div className="modal-backdrop" onClick={handleClose} />
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
@@ -18,34 +44,43 @@ export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile,
|
|||||||
<Typography variant="body1" component="div" className="settings">
|
<Typography variant="body1" component="div" className="settings">
|
||||||
<div className='preferences-category'>
|
<div className='preferences-category'>
|
||||||
<div className='category-title'>
|
<div className='category-title'>
|
||||||
<h3>Mobile layout</h3>
|
<h3>Layout</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className='preferences-item'>
|
<div className='preferences-item first leave-left-gap'>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Box sx={{ width: 300 }}>
|
||||||
checked={mobile}
|
<Slider
|
||||||
onChange={() => setMobile(!mobile)}
|
aria-label="Always visible"
|
||||||
name="checked"
|
value={marks.find(item => item.key === layout).value}
|
||||||
color="primary"
|
step={1}
|
||||||
/>
|
marks={marks}
|
||||||
|
max={2}
|
||||||
|
sx={{
|
||||||
|
'& .MuiSlider-track': { display: 'none', },
|
||||||
|
}}
|
||||||
|
onChange={handlerChangeLayout}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
label="Enable"
|
label=""
|
||||||
labelPlacement="start"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='preferences-category tail-category'>
|
||||||
<div className='preferences-item'>
|
<div className='preferences-item'>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={!lockMobile}
|
checked={isSavePreferences}
|
||||||
onChange={() => setLockMobile(!lockMobile)}
|
onChange={() => setIsSavePreferences(!isSavePreferences)}
|
||||||
name="checked"
|
name="checked"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Auto"
|
label="Save my settings (in the browser store)"
|
||||||
labelPlacement="start"
|
labelPlacement="end"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useAppDispatch, useAppSelector } from '../hooks'
|
|||||||
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
|
import { changedOpenedIntro, selectOpenedIntro } from '../state/progress'
|
||||||
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'
|
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery } from '../state/api'
|
||||||
import { Button } from './button'
|
import { Button } from './button'
|
||||||
import { MobileContext } from './infoview/context'
|
import { PreferencesContext } from './infoview/context'
|
||||||
import { InventoryPanel } from './inventory'
|
import { InventoryPanel } from './inventory'
|
||||||
import { ErasePopup } from './popup/erase'
|
import { ErasePopup } from './popup/erase'
|
||||||
import { InfoPopup } from './popup/game_info'
|
import { InfoPopup } from './popup/game_info'
|
||||||
@@ -27,7 +27,7 @@ import { Hint } from './hints'
|
|||||||
|
|
||||||
/** the panel showing the game's introduction text */
|
/** the panel showing the game's introduction text */
|
||||||
function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) {
|
function IntroductionPanel({introduction, setPageNumber}: {introduction: string, setPageNumber}) {
|
||||||
const {mobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
@@ -64,7 +64,9 @@ function IntroductionPanel({introduction, setPageNumber}: {introduction: string,
|
|||||||
/** main page of the game showing among others the tree of worlds/levels */
|
/** main page of the game showing among others the tree of worlds/levels */
|
||||||
function Welcome() {
|
function Welcome() {
|
||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const {mobile, setMobile, lockMobile, setLockMobile} = React.useContext(MobileContext)
|
const {mobile} = React.useContext(PreferencesContext)
|
||||||
|
const {layout, isSavePreferences, setLayout, setIsSavePreferences} = React.useContext(PreferencesContext)
|
||||||
|
|
||||||
const gameInfo = useGetGameInfoQuery({game: gameId})
|
const gameInfo = useGetGameInfoQuery({game: gameId})
|
||||||
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
const inventory = useLoadInventoryOverviewQuery({game: gameId})
|
||||||
|
|
||||||
@@ -134,7 +136,7 @@ function Welcome() {
|
|||||||
{eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null}
|
{eraseMenu? <ErasePopup handleClose={closeEraseMenu}/> : null}
|
||||||
{uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null}
|
{uploadMenu? <UploadPopup handleClose={closeUploadMenu}/> : null}
|
||||||
{info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null}
|
{info ? <InfoPopup info={gameInfo.data?.info} handleClose={closeInfo}/> : null}
|
||||||
{preferencesPopup ? <PreferencesPopup mobile={mobile} setMobile={setMobile} lockMobile={lockMobile} setLockMobile={setLockMobile} handleClose={closePreferencesPopup}/> : null}
|
{preferencesPopup ? <PreferencesPopup layout={layout} isSavePreferences={isSavePreferences} setLayout={setLayout} setIsSavePreferences={setIsSavePreferences} handleClose={closePreferencesPopup}/> : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|||||||
import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
import { faXmark, faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
import { GameIdContext } from '../app'
|
import { GameIdContext } from '../app'
|
||||||
import { useAppDispatch, useMobile } from '../hooks'
|
import { useAppDispatch } from '../hooks'
|
||||||
import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
|
import { selectDifficulty, changedDifficulty, selectCompleted } from '../state/progress'
|
||||||
import { store } from '../state/store'
|
import { store } from '../state/store'
|
||||||
|
|
||||||
import '../css/world_tree.css'
|
import '../css/world_tree.css'
|
||||||
|
import { PreferencesContext } from './infoview/context'
|
||||||
|
|
||||||
// Settings for the world tree
|
// Settings for the world tree
|
||||||
cytoscape.use( klay )
|
cytoscape.use( klay )
|
||||||
@@ -197,7 +198,7 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
|
|||||||
const gameId = React.useContext(GameIdContext)
|
const gameId = React.useContext(GameIdContext)
|
||||||
const difficulty = useSelector(selectDifficulty(gameId))
|
const difficulty = useSelector(selectDifficulty(gameId))
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const { mobile } = useMobile()
|
const { mobile } = React.useContext(PreferencesContext)
|
||||||
|
|
||||||
|
|
||||||
function label(x : number) {
|
function label(x : number) {
|
||||||
|
|||||||
@@ -187,3 +187,15 @@ h5, h6 {
|
|||||||
margin-left: 0.3rem;
|
margin-left: 0.3rem;
|
||||||
margin-right: 0.3rem;
|
margin-right: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preferences-category.tail-category{
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences-item.first{
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences-item.leave-left-gap{
|
||||||
|
margin-left: 3em;
|
||||||
|
}
|
||||||
@@ -1,30 +1,6 @@
|
|||||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
|
||||||
import type { RootState, AppDispatch } from './state/store'
|
import type { RootState, AppDispatch } from './state/store'
|
||||||
|
|
||||||
import { setMobile as setMobileState, setLockMobile as setLockMobileState} from "./state/preferences"
|
|
||||||
|
|
||||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||||
export const useAppDispatch: () => AppDispatch = useDispatch
|
export const useAppDispatch: () => AppDispatch = useDispatch
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
||||||
|
|
||||||
export const useMobile = () => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const mobile = useAppSelector((state) => state.preferences.mobile);
|
|
||||||
const lockMobile = useAppSelector((state) => state.preferences.lockMobile);
|
|
||||||
|
|
||||||
const setMobile = (val: boolean) => {
|
|
||||||
dispatch(setMobileState(val));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLockMobile = (val: boolean) => {
|
|
||||||
dispatch(setLockMobileState(val));
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mobile,
|
|
||||||
setMobile,
|
|
||||||
lockMobile,
|
|
||||||
setLockMobile,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../hooks";
|
||||||
|
import {
|
||||||
|
PreferencesState,
|
||||||
|
setLayout as setPreferencesLayout,
|
||||||
|
setIsSavePreferences as setPreferencesIsSavePreferences,
|
||||||
|
getWindowDimensions,
|
||||||
|
AUTO_SWITCH_THRESHOLD
|
||||||
|
} from "../preferences";
|
||||||
|
|
||||||
|
|
||||||
|
const UsePreferences = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const [mobile, setMobile] = React.useState<boolean>()
|
||||||
|
|
||||||
|
const layout = useAppSelector((state) => state.preferences.layout);
|
||||||
|
const setLayout = (layout: PreferencesState["layout"]) => dispatch(setPreferencesLayout(layout))
|
||||||
|
|
||||||
|
const isSavePreferences = useAppSelector((state) => state.preferences.isSavePreferences);
|
||||||
|
const setIsSavePreferences = (isSave: boolean) => dispatch(setPreferencesIsSavePreferences(isSave))
|
||||||
|
|
||||||
|
const automaticallyAdjustLayout = () => {
|
||||||
|
const {width} = getWindowDimensions()
|
||||||
|
setMobile(width < AUTO_SWITCH_THRESHOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(()=>{
|
||||||
|
if (layout === "auto"){
|
||||||
|
void automaticallyAdjustLayout()
|
||||||
|
window.addEventListener('resize', automaticallyAdjustLayout)
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', automaticallyAdjustLayout)
|
||||||
|
} else {
|
||||||
|
setMobile(layout === "mobile")
|
||||||
|
}
|
||||||
|
}, [layout])
|
||||||
|
|
||||||
|
return {mobile, layout, isSavePreferences, setLayout, setIsSavePreferences}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsePreferences;
|
||||||
@@ -57,3 +57,12 @@ export function savePreferences(state: any) {
|
|||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function removePreferences() {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(PREFERENCES_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
import { createSlice } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
import { loadPreferences } from "./local_storage";
|
import { loadPreferences, removePreferences, savePreferences } from "./local_storage";
|
||||||
|
|
||||||
interface PreferencesState {
|
export interface PreferencesState {
|
||||||
mobile: boolean;
|
layout: "mobile" | "auto" | "desktop";
|
||||||
lockMobile: boolean;
|
isSavePreferences: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWindowDimensions() {
|
export function getWindowDimensions() {
|
||||||
@@ -12,26 +12,24 @@ export function getWindowDimensions() {
|
|||||||
return {width, height}
|
return {width, height}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = getWindowDimensions()
|
|
||||||
|
|
||||||
export const AUTO_SWITCH_THRESHOLD = 800
|
export const AUTO_SWITCH_THRESHOLD = 800
|
||||||
|
|
||||||
const initialState: PreferencesState = loadPreferences() ?? {
|
const initialState: PreferencesState = loadPreferences() ??{
|
||||||
mobile: width < AUTO_SWITCH_THRESHOLD,
|
layout: "auto",
|
||||||
lockMobile: false
|
isSavePreferences: false
|
||||||
}
|
}
|
||||||
|
|
||||||
export const preferencesSlice = createSlice({
|
export const preferencesSlice = createSlice({
|
||||||
name: "preferences",
|
name: "preferences",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setMobile: (state, action) => {
|
setLayout: (state, action) => {
|
||||||
state.mobile = action.payload;
|
state.layout = action.payload;
|
||||||
},
|
},
|
||||||
setLockMobile: (state, action) => {
|
setIsSavePreferences: (state, action) => {
|
||||||
state.lockMobile = action.payload;
|
state.isSavePreferences = action.payload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { setMobile, setLockMobile } = preferencesSlice.actions;
|
export const { setLayout, setIsSavePreferences } = preferencesSlice.actions;
|
||||||
|
|||||||
@@ -53,22 +53,22 @@ const initalLevelProgressState: LevelProgressState = {code: "", completed: false
|
|||||||
|
|
||||||
/** Add an empty skeleton with progress for the current game */
|
/** Add an empty skeleton with progress for the current game */
|
||||||
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
|
function addGameProgress (state: ProgressState, action: PayloadAction<{game: string}>) {
|
||||||
if (!state.games[action.payload.game]) {
|
if (!state.games[action.payload.game.toLowerCase()]) {
|
||||||
state.games[action.payload.game] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
|
state.games[action.payload.game.toLowerCase()] = {inventory: [], openedIntro: true, data: {}, difficulty: DEFAULT_DIFFICULTY}
|
||||||
}
|
}
|
||||||
if (!state.games[action.payload.game].data) {
|
if (!state.games[action.payload.game.toLowerCase()].data) {
|
||||||
state.games[action.payload.game].data = {}
|
state.games[action.payload.game.toLowerCase()].data = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add an empty skeleton with progress for the current level */
|
/** Add an empty skeleton with progress for the current level */
|
||||||
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
function addLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||||
addGameProgress(state, action)
|
addGameProgress(state, action)
|
||||||
if (!state.games[action.payload.game].data[action.payload.world]) {
|
if (!state.games[action.payload.game.toLowerCase()].data[action.payload.world]) {
|
||||||
state.games[action.payload.game].data[action.payload.world] = {}
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world] = {}
|
||||||
}
|
}
|
||||||
if (!state.games[action.payload.game].data[action.payload.world][action.payload.level]) {
|
if (!state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level]) {
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level] = {...initalLevelProgressState}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,58 +79,58 @@ export const progressSlice = createSlice({
|
|||||||
/** put edited code in the state and set completed to false */
|
/** put edited code in the state and set completed to false */
|
||||||
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
|
codeEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, code: string}>) {
|
||||||
addLevelProgress(state, action)
|
addLevelProgress(state, action)
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].code = action.payload.code
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].code = action.payload.code
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = false
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].completed = false
|
||||||
},
|
},
|
||||||
/** TODO: docstring */
|
/** TODO: docstring */
|
||||||
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
|
changedSelection(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, selections: Selection[]}>) {
|
||||||
addLevelProgress(state, action)
|
addLevelProgress(state, action)
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].selections = action.payload.selections
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].selections = action.payload.selections
|
||||||
},
|
},
|
||||||
/** mark level as completed */
|
/** mark level as completed */
|
||||||
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
levelCompleted(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||||
addLevelProgress(state, action)
|
addLevelProgress(state, action)
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].completed = true
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].completed = true
|
||||||
},
|
},
|
||||||
/** Set the list of rows where help is displayed */
|
/** Set the list of rows where help is displayed */
|
||||||
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
|
helpEdited(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number, help: number[]}>) {
|
||||||
addLevelProgress(state, action)
|
addLevelProgress(state, action)
|
||||||
console.debug(`!setting help to: ${action.payload.help}`)
|
console.debug(`!setting help to: ${action.payload.help}`)
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level].help = action.payload.help
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level].help = action.payload.help
|
||||||
},
|
},
|
||||||
/** delete all progress for this game */
|
/** delete all progress for this game */
|
||||||
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
|
deleteProgress(state: ProgressState, action: PayloadAction<{game: string}>) {
|
||||||
state.games[action.payload.game] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
|
state.games[action.payload.game.toLowerCase()] = {inventory: [], data: {}, openedIntro: true, difficulty: DEFAULT_DIFFICULTY}
|
||||||
},
|
},
|
||||||
/** delete progress for this level */
|
/** delete progress for this level */
|
||||||
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
deleteLevelProgress(state: ProgressState, action: PayloadAction<{game: string, world: string, level: number}>) {
|
||||||
addLevelProgress(state, action)
|
addLevelProgress(state, action)
|
||||||
state.games[action.payload.game].data[action.payload.world][action.payload.level] = initalLevelProgressState
|
state.games[action.payload.game.toLowerCase()].data[action.payload.world][action.payload.level] = initalLevelProgressState
|
||||||
},
|
},
|
||||||
/** load progress, e.g. from external import */
|
/** load progress, e.g. from external import */
|
||||||
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
|
loadProgress(state: ProgressState, action: PayloadAction<{game: string, data:GameProgressState}>) {
|
||||||
console.debug(`setting data to:\n ${action.payload.data}`)
|
console.debug(`setting data to:\n ${action.payload.data}`)
|
||||||
state.games[action.payload.game] = action.payload.data
|
state.games[action.payload.game.toLowerCase()] = action.payload.data
|
||||||
},
|
},
|
||||||
/** set the current inventory */
|
/** set the current inventory */
|
||||||
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
|
changedInventory(state: ProgressState, action: PayloadAction<{game: string, inventory: string[]}>) {
|
||||||
addGameProgress(state, action)
|
addGameProgress(state, action)
|
||||||
state.games[action.payload.game].inventory = action.payload.inventory
|
state.games[action.payload.game.toLowerCase()].inventory = action.payload.inventory
|
||||||
},
|
},
|
||||||
/** set the difficulty */
|
/** set the difficulty */
|
||||||
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
|
changedDifficulty(state: ProgressState, action: PayloadAction<{game: string, difficulty: number}>) {
|
||||||
addGameProgress(state, action)
|
addGameProgress(state, action)
|
||||||
state.games[action.payload.game].difficulty = action.payload.difficulty
|
state.games[action.payload.game.toLowerCase()].difficulty = action.payload.difficulty
|
||||||
},
|
},
|
||||||
/** set the difficulty */
|
/** set the difficulty */
|
||||||
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) {
|
changedOpenedIntro(state: ProgressState, action: PayloadAction<{game: string, openedIntro: boolean}>) {
|
||||||
addGameProgress(state, action)
|
addGameProgress(state, action)
|
||||||
state.games[action.payload.game].openedIntro = action.payload.openedIntro
|
state.games[action.payload.game.toLowerCase()].openedIntro = action.payload.openedIntro
|
||||||
},
|
},
|
||||||
/** set the typewriter mode */
|
/** set the typewriter mode */
|
||||||
changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) {
|
changeTypewriterMode(state: ProgressState, action: PayloadAction<{game: string, typewriterMode: boolean}>) {
|
||||||
addGameProgress(state, action)
|
addGameProgress(state, action)
|
||||||
state.games[action.payload.game].typewriterMode = action.payload.typewriterMode
|
state.games[action.payload.game.toLowerCase()].typewriterMode = action.payload.typewriterMode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -138,74 +138,74 @@ export const progressSlice = createSlice({
|
|||||||
/** if the level does not exist, return default values */
|
/** if the level does not exist, return default values */
|
||||||
export function selectLevel(game: string, world: string, level: number) {
|
export function selectLevel(game: string, world: string, level: number) {
|
||||||
return (state) =>{
|
return (state) =>{
|
||||||
if (!state.progress.games[game]) { return initalLevelProgressState }
|
if (!state.progress.games[game.toLowerCase()]) { return initalLevelProgressState }
|
||||||
if (!state.progress.games[game].data[world]) { return initalLevelProgressState }
|
if (!state.progress.games[game.toLowerCase()].data[world]) { return initalLevelProgressState }
|
||||||
if (!state.progress.games[game].data[world][level]) { return initalLevelProgressState }
|
if (!state.progress.games[game.toLowerCase()].data[world][level]) { return initalLevelProgressState }
|
||||||
return state.progress.games[game].data[world][level]
|
return state.progress.games[game.toLowerCase()].data[world][level]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return the code of the current level */
|
/** return the code of the current level */
|
||||||
export function selectCode(game: string, world: string, level: number) {
|
export function selectCode(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).code
|
return selectLevel(game.toLowerCase(), world, level)(state).code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return the current inventory */
|
/** return the current inventory */
|
||||||
export function selectInventory(game: string) {
|
export function selectInventory(game: string) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
if (!state.progress.games[game]) { return [] }
|
if (!state.progress.games[game.toLowerCase()]) { return [] }
|
||||||
return state.progress.games[game].inventory
|
return state.progress.games[game.toLowerCase()].inventory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return the code of the current level */
|
/** return the code of the current level */
|
||||||
export function selectHelp(game: string, world: string, level: number) {
|
export function selectHelp(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).help
|
return selectLevel(game.toLowerCase(), world, level)(state).help
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return the selections made in the current level */
|
/** return the selections made in the current level */
|
||||||
export function selectSelections(game: string, world: string, level: number) {
|
export function selectSelections(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).selections
|
return selectLevel(game.toLowerCase(), world, level)(state).selections
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return whether the current level is clompleted */
|
/** return whether the current level is clompleted */
|
||||||
export function selectCompleted(game: string, world: string, level: number) {
|
export function selectCompleted(game: string, world: string, level: number) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return selectLevel(game, world, level)(state).completed
|
return selectLevel(game.toLowerCase(), world, level)(state).completed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return progress for the current game if it exists */
|
/** return progress for the current game if it exists */
|
||||||
export function selectProgress(game: string) {
|
export function selectProgress(game: string) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return state.progress.games[game] ?? null
|
return state.progress.games[game.toLowerCase()] ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return difficulty for the current game if it exists */
|
/** return difficulty for the current game if it exists */
|
||||||
export function selectDifficulty(game: string) {
|
export function selectDifficulty(game: string) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return state.progress.games[game]?.difficulty ?? DEFAULT_DIFFICULTY
|
return state.progress.games[game.toLowerCase()]?.difficulty ?? DEFAULT_DIFFICULTY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return whether the intro has been read */
|
/** return whether the intro has been read */
|
||||||
export function selectOpenedIntro(game: string) {
|
export function selectOpenedIntro(game: string) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return state.progress.games[game]?.openedIntro
|
return state.progress.games[game.toLowerCase()]?.openedIntro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** return typewriter mode for the current game if it exists */
|
/** return typewriter mode for the current game if it exists */
|
||||||
export function selectTypewriterMode(game: string) {
|
export function selectTypewriterMode(game: string) {
|
||||||
return (state) => {
|
return (state) => {
|
||||||
return state.progress.games[game]?.typewriterMode ?? true
|
return state.progress.games[game.toLowerCase()]?.typewriterMode ?? true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { connection } from '../connection'
|
|||||||
import { apiSlice } from './api'
|
import { apiSlice } from './api'
|
||||||
import { progressSlice } from './progress'
|
import { progressSlice } from './progress'
|
||||||
import { preferencesSlice } from "./preferences"
|
import { preferencesSlice } from "./preferences"
|
||||||
import { saveState, savePreferences } from "./local_storage";
|
import { saveState, savePreferences, removePreferences} from "./local_storage";
|
||||||
|
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
@@ -29,7 +29,9 @@ export const store = configureStore({
|
|||||||
store.subscribe(
|
store.subscribe(
|
||||||
debounce(() => {
|
debounce(() => {
|
||||||
saveState(store.getState()[progressSlice.name]);
|
saveState(store.getState()[progressSlice.name]);
|
||||||
savePreferences(store.getState()[preferencesSlice.name]);
|
|
||||||
|
const preferencesState = store.getState()[preferencesSlice.name]
|
||||||
|
preferencesState.isSavePreferences ? savePreferences(preferencesState) : removePreferences()
|
||||||
}, 800)
|
}, 800)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,339 +0,0 @@
|
|||||||
import { DataCallback, AbstractMessageReader, MessageReader } from 'vscode-jsonrpc/lib/common/messageReader.js';
|
|
||||||
|
|
||||||
import { Message } from 'vscode-jsonrpc/lib/common/messages.js';
|
|
||||||
import { AbstractMessageWriter, MessageWriter } from 'vscode-jsonrpc/lib/common/messageWriter.js';
|
|
||||||
import { Emitter } from 'vscode-jsonrpc/lib/common/events.js';
|
|
||||||
import { Disposable, IWebSocket } from 'vscode-ws-jsonrpc/.';
|
|
||||||
|
|
||||||
declare var IO: any;
|
|
||||||
|
|
||||||
export class WasmWriter implements MessageWriter {
|
|
||||||
protected errorCount = 0;
|
|
||||||
errorEmitter
|
|
||||||
closeEmitter
|
|
||||||
constructor(private worker: Worker) {
|
|
||||||
this.errorEmitter = new Emitter()
|
|
||||||
this.closeEmitter = new Emitter()
|
|
||||||
}
|
|
||||||
dispose() {
|
|
||||||
this.errorEmitter.dispose();
|
|
||||||
this.closeEmitter.dispose();
|
|
||||||
}
|
|
||||||
get onError() {
|
|
||||||
return this.errorEmitter.event;
|
|
||||||
}
|
|
||||||
fireError(error, message, count) {
|
|
||||||
this.errorEmitter.fire([this.asError(error), message, count]);
|
|
||||||
}
|
|
||||||
get onClose() {
|
|
||||||
return this.closeEmitter.event;
|
|
||||||
}
|
|
||||||
fireClose() {
|
|
||||||
this.closeEmitter.fire(undefined);
|
|
||||||
}
|
|
||||||
asError(error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return new Error(`Writer received error. Reason: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
end(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(msg: Message): Promise<void> {
|
|
||||||
try {
|
|
||||||
const content = JSON.stringify(msg);
|
|
||||||
this.worker.postMessage(content)
|
|
||||||
} catch (e) {
|
|
||||||
this.errorCount++;
|
|
||||||
this.fireError(e, msg, this.errorCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class WasmReader implements MessageReader {
|
|
||||||
protected state: 'initial' | 'listening' | 'closed' = 'initial';
|
|
||||||
protected callback: DataCallback | undefined;
|
|
||||||
protected readonly events: { message?: any, error?: any }[] = [];
|
|
||||||
|
|
||||||
constructor(private worker: Worker) {
|
|
||||||
this.worker.onmessage = (ev) => {
|
|
||||||
this.readMessage(ev.data)
|
|
||||||
}
|
|
||||||
// this.socket.onMessage(message =>
|
|
||||||
// this.readMessage(message)
|
|
||||||
// );
|
|
||||||
// this.socket.onError(error =>
|
|
||||||
// this.fireError(error)
|
|
||||||
// );
|
|
||||||
// this.socket.onClose((code, reason) => {
|
|
||||||
// if (code !== 1000) {
|
|
||||||
// const error: Error = {
|
|
||||||
// name: '' + code,
|
|
||||||
// message: `Error during socket reconnect: code = ${code}, reason = ${reason}`
|
|
||||||
// };
|
|
||||||
// this.fireError(error);
|
|
||||||
// }
|
|
||||||
// this.fireClose();
|
|
||||||
// });
|
|
||||||
this.errorEmitter = new Emitter()
|
|
||||||
this.closeEmitter = new Emitter()
|
|
||||||
this.partialMessageEmitter = new Emitter()
|
|
||||||
}
|
|
||||||
|
|
||||||
protected errorCount = 0;
|
|
||||||
errorEmitter
|
|
||||||
closeEmitter
|
|
||||||
partialMessageEmitter
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.errorEmitter.dispose();
|
|
||||||
this.closeEmitter.dispose();
|
|
||||||
}
|
|
||||||
get onError() {
|
|
||||||
return this.errorEmitter.event;
|
|
||||||
}
|
|
||||||
get onClose() {
|
|
||||||
return this.closeEmitter.event;
|
|
||||||
}
|
|
||||||
get onPartialMessage() {
|
|
||||||
return this.partialMessageEmitter.event;
|
|
||||||
}
|
|
||||||
firePartialMessage(info) {
|
|
||||||
this.partialMessageEmitter.fire(info);
|
|
||||||
}
|
|
||||||
asError(error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return new Error(`Reader received error. Reason: ${error.message ? error.message : 'unknown'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listen(callback: DataCallback): Disposable {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.state = 'listening';
|
|
||||||
this.callback = callback;
|
|
||||||
while (this.events.length !== 0) {
|
|
||||||
const event = this.events.pop()!;
|
|
||||||
if (event.message) {
|
|
||||||
this.readMessage(event.message);
|
|
||||||
} else if (event.error) {
|
|
||||||
this.fireError(event.error);
|
|
||||||
} else {
|
|
||||||
this.fireClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
dispose: () => {
|
|
||||||
if (this.callback === callback) {
|
|
||||||
this.callback = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readMessage(message: any): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, { message });
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
this.callback!(data);
|
|
||||||
} catch (err) {
|
|
||||||
const error: Error = {
|
|
||||||
name: '' + 400,
|
|
||||||
message: `Error during message parsing, reason = ${typeof err === 'object' ? (err as any).message : 'unknown'}`
|
|
||||||
};
|
|
||||||
this.fireError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fireError(error: any): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, { error });
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
|
|
||||||
this.errorEmitter.fire(this.asError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fireClose(): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, {});
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
this.closeEmitter.fire(undefined);
|
|
||||||
}
|
|
||||||
this.state = 'closed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export class WebSocketMessageWriter implements MessageWriter {
|
|
||||||
protected errorCount = 0;
|
|
||||||
errorEmitter
|
|
||||||
closeEmitter
|
|
||||||
|
|
||||||
constructor(protected readonly socket: IWebSocket) {
|
|
||||||
this.errorEmitter = new Emitter();
|
|
||||||
this.closeEmitter = new Emitter();
|
|
||||||
}
|
|
||||||
dispose() {
|
|
||||||
this.errorEmitter.dispose();
|
|
||||||
this.closeEmitter.dispose();
|
|
||||||
}
|
|
||||||
get onError() {
|
|
||||||
return this.errorEmitter.event;
|
|
||||||
}
|
|
||||||
fireError(error, message, count) {
|
|
||||||
this.errorEmitter.fire([this.asError(error), message, count]);
|
|
||||||
}
|
|
||||||
get onClose() {
|
|
||||||
return this.closeEmitter.event;
|
|
||||||
}
|
|
||||||
fireClose() {
|
|
||||||
this.closeEmitter.fire(undefined);
|
|
||||||
}
|
|
||||||
asError(error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return new Error(`Writer received error. Reason: ${(error.message) ? error.message : 'unknown'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end(): void {
|
|
||||||
}
|
|
||||||
|
|
||||||
async write(msg: Message): Promise<void> {
|
|
||||||
console.log("WRITE",msg)
|
|
||||||
try {
|
|
||||||
const content = JSON.stringify(msg);
|
|
||||||
this.socket.send(content);
|
|
||||||
} catch (e) {
|
|
||||||
this.errorCount++;
|
|
||||||
this.fireError(e, msg, this.errorCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class WebSocketMessageReader implements MessageReader {
|
|
||||||
protected state: 'initial' | 'listening' | 'closed' = 'initial';
|
|
||||||
protected callback: DataCallback | undefined;
|
|
||||||
protected readonly events: { message?: any, error?: any }[] = [];
|
|
||||||
errorEmitter
|
|
||||||
closeEmitter
|
|
||||||
partialMessageEmitter
|
|
||||||
|
|
||||||
constructor(protected readonly socket: IWebSocket) {
|
|
||||||
this.errorEmitter = new Emitter();
|
|
||||||
this.closeEmitter = new Emitter();
|
|
||||||
this.partialMessageEmitter = new Emitter();
|
|
||||||
this.socket.onMessage(message =>{
|
|
||||||
console.log("READ", message)
|
|
||||||
this.readMessage(message)
|
|
||||||
});
|
|
||||||
this.socket.onError(error =>
|
|
||||||
this.fireError(error)
|
|
||||||
);
|
|
||||||
this.socket.onClose((code, reason) => {
|
|
||||||
if (code !== 1000) {
|
|
||||||
const error: Error = {
|
|
||||||
name: '' + code,
|
|
||||||
message: `Error during socket reconnect: code = ${code}, reason = ${reason}`
|
|
||||||
};
|
|
||||||
this.fireError(error);
|
|
||||||
}
|
|
||||||
this.fireClose();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
dispose() {
|
|
||||||
this.errorEmitter.dispose();
|
|
||||||
this.closeEmitter.dispose();
|
|
||||||
}
|
|
||||||
get onError() {
|
|
||||||
return this.errorEmitter.event;
|
|
||||||
}
|
|
||||||
get onClose() {
|
|
||||||
return this.closeEmitter.event;
|
|
||||||
}
|
|
||||||
get onPartialMessage() {
|
|
||||||
return this.partialMessageEmitter.event;
|
|
||||||
}
|
|
||||||
firePartialMessage(info) {
|
|
||||||
this.partialMessageEmitter.fire(info);
|
|
||||||
}
|
|
||||||
asError(error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return new Error(`Reader received error. Reason: ${(error.message) ? error.message : 'unknown'}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listen(callback: DataCallback): Disposable {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.state = 'listening';
|
|
||||||
this.callback = callback;
|
|
||||||
while (this.events.length !== 0) {
|
|
||||||
const event = this.events.pop()!;
|
|
||||||
if (event.message) {
|
|
||||||
this.readMessage(event.message);
|
|
||||||
} else if (event.error) {
|
|
||||||
this.fireError(event.error);
|
|
||||||
} else {
|
|
||||||
this.fireClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
dispose: () => {
|
|
||||||
if (this.callback === callback) {
|
|
||||||
this.callback = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected readMessage(message: any): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, { message });
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
this.callback!(data);
|
|
||||||
} catch (err) {
|
|
||||||
const error: Error = {
|
|
||||||
name: '' + 400,
|
|
||||||
message: `Error during message parsing, reason = ${typeof err === 'object' ? (err as any).message : 'unknown'}`
|
|
||||||
};
|
|
||||||
this.fireError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fireError(error: any): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, { error });
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
this.errorEmitter.fire(this.asError(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected fireClose(): void {
|
|
||||||
if (this.state === 'initial') {
|
|
||||||
this.events.splice(0, 0, {});
|
|
||||||
} else if (this.state === 'listening') {
|
|
||||||
this.closeEmitter.fire(undefined);
|
|
||||||
}
|
|
||||||
this.state = 'closed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+53
-321
@@ -1,335 +1,67 @@
|
|||||||
**NOTE! This document is deprecated! The current documentation is [How To Create A Game](create_game.md)**
|
# Server
|
||||||
|
|
||||||
# Creating a game.
|
The server is made out of two parts, named "relay" and "server".
|
||||||
|
|
||||||
Ideally one takes the [GameSkeleton template](https://github.com/hhu-adam/GameSkeleton) to create a new game.
|
The former, "relay", is the server which
|
||||||
|
sets up a socket connection to the client, starts the lean servers to work on files and
|
||||||
|
relays messages between the lean server and the client. `index.mjs` is the file that needs to
|
||||||
|
be run, which is done for example using `pm2` or by calling `npm run start_server` or
|
||||||
|
`npm run production`, see more later.
|
||||||
|
|
||||||
## Game Structure
|
The latter, "server", is the lean server which has two jobs. For one, it produces the "gameserver"
|
||||||
|
executable which is the lean server that handles the files the player plays on. The second job
|
||||||
|
is to provide the lean commands which are used when creating a game. These are located in
|
||||||
|
`Commands.lean`.
|
||||||
|
|
||||||
A game consist of worlds which have multiple levels each. In the following we describe how to create a level file and how to combine these into a game.
|
|
||||||
|
|
||||||
### Level
|
## Integration into Games
|
||||||
|
|
||||||
A level file is a lean file that imports at least `import GameServer.Commands` and starts with the following Lean commands.
|
Games need the "server" as a lake-dependency, which is done in the game's lakefile.
|
||||||
|
|
||||||
|
A game imports `GameServer.Commands` which provides to all the API required to
|
||||||
|
create a game.
|
||||||
|
|
||||||
|
In particular the lean command `MakeGame` compiles the entire game. Static information is
|
||||||
|
stored as JSON files in `.lake/gamedata` for faster loading, while other data is only
|
||||||
|
saved to lean env-extensions which the lean server has access to after loading the lean file.
|
||||||
|
|
||||||
|
For games to be run successfully, it is important that the "gameserver" executable inside
|
||||||
|
the game's `.lake` folder is actually built.
|
||||||
|
Currently, this happens through a lake-post-update-hook when calling `lake update -R` (in the game's folder), but if this fails, you can always build it manually by calling `lake build gameserver`.
|
||||||
|
(both commands are to be executed in the game's directory!)
|
||||||
|
|
||||||
|
## Modifying the server
|
||||||
|
|
||||||
|
### Starting the server
|
||||||
|
|
||||||
|
When using the [manual installation](running_locally.md#manual-installation) you can run the server
|
||||||
|
using
|
||||||
|
|
||||||
```lean
|
|
||||||
Game "NNG"
|
|
||||||
World "Addition"
|
|
||||||
Level 1
|
|
||||||
Title "The rfl tactic"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Note that the levels inside a world must have consecutive numbering starting with `1`. The `Game`
|
|
||||||
and `World` strings can be anything, see below.
|
|
||||||
|
|
||||||
#### Statement
|
|
||||||
|
|
||||||
The core of a level is the `Statement`, which is the exercise that should be proven.
|
|
||||||
|
|
||||||
```lean
|
|
||||||
/-- For all natural numbers $n$, we have $0 + n = n$. -/
|
|
||||||
@[simp]
|
|
||||||
Statement MyNat.zero_add
|
|
||||||
(n : ℕ) : 0 + n = n := by
|
|
||||||
Hint "You can start a proof by `induction n`."
|
|
||||||
induction n with n hn
|
|
||||||
· Hint "This is the base case."
|
|
||||||
rw [add_zero]
|
|
||||||
rfl
|
|
||||||
· Hint "This is the induction hypothesis"
|
|
||||||
rw [add_succ]
|
|
||||||
Branch
|
|
||||||
simp
|
|
||||||
Hint "A branch is an alternative tactic sequence. Does not need to finish the proof."
|
|
||||||
rw [hn]
|
|
||||||
rfl
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Proof
|
|
||||||
|
|
||||||
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
|
|
||||||
|
|
||||||
There are a few extra tactics that help you structuring the proof:
|
|
||||||
|
|
||||||
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
|
|
||||||
the one where `Hint` is placed. For more options about hints, see below.
|
|
||||||
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
|
|
||||||
helps setting `Hints` in different places. The `Branch` does not affect the main
|
|
||||||
proof and does not need to finish any goals.
|
|
||||||
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
|
|
||||||
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
|
|
||||||
having a `Template` will force the user to use Editor-mode for this level.
|
|
||||||
|
|
||||||
##### Statement Name (optional)
|
|
||||||
|
|
||||||
If you specify a name (`MyNat.zero_add`), this lemma will be available in future levels.
|
|
||||||
(Note that a future level must also import this level,
|
|
||||||
so that Lean knows about the added statement).
|
|
||||||
|
|
||||||
The name must be *fully qualified*. (TODO: is that still true? Did we implement namespaces?)
|
|
||||||
|
|
||||||
##### Doc Comment (optional)
|
|
||||||
|
|
||||||
There are three places where the documentation comment appears:
|
|
||||||
|
|
||||||
1. as doc comment when hovering over the theorem
|
|
||||||
2. as exercise description at the top of the level: ``Theorem `zero_add`: yada yada.``
|
|
||||||
3. in the inventory. This can be overwritten by using
|
|
||||||
`LemmaDoc MyNat.zero_add "different yada yada"` as one might want to add a more detailed
|
|
||||||
description there including examples etc.
|
|
||||||
|
|
||||||
Both latter points support Markdown (including katex).
|
|
||||||
|
|
||||||
##### Attributes (optional)
|
|
||||||
|
|
||||||
the `@[ attributes ]` prefix should work just like you know it from the `theorem` keyword.
|
|
||||||
|
|
||||||
#### Introduction/Conclusion
|
|
||||||
|
|
||||||
Optionally, you can add an `Introduction "some text"` and `Conclusion "some text"` to your level.
|
|
||||||
The introduction will be shown at the beginning, the conclusion is displayed once the level
|
|
||||||
is solved.
|
|
||||||
|
|
||||||
#### Theorems/Tactics/Definitions
|
|
||||||
|
|
||||||
Only enabled theorems/tactics/definitions (called "items" here) are available in a level.
|
|
||||||
|
|
||||||
To add a new item in a level, you can add
|
|
||||||
|
|
||||||
```lean
|
|
||||||
NewTactic rfl simp
|
|
||||||
NewLemma MyNat.add_zero MyNat.add_succ
|
|
||||||
NewDefinition Nat Pow Mul
|
|
||||||
```
|
|
||||||
|
|
||||||
Once added, items will be available in all future levels/worlds,
|
|
||||||
unless you disable them for a particular level with
|
|
||||||
|
|
||||||
```lean
|
|
||||||
DisabledTactic tauto
|
|
||||||
DisabledLemma MyNat.add_zero
|
|
||||||
```
|
|
||||||
|
|
||||||
or specify explicitly which items should be available with
|
|
||||||
|
|
||||||
```lean
|
|
||||||
OnlyTactic rw rfl apply
|
|
||||||
OnlyLemma MyNat.add_zero
|
|
||||||
```
|
|
||||||
|
|
||||||
Lastly, all items need documentation entries (which are imported in the level),
|
|
||||||
see more about that below. There is also explains the `LemmaTab` keyword.
|
|
||||||
|
|
||||||
### World
|
|
||||||
|
|
||||||
Multiple levels are combined into a world and the worlds are then added to the game. It is recommended that all levels of a world are inside one folder (e.g. `NNG/Levels/Addition/`) and
|
|
||||||
then there is one world file (`NNG/Levels/Addition.lean`) which contains the following
|
|
||||||
|
|
||||||
```lean
|
|
||||||
import NNG.Levels.Addition.Level_1
|
|
||||||
import NNG.Levels.Addition.Level_2
|
|
||||||
|
|
||||||
Game "NNG"
|
|
||||||
World "Addition"
|
|
||||||
Title "Addition World"
|
|
||||||
|
|
||||||
Introduction "some text"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `Title` is the world's display title. The `Introduction` is displayed before loading level 1.
|
|
||||||
Note that all levels of a world should be imported by the world file.
|
|
||||||
|
|
||||||
BUG: A level **must not** be imported in a different world's level. Instead, you have to import an entire world there: `import NNG.Levels.Addition`
|
|
||||||
|
|
||||||
### Game
|
|
||||||
|
|
||||||
The Game itself (i.e. the main file of you lake project, `NNG.lean`) should import all worlds and have the following layout, concluding with `MakeGame`:
|
|
||||||
|
|
||||||
```lean
|
|
||||||
import NNG.Levels.Addition
|
|
||||||
import NNG.Levels.Multiplication
|
|
||||||
import NNG.Levels.Power
|
|
||||||
|
|
||||||
Game "NNG"
|
|
||||||
Title "Natural Number Game"
|
|
||||||
Introduction "some text"
|
|
||||||
|
|
||||||
MakeGame
|
|
||||||
```
|
|
||||||
|
|
||||||
The game will automatically compute the order of the worlds depending on the sample proofs of the Levels (ignoring anything inside a `Branch`). You can add additional dependencies manually by adding `Dependency PowerWorld → ImpossibleWorld` before `MakeGame`.
|
|
||||||
The order of worlds influences which tactics and lemmas will be unlocked in a given level.
|
|
||||||
|
|
||||||
`MakeGame` will display warnings about things in the game that need to be fixed, like missing
|
|
||||||
documentation or if a tactic is never introduced.
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
Each tactic, theorem, or definition (all called items here) that is introduced in the game
|
|
||||||
needs a documentation entry. These are statements of the following form:
|
|
||||||
|
|
||||||
```lean
|
|
||||||
LemmaDoc MyNat.add_squared as "add_squared" in "Pow"
|
|
||||||
"(missing)"
|
|
||||||
|
|
||||||
TacticDoc constructor
|
|
||||||
"(missing)"
|
|
||||||
|
|
||||||
DefinitionDoc One as "1"
|
|
||||||
"(missing)"
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
* The lemma name must be **fully qualified**. The string display name can be arbitrary.
|
|
||||||
* Tactics must have their proper name. use `TacticDoc «have» ""` if it does not work
|
|
||||||
without french quotes.
|
|
||||||
* Definition names can be arbitrary. E.g. I used `DefinitionDoc Symbol.Fun as "fun x ↦ x" "(missing)"` once.
|
|
||||||
|
|
||||||
Moreover, the lemmas are in sorted in tabs (the `in "Pow`) part. In each level file, you
|
|
||||||
can define which tab is open when the level is loaded by adding `LemmaTab "Pow"`.
|
|
||||||
|
|
||||||
There will be features added to get automatic information from mathlib!
|
|
||||||
|
|
||||||
## Escaping
|
|
||||||
(TODO: Move)
|
|
||||||
|
|
||||||
|
|
||||||
Inside the doc comment you don't need to escape the backslashes:
|
|
||||||
|
|
||||||
```lean
|
|
||||||
/-- $\operatorname{succ}(n)$. notation for naturals is `\N`. -/
|
|
||||||
Statement ...
|
|
||||||
```
|
|
||||||
|
|
||||||
However, inside interpolated strings (e.g. in `Hint`, `Introduction` and `Conclusion`)
|
|
||||||
you do need to escape backslashes
|
|
||||||
with `\\` and `{` with `\{`:
|
|
||||||
|
|
||||||
```lean
|
|
||||||
Hint "This code has some $\\operatorname\{succ}(n)$ math. The value of `h` is {h}.
|
|
||||||
Notation for naturals is `\\N`."
|
|
||||||
```
|
|
||||||
|
|
||||||
## Game design
|
|
||||||
Here are some things you should consider designing a new game:
|
|
||||||
|
|
||||||
* A world with more than 16 levels will be displayed with the levels spiraling outwards,
|
|
||||||
it might be desirable to stay below that bound. Above 22 levels the spiral start getting out
|
|
||||||
of control.
|
|
||||||
|
|
||||||
# Running Games Locally
|
|
||||||
|
|
||||||
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
|
|
||||||
|
|
||||||
## VSCode Dev Containers
|
|
||||||
|
|
||||||
1. **Install Docker and Dev Containers** *(once)*:<br/>
|
|
||||||
See [official instructions](https://code.visualstudio.com/docs/devcontainers/containers#_getting-started).
|
|
||||||
Explicitly this means:
|
|
||||||
* Install docker engine if you have not yet: [Instructions](https://docs.docker.com/engine/install/).
|
|
||||||
I followed the "Server" instructions for linux.
|
|
||||||
* Note that on Linux you need to add your user to the `docker` group
|
|
||||||
([see instructions](https://docs.docker.com/engine/install/linux-postinstall/)) and probably reboot.
|
|
||||||
* Open the games folder in VSCode: `cd NNG4 && code .` or "Open Folder" within VSCode
|
|
||||||
* a message appears prompting you to install the "Dev Containers" extension (by Microsoft).
|
|
||||||
|
|
||||||
2. **Open Project in Dev Container** *(everytime)*:<br/>
|
|
||||||
Once you have the Dev Containers Extension installed, (re)open the project folder of your game in VSCode.
|
|
||||||
A message appears asking you to "Reopen in Container".
|
|
||||||
|
|
||||||
* The first start will take a while, ca. 2-10 minutes. After the first
|
|
||||||
start this should be very quickly.
|
|
||||||
* Once built, it should open a tab "Simple Browser" inside VSCode displaying
|
|
||||||
the game. (Alternatively, open http://localhost:3000 in your browser).
|
|
||||||
|
|
||||||
3. **Editing Files** *(everytime)*:<br/>
|
|
||||||
After editing some files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`.
|
|
||||||
Now you can reload your browser to see the changes.
|
|
||||||
|
|
||||||
### Errors
|
|
||||||
|
|
||||||
* If you don't get the pop-up, you might have disabled them and you can reenable it by
|
|
||||||
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
|
|
||||||
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
|
|
||||||
you might have deleted stuff from docker via your shell. Try deleting the container and image
|
|
||||||
explicitly in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
|
|
||||||
container. (this will again take some time)
|
|
||||||
|
|
||||||
|
|
||||||
## Without Dev Containers
|
|
||||||
Install `nvm`:
|
|
||||||
```bash
|
|
||||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash
|
|
||||||
```
|
|
||||||
then reopen bash and test with `command -v nvm` if it is available (Should print "nvm").
|
|
||||||
|
|
||||||
Now install node:
|
|
||||||
```bash
|
|
||||||
nvm install node
|
|
||||||
```
|
|
||||||
|
|
||||||
Clone the game (e.g. `NNG4` here):
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/hhu-adam/NNG4.git
|
|
||||||
# or: git clone git@github.com:hhu-adam/NNG4.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Download dependencies and build the game:
|
|
||||||
```bash
|
|
||||||
cd NNG4
|
|
||||||
lake update
|
|
||||||
lake exe cache get # if your game depends on mathlib
|
|
||||||
lake build
|
|
||||||
```
|
|
||||||
|
|
||||||
Clone the game repository into a directory next to the game:
|
|
||||||
```bash
|
|
||||||
cd ..
|
|
||||||
git clone https://github.com/leanprover-community/lean4game.git
|
|
||||||
# or: git clone git@github.com:leanprover-community/lean4game.git
|
|
||||||
```
|
|
||||||
The folders `NNG4` and `lean4game` must be in the same directory!
|
|
||||||
|
|
||||||
In `lean4game`, install dependencies:
|
|
||||||
```bash
|
|
||||||
cd lean4game
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
TODO: This is outdated!
|
|
||||||
If you are developing a game other than `Robo` or `NNG4`, adapt the
|
|
||||||
code at the beginning of `lean4game/relay/index.mjs`:
|
|
||||||
```typescript
|
|
||||||
const games = {
|
|
||||||
"g/hhu-adam/robo": {
|
|
||||||
dir: "../../../../Robo",
|
|
||||||
queueLength: 5
|
|
||||||
},
|
|
||||||
"g/hhu-adam/nng4": {
|
|
||||||
dir: "../../../../NNG4",
|
|
||||||
queueLength: 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the game:
|
|
||||||
```bash
|
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
This takes a little time. Eventually, the server is available on http://localhost:3000/
|
This way any changes to files in `client/` or `relay/` will cause the server to restart automatically.
|
||||||
and the game is available on http://localhost:3000/#/g/hhu-adam/NNG4.
|
|
||||||
|
|
||||||
### Modifying the GameServer
|
Alternative, you can run `npm run build` followed by the commands
|
||||||
|
|
||||||
When modifying the game engine itself (in particular the content in `lean4game/server`) you can test it live with this
|
|
||||||
setup by setting `export NODE_ENV=development` inside your local game before building it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd NNG4
|
|
||||||
export NODE_ENV=development
|
|
||||||
lake update
|
|
||||||
lake build
|
|
||||||
```
|
```
|
||||||
This causes lake to search locally for the `GameServer` lake package instead of using the version from github.
|
npm run start_client
|
||||||
|
npm run production
|
||||||
|
```
|
||||||
|
|
||||||
|
(in two separate terminals) to test the production mode of the server. This way it will only
|
||||||
|
change once you build and restart the server.
|
||||||
|
|
||||||
|
### Modifying the lean server
|
||||||
|
|
||||||
|
To test a modified lean server (i.e. content of `server/`), you can use the local dev setup and call
|
||||||
|
`lake update -R -Klean4game.local` in your game followed by `lake build`.
|
||||||
|
This will cause lake to look for the
|
||||||
|
local lean server as a dependency instead of the version it downloaded from git.
|
||||||
|
|
||||||
|
You can play a local game at https://localhost:3000/#/g/local/{FolderName} where you replace `{FolderName}` with the game folder name.
|
||||||
|
|
||||||
|
After modifications in `server/`, you will need to call `lake build gameserver` (called in `server/` or in your game's folder) to rebuild
|
||||||
|
the gameserver executable and
|
||||||
|
`lake build` (called in the game's folder) to rebuild the game.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## v4.5.0
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
* Fix (#183): local store accepts case insensitive URL. The game progress has previously been saved under case sensitive URLs. You might need to recover old progress from your browser storage.
|
||||||
|
|
||||||
|
## Other
|
||||||
+28
-17
@@ -98,7 +98,7 @@ This introduction text is shown when one first enters a world.
|
|||||||
1. Use the template above and make sure you import all levels of this world.
|
1. Use the template above and make sure you import all levels of this world.
|
||||||
1. In `Game.lean` import the world with `import Game.Levels.MyWorld`
|
1. In `Game.lean` import the world with `import Game.Levels.MyWorld`
|
||||||
|
|
||||||
Now you created a world with one level and added it🎉 The command `MakeGame` in `Game.lean` shows you any problems you might need to fix. Currently it shows
|
Now you created a world with one level and added it🎉 The command `MakeGame` in `Game.lean` shows you any problems you might need to fix. Currently, it shows
|
||||||
|
|
||||||
```text
|
```text
|
||||||
No world introducing sorry, but required by MyWorld
|
No world introducing sorry, but required by MyWorld
|
||||||
@@ -125,23 +125,32 @@ The player has an inventory with tactics, theorems, and definitions that unlock
|
|||||||
|
|
||||||
```lean
|
```lean
|
||||||
NewTactic induction simp
|
NewTactic induction simp
|
||||||
NewLemma Nat.zero_mul
|
NewTheorem Nat.zero_mul
|
||||||
NewDefinition Pow
|
NewDefinition Pow
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Important:** All commands in this section 6a) expect the `Name` they take as input
|
||||||
|
to be **fully qualified**. For example `NewTheorem Nat.zero_mul` and not `NewTheorem zero_mul`.
|
||||||
|
|
||||||
#### Doc entries
|
#### Doc entries
|
||||||
|
|
||||||
You'll see a warning about a missing Lemma documentation. You can fix it by adding doc-entries like the following somewhere above it.
|
You'll see a warning about a missing Theorem documentation. You can fix it by adding doc-entries like the following somewhere above it.
|
||||||
|
|
||||||
```lean
|
```lean
|
||||||
LemmaDoc Nat.zero_mul as "zero_mul" in "Mul"
|
/--
|
||||||
"some description"
|
some description
|
||||||
|
-/
|
||||||
|
TheoremDoc Nat.zero_mul as "zero_mul" in "Mul"
|
||||||
|
|
||||||
|
/--
|
||||||
|
some description
|
||||||
|
-/
|
||||||
TacticDoc simp
|
TacticDoc simp
|
||||||
"some description"
|
|
||||||
|
|
||||||
|
/--
|
||||||
|
some description
|
||||||
|
-/
|
||||||
DefinitionDoc Pow as "^"
|
DefinitionDoc Pow as "^"
|
||||||
"some description"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
(e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it)
|
(e.g. you could also create a file `Game/Doc/MyTheorems.lean`, add there your documentation and import it)
|
||||||
@@ -153,7 +162,7 @@ If you do not provide any content for the inventory, the game will try to find a
|
|||||||
You have a few options to disable inventory items that have been unlocked in previous levels:
|
You have a few options to disable inventory items that have been unlocked in previous levels:
|
||||||
|
|
||||||
```lean
|
```lean
|
||||||
DisableTactic, DisableLemma, OnlyTactic, OnlyLemma
|
DisabledTactic, DisabledTheorem, OnlyTactic, OnlyTheorem
|
||||||
```
|
```
|
||||||
|
|
||||||
have the same syntax as above. The former two disable items for this level, the latter two
|
have the same syntax as above. The former two disable items for this level, the latter two
|
||||||
@@ -161,7 +170,7 @@ disable all items except the ones specified.
|
|||||||
|
|
||||||
#### Theorem Tab
|
#### Theorem Tab
|
||||||
|
|
||||||
Theorems are sorted into tabs. with `LemmaTab "Mul"` you specify which tab should be open by default in this level.
|
Theorems are sorted into tabs. With `TheoremTab "Mul"` you specify which tab should be open by default in this level.
|
||||||
|
|
||||||
#### HiddenTactic
|
#### HiddenTactic
|
||||||
|
|
||||||
@@ -176,12 +185,14 @@ and only `rw` would show up in the inventory.
|
|||||||
|
|
||||||
### 6. b) Statement
|
### 6. b) Statement
|
||||||
|
|
||||||
The statement is the exercise of the level. the basics work the same as they would in `example` or `theorem`. Note however, that you **must** do a tactic proof, i.e. the `:= by` is a hard-coded part of the syntax
|
The statement is the exercise of the level. The basics work the same as they would in `example` or `theorem`. Note however, that you **must** do a tactic proof, i.e. the `:= by` is a hard-coded part of the syntax
|
||||||
|
|
||||||
#### Name
|
#### Name
|
||||||
|
|
||||||
You can give your exercise a name: `Statement my_first_exercise (n : Nat) ...`. If you do so, it will be added to the inventory and be available in future levels.
|
You can give your exercise a name: `Statement my_first_exercise (n : Nat) ...`. If you do so, it will be added to the inventory and be available in future levels.
|
||||||
|
|
||||||
|
You can but a `Statement` inside namespaces like you would with `theorem`.
|
||||||
|
|
||||||
#### Doc String / Exercise statement
|
#### Doc String / Exercise statement
|
||||||
|
|
||||||
Add a docstring that contains the exercise statement in natural language. If you do this, it will appear at the top of the exercise. It supports Latex.
|
Add a docstring that contains the exercise statement in natural language. If you do this, it will appear at the top of the exercise. It supports Latex.
|
||||||
@@ -205,12 +216,12 @@ Statement my_simp_lemma ...
|
|||||||
|
|
||||||
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
|
The proof must always be a tactic proof, i.e. `:= by` is a mandatory part of the syntax.
|
||||||
|
|
||||||
There are a few extra tactics that help you structuring the proof:
|
There are a few extra tactics that help you with structuring the proof:
|
||||||
|
|
||||||
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
|
- `Hint`: You can use `Hint "text"` to display text if the goal state in-game matches
|
||||||
the one where `Hint` is placed. For more options about hints, see below.
|
the one where `Hint` is placed. For more options about hints, see below.
|
||||||
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
|
- `Branch`: In the proof you can add a `Branch` that runs an alternative tactic sequence, which
|
||||||
helps setting `Hints` in different places. The `Branch` does not affect the main
|
helps to set `Hints` in different places. The `Branch` does not affect the main
|
||||||
proof and does not need to finish any goals.
|
proof and does not need to finish any goals.
|
||||||
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
|
- `Template`/`Hole`: Used to provide a sample proof template. Anything inside `Template`
|
||||||
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
|
will be copied into the editor with all `Hole`s replaced with `sorry`. Note that
|
||||||
@@ -225,23 +236,23 @@ Most important for game development are probably the `Hints`.
|
|||||||
The hints will be displayed whenever the player's current goal matches the goal the hint is
|
The hints will be displayed whenever the player's current goal matches the goal the hint is
|
||||||
placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands.
|
placed at inside the sample proof. You can use `Branch` to place hints in dead ends or alternative proof strands.
|
||||||
|
|
||||||
Read [More about Hints](doc/hints.md) for how they work and what the options are.
|
Read [More about Hints](hints.md) for how they work and what the options are.
|
||||||
|
|
||||||
### 6. e) Extra: Images
|
### 6. e) Extra: Images
|
||||||
You can add images on any layer of the game (i.e. game/world/level). These will be displayed in your game.
|
You can add images on any layer of the game (i.e. game/world/level). These will be displayed in your game.
|
||||||
|
|
||||||
The images need to be placed in `images/` and you need to add a command like `Image "images/path/to/myWorldImage.png"`
|
The images need to be placed in `images/` and you need to add a command like `Image "images/path/to/myWorldImage.png"`
|
||||||
in one of the files you created in 2), 3), or 4) (i.e. game/world/level).
|
in one of the files you created in 2), 3), or 4) (i.e. game/world/level).
|
||||||
|
|
||||||
NOTE: At present, only the images for a world are displayed. They appear in the introduction of the world.
|
NOTE: At present, only the images for a world are displayed. They appear in the introduction of the world.
|
||||||
|
|
||||||
## 7. Update your game
|
## 7. Update your game
|
||||||
|
|
||||||
In principle, it is as simple as modifying `lean-toolchain` to update your game to a new Lean version. However, you should read about the details in [Update An Existing Game](doc/update_game.md).
|
In principle, it is as simple as modifying `lean-toolchain` to update your game to a new Lean version. However, you should read about the details in [Update An Existing Game](update_game.md).
|
||||||
|
|
||||||
## 8. Publish your game
|
## 8. Publish your game
|
||||||
|
|
||||||
To publish your game on the official server, see [Publishing a game](doc/publish_game.md)
|
To publish your game on the official server, see [Publishing a game](publish_game.md)
|
||||||
|
|
||||||
There are a few more options you can add in `Game.lean` before the `MakeGame` command, which describe the tile that is visible on the server's landing page:
|
There are a few more options you can add in `Game.lean` before the `MakeGame` command, which describe the tile that is visible on the server's landing page:
|
||||||
|
|
||||||
@@ -253,7 +264,7 @@ Prerequisites "NNG"
|
|||||||
CoverImage "images/cover.png"
|
CoverImage "images/cover.png"
|
||||||
```
|
```
|
||||||
* `Languages`: Currently only a single language (capital English name). The tile will show a corresponding flag.
|
* `Languages`: Currently only a single language (capital English name). The tile will show a corresponding flag.
|
||||||
* `CaptionShort`: One catch phrase. Appears above the image.
|
* `CaptionShort`: One catchphrase. Appears above the image.
|
||||||
* `CaptionLong`: 2-4 sentences to describe the game.
|
* `CaptionLong`: 2-4 sentences to describe the game.
|
||||||
* `Prerequisites` a list of other games you should play before this one, e.g. `Prerequisites "NNG" "STG"`. The game names are free-text.
|
* `Prerequisites` a list of other games you should play before this one, e.g. `Prerequisites "NNG" "STG"`. The game names are free-text.
|
||||||
* `CoverImage`: You can create a folder `images/` and put images there for the game to use. The maximal ratio is ca. 500x200 (W x H) but it might be cropped horizontally on narrow screens.
|
* `CoverImage`: You can create a folder `images/` and put images there for the game to use. The maximal ratio is ca. 500x200 (W x H) but it might be cropped horizontally on narrow screens.
|
||||||
|
|||||||
+5
-3
@@ -10,6 +10,8 @@ Statement .... := by
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note that hints are only **context-aware but not history-aware**. In particular, they only look at the assumptions and the current goal. Player's might encounter hints in a different order - or not at all - if they decide to go for a unique proof idea. The `Branch` tactic helps to place hints outside the sample solution's proof.
|
||||||
|
|
||||||
## 1. When do hints show?
|
## 1. When do hints show?
|
||||||
|
|
||||||
A hint will be displayed if the player's goal matches the one where the hint was placed in the
|
A hint will be displayed if the player's goal matches the one where the hint was placed in the
|
||||||
@@ -17,7 +19,7 @@ sample solutions and the entire context from the sample solutions is present in
|
|||||||
player's context. The player's context may contain additional items.
|
player's context. The player's context may contain additional items.
|
||||||
|
|
||||||
This means if you have multiple identical
|
This means if you have multiple identical
|
||||||
subgoals, you should only place a single hint in one of them and it will be displayed in
|
subgoals, you should only place a single hint in one of them, and it will be displayed in
|
||||||
all of them.
|
all of them.
|
||||||
|
|
||||||
However, identical (non-hidden) hints which where already present in the step
|
However, identical (non-hidden) hints which where already present in the step
|
||||||
@@ -30,12 +32,12 @@ Hidden hints are not filtered.
|
|||||||
You can use `Branch` to place hints
|
You can use `Branch` to place hints
|
||||||
in dead ends or alternative proof strands.
|
in dead ends or alternative proof strands.
|
||||||
|
|
||||||
A proof inside a `Branch`-block is normally evaluated by lean but it's discarded at the end
|
A proof inside a `Branch`-block is normally evaluated by lean, but it's discarded at the end
|
||||||
so that no progress has been made on proofing the goal.
|
so that no progress has been made on proofing the goal.
|
||||||
|
|
||||||
```
|
```
|
||||||
Statement .... := by
|
Statement .... := by
|
||||||
Hint "Huse `rw` or `rewrite`."
|
Hint "use `rw` or `rewrite`."
|
||||||
Branch
|
Branch
|
||||||
rewrite [h]
|
rewrite [h]
|
||||||
Hint "now you still need `rfl`"
|
Hint "now you still need `rfl`"
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Publishing games
|
# Publishing games
|
||||||
|
|
||||||
You can publish your game on the official (Lean Game Server)[https://adam.math.hhu.de] in a few simple
|
You can publish your game on the official [Lean Game Server](https://adam.math.hhu.de) in a few simple
|
||||||
steps.
|
steps.
|
||||||
|
|
||||||
## 1. Upload Game to github
|
## 1. Upload Game to github
|
||||||
@@ -11,7 +11,7 @@ tab.
|
|||||||
|
|
||||||
## 2. Import the game
|
## 2. Import the game
|
||||||
|
|
||||||
You call the URL that's listed under "What's Next?" in the latest action run. Explicitely you call
|
You call the URL that's listed under "What's Next?" in the latest action run. Explicitly you call
|
||||||
the URL of the form
|
the URL of the form
|
||||||
|
|
||||||
> adam.math.hhu.de/import/trigger/{USER}/{REPOSITORY}
|
> adam.math.hhu.de/import/trigger/{USER}/{REPOSITORY}
|
||||||
|
|||||||
+27
-12
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
|
The installation instructions are not yet tested on Mac/Windows. Comments very welcome!
|
||||||
|
|
||||||
|
Please also consult the [Troubleshooting Collection](troubleshoot.md), where some known pitfalls are collected.
|
||||||
|
|
||||||
There are several options to play a game locally:
|
There are several options to play a game locally:
|
||||||
|
|
||||||
1. VSCode Dev Container: needs `docker` installed on your machine
|
1. VSCode Dev Container: needs `docker` installed on your machine
|
||||||
@@ -31,24 +33,14 @@ The template game [GameSkeleton](https://github.com/hhu-adam/GameSkeleton) conta
|
|||||||
|
|
||||||
* The first start will take a while, ca. 2-15 minutes. After the first
|
* The first start will take a while, ca. 2-15 minutes. After the first
|
||||||
start this should be very quickly.
|
start this should be very quickly.
|
||||||
* Once built, you can open http://localhost:3000 in your browser. which should load the game.
|
* Once built, you can open http://localhost:3000 in your browser, which should load the game.
|
||||||
|
|
||||||
3. **Editing Files** *(everytime)*:<br/>
|
3. **Editing Files** *(everytime)*:<br/>
|
||||||
After editing some Lean files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`. Now you can reload your browser to see the changes.
|
After editing some Lean files in VSCode, open VSCode's terminal (View > Terminal) and run `lake build`. Now you can reload your browser to see the changes.
|
||||||
|
|
||||||
### Errors
|
|
||||||
|
|
||||||
* If you don't get the pop-up, you might have disabled them and you can reenable it by
|
|
||||||
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
|
|
||||||
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
|
|
||||||
you might have deleted stuff from docker via your shell. Try deleting the container and image
|
|
||||||
explicitely in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
|
|
||||||
container. (this will again take some time)
|
|
||||||
* On a working dev container setup, http://localhost:3000 should directly redirect you to http://localhost:3000/#/g/local/game, try if the latter is accessible.
|
|
||||||
|
|
||||||
## Codespaces
|
## Codespaces
|
||||||
|
|
||||||
You can work on your game using Github codespaces (click "Code" and then "Codespaces" and then "create codespace on main"). It it should run the game locally in the background. You can open it for example under "Ports" and clicking on "Open in Browser".
|
You can work on your game using Github codespaces (click "Code" and then "Codespaces" and then "create codespace on main"). It should run the game locally in the background. You can open it for example under "Ports" and clicking on "Open in Browser".
|
||||||
|
|
||||||
Note: You have to wait until npm started properly, which might take a good while.
|
Note: You have to wait until npm started properly, which might take a good while.
|
||||||
|
|
||||||
@@ -108,6 +100,29 @@ Run the game:
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You should see a message like this:
|
||||||
|
```bash
|
||||||
|
[server] > lean4-game@0.1.0 start_server
|
||||||
|
[server] > (cd server && lake build) && (cd relay && cross-env NODE_ENV=development nodemon -e mjs --exec "node ./index.mjs")
|
||||||
|
[server]
|
||||||
|
[client]
|
||||||
|
[client] > lean4-game@0.1.0 start_client
|
||||||
|
[client] > cross-env NODE_ENV=development vite --host
|
||||||
|
[client]
|
||||||
|
[server] [nodemon] 3.0.#
|
||||||
|
[server] [nodemon] to restart at any time, enter `rs`
|
||||||
|
[server] [nodemon] watching path(s): *.*
|
||||||
|
[server] [nodemon] watching extensions: mjs
|
||||||
|
[server] [nodemon] starting `node ./index.mjs`
|
||||||
|
[client]
|
||||||
|
[client] VITE v4.5.1 ready in \#\#\# ms
|
||||||
|
[client]
|
||||||
|
[client] ➜ Local: http://localhost:3000/
|
||||||
|
[client] ➜ Network: http://###.###.###.##:3000/
|
||||||
|
[client] [vite-plugin-static-copy] Collected 7 items.
|
||||||
|
[server] (node:#####) [DEP0040] [server] Listening on 8080
|
||||||
|
```
|
||||||
|
|
||||||
This takes a little time. Eventually, the game is available on http://localhost:3000/#/g/local/GameSkeleton. Replace `GameSkeleton` with the folder name of your local game.
|
This takes a little time. Eventually, the game is available on http://localhost:3000/#/g/local/GameSkeleton. Replace `GameSkeleton` with the folder name of your local game.
|
||||||
|
|
||||||
## Modifying the GameServer
|
## Modifying the GameServer
|
||||||
|
|||||||
+3
-3
@@ -6,7 +6,7 @@ In order to set up the server to allow imports, one needs to create a
|
|||||||
repos will suffice.
|
repos will suffice.
|
||||||
|
|
||||||
You need to set the environment variables `LEAN4GAME_GITHUB_USER` and `LEAN4GAME_GITHUB_TOKEN`
|
You need to set the environment variables `LEAN4GAME_GITHUB_USER` and `LEAN4GAME_GITHUB_TOKEN`
|
||||||
with your user name and access token. For example, you can seet these in `ecosystem.config.cjs` if
|
with your user name and access token. For example, you can set these in `ecosystem.config.cjs` if
|
||||||
you're using `pm2`
|
you're using `pm2`
|
||||||
|
|
||||||
Then people can call:
|
Then people can call:
|
||||||
@@ -22,7 +22,7 @@ where you replace:
|
|||||||
|
|
||||||
> https://{website}/#/g/{owner}/{repo}
|
> https://{website}/#/g/{owner}/{repo}
|
||||||
|
|
||||||
## data management
|
## Data management
|
||||||
Everything downloaded remains in the folder `lean4game/games`.
|
Everything downloaded remains in the folder `lean4game/games`.
|
||||||
the subfolder `tmp` contains downloaded artifacts and can be deleted without loss.
|
The subfolder `tmp` contains downloaded artifacts and can be deleted without loss.
|
||||||
The other folders should only contain the built lean-games, sorted by owner and repo.
|
The other folders should only contain the built lean-games, sorted by owner and repo.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
Here are some issues experienced by users.
|
||||||
|
|
||||||
|
- You can reset the lake projects involved (i.e. the `server/` folder here as well as your [game's folder](https://github.com/hhu-adam/GameSkeleton)) with the following commands:
|
||||||
|
```
|
||||||
|
cd [THE PROJECT]
|
||||||
|
rm -rf .lake/
|
||||||
|
lake update -R
|
||||||
|
lake build
|
||||||
|
```
|
||||||
|
If you experience problems related to Lean or lake, you should first try to reset it this way.
|
||||||
|
|
||||||
|
# VSCode Dev-Container
|
||||||
|
* If you don't get the pop-up, you might have disabled them, and you can reenable it by
|
||||||
|
running the `remote-containers.showReopenInContainerNotificationReset` command in vscode.
|
||||||
|
* If the starting the container fails, in particular with a message `Error: network xyz not found`,
|
||||||
|
you might have deleted stuff from docker via your shell. Try deleting the container and image
|
||||||
|
explicitly in VSCode (left side, "Docker" icon). Then reopen vscode and let it rebuild the
|
||||||
|
container. (this will again take some time)
|
||||||
|
* On a working dev container setup, http://localhost:3000 should directly redirect you to http://localhost:3000/#/g/local/game, try if the latter is accessible.
|
||||||
|
|
||||||
|
|
||||||
|
# Manual Installation
|
||||||
|
Here are known issues/pitfalls with the local setup using `npm`.
|
||||||
|
|
||||||
|
* If `CDPATH` is set on your mac/linux system, it might provide issues with `npm start` resulting in a server crash or blank screen. In particular `npm start` will display
|
||||||
|
```
|
||||||
|
[server] sh: line 0: cd: server: No such file or directory
|
||||||
|
[server] npm run start_server exited with code 1
|
||||||
|
```
|
||||||
|
As a fix you might need to delete your manually set `CDPATH` environment variable.
|
||||||
|
|
||||||
|
# Publication
|
||||||
|
Errors concerning uploads to the server.
|
||||||
|
|
||||||
|
* Your game overview loads but the server crashes on loading a level: Check your game's github action is identical to the [GameSkeleton's](https://github.com/hhu-adam/GameSkeleton/blob/main/.github/workflows/build.yml), in particular that there is a step about building the "`gameserver`-executable".
|
||||||
+3
-3
@@ -9,8 +9,8 @@ Before you continue, make sure there [exists a `v4.X.0`-tag in this repo](https:
|
|||||||
|
|
||||||
Then, depending on the setup you use, do one of the following:
|
Then, depending on the setup you use, do one of the following:
|
||||||
|
|
||||||
* Dev Container: Rebuild the VSCode Devcontainer.
|
* **Dev Container**: Rebuild the VSCode Devcontainer (without Cache!).
|
||||||
* Local Setup: in your game's folder run the following:
|
* **Local Setup**: in your game's folder run the following:
|
||||||
```
|
```
|
||||||
lake update -R
|
lake update -R
|
||||||
lake build
|
lake build
|
||||||
@@ -24,7 +24,7 @@ Then, depending on the setup you use, do one of the following:
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
where `{VERSION_TAG}` is the tag from above of the form `v4.X.0`
|
where `{VERSION_TAG}` is the tag from above of the form `v4.X.0`
|
||||||
* Gitpod/Codespaces: Create a fresh one
|
* **Gitpod/Codespaces**: Create a fresh one
|
||||||
|
|
||||||
This will update your game (and the mathlib version you might be using) to the new lean version.
|
This will update your game (and the mathlib version you might be using) to the new lean version.
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<script src="coi-serviceworker.js"></script>
|
|
||||||
<script type="module" src="/client/src/index.tsx"></script>
|
<script type="module" src="/client/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
Generated
+1901
-2566
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -21,7 +21,6 @@
|
|||||||
"@types/cytoscape": "^3.19.9",
|
"@types/cytoscape": "^3.19.9",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react-swc": "^3.4.0",
|
"@vitejs/plugin-react-swc": "^3.4.0",
|
||||||
"coi-serviceworker": "^0.1.7",
|
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"cytoscape": "^3.23.0",
|
"cytoscape": "^3.23.0",
|
||||||
"cytoscape-elk": "^2.1.0",
|
"cytoscape-elk": "^2.1.0",
|
||||||
@@ -29,8 +28,8 @@
|
|||||||
"debounce": "^1.2.1",
|
"debounce": "^1.2.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c",
|
"lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c",
|
||||||
"lean4web": "github:hhu-adam/lean4web#b91645a7b88814675ba9f99817436d0a2ce3a0ec",
|
"lean4web": "github:hhu-adam/lean4web#414d9e62638a392fca278761b4c61a1d2e138bc7",
|
||||||
"octokit": "^2.0.14",
|
"octokit": "^3.1.2",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ import { importTrigger, importStatus } from './import.mjs'
|
|||||||
const queueLength = {
|
const queueLength = {
|
||||||
"g/hhu-adam/robo": 2,
|
"g/hhu-adam/robo": 2,
|
||||||
"g/hhu-adam/nng4": 5,
|
"g/hhu-adam/nng4": 5,
|
||||||
"g/djvelleman/stg4": 2,
|
"g/djvelleman/stg4": 0,
|
||||||
|
"g/trequetrum/lean4game-logic": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|||||||
@@ -28,3 +28,7 @@ do
|
|||||||
#tar -xvzf $f -C games/${OWNER}/${REPO}
|
#tar -xvzf $f -C games/${OWNER}/${REPO}
|
||||||
unzip -q -o $f -d ${OWNER}/${REPO}
|
unzip -q -o $f -d ${OWNER}/${REPO}
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Delete temporary files
|
||||||
|
rm -f tmp/${OWNER}_${REPO}_${ARTIFACT_ID}.zip
|
||||||
|
rm -fr tmp/${OWNER}_${REPO}_${ARTIFACT_ID}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ unsafe def main : List String → IO UInt32 := fun args => do
|
|||||||
|
|
||||||
-- TODO: remove this argument
|
-- TODO: remove this argument
|
||||||
if args[0]? == some "--server" then
|
if args[0]? == some "--server" then
|
||||||
MyServer.FileWorker.workerMain {} args
|
GameServer.FileWorker.workerMain {} args
|
||||||
else
|
else
|
||||||
e.putStrLn s!"Expected `--server`"
|
e.putStrLn s!"Expected `--server`"
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
+342
-259
@@ -5,6 +5,7 @@ import GameServer.ImportModules
|
|||||||
import GameServer.SaveData
|
import GameServer.SaveData
|
||||||
|
|
||||||
namespace MyModule
|
namespace MyModule
|
||||||
|
|
||||||
open Lean
|
open Lean
|
||||||
open Elab
|
open Elab
|
||||||
open Parser
|
open Parser
|
||||||
@@ -48,7 +49,8 @@ partial def parseTactic (inputCtx : InputContext) (pmctx : ParserModuleContext)
|
|||||||
|
|
||||||
end MyModule
|
end MyModule
|
||||||
|
|
||||||
namespace MyServer.FileWorker
|
namespace GameServer.FileWorker
|
||||||
|
|
||||||
open Lean
|
open Lean
|
||||||
open Lean.Server
|
open Lean.Server
|
||||||
open Lean.Server.FileWorker
|
open Lean.Server.FileWorker
|
||||||
@@ -57,103 +59,147 @@ open IO
|
|||||||
open Snapshots
|
open Snapshots
|
||||||
open JsonRpc
|
open JsonRpc
|
||||||
|
|
||||||
structure GameWorkerState :=
|
/--
|
||||||
|
Game-specific state to be packed on top of the `Lean.Server.FileWorker.WorkerState`
|
||||||
|
used by the lean server.
|
||||||
|
-/
|
||||||
|
structure WorkerState :=
|
||||||
|
/--
|
||||||
|
Collection of items which are considered unlocked.
|
||||||
|
Tactics and theorems are mixed together.
|
||||||
|
-/
|
||||||
inventory : Array String
|
inventory : Array String
|
||||||
/--
|
/--
|
||||||
Check for tactics/theorems that are not unlocked.
|
Difficulty determines whether tactics/theorems can be locked.
|
||||||
0: no check
|
* 0: do not check
|
||||||
1: give warnings
|
* 1: give warnings when locked items are used
|
||||||
2: give errors
|
* 2: give errors when locked items are used
|
||||||
-/
|
-/
|
||||||
difficulty : Nat
|
difficulty : Nat
|
||||||
|
/--
|
||||||
|
`levelInfo` contains all the (static) information about the level which is not influenced
|
||||||
|
by the user's progress.
|
||||||
|
-/
|
||||||
levelInfo : LevelInfo
|
levelInfo : LevelInfo
|
||||||
deriving ToJson, FromJson
|
deriving ToJson, FromJson
|
||||||
|
|
||||||
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
|
/--
|
||||||
|
Pack the `GameServer.FileWorker.WorkerState` on top of the normal worker monad
|
||||||
|
`Server.FileWorker.WorkerM`.
|
||||||
|
-/
|
||||||
|
abbrev WorkerM := StateT WorkerState Server.FileWorker.WorkerM
|
||||||
|
|
||||||
section Elab
|
section Elab
|
||||||
|
|
||||||
|
/-- Add a message. use `(severity := .warning)` to specify the severity-/
|
||||||
|
def addMessage (info : SourceInfo) (inputCtx : Parser.InputContext)
|
||||||
|
(severity := MessageSeverity.warning) (s : MessageData) :
|
||||||
|
Elab.Command.CommandElabM Unit := do
|
||||||
|
modify fun st => { st with
|
||||||
|
messages := st.messages.add {
|
||||||
|
fileName := inputCtx.fileName
|
||||||
|
severity := severity
|
||||||
|
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
||||||
|
data := s }}
|
||||||
|
|
||||||
|
/-- Deprecated! -/
|
||||||
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) :
|
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) :
|
||||||
Elab.Command.CommandElabM Unit := do
|
Elab.Command.CommandElabM Unit := do
|
||||||
modify fun st => { st with
|
modify fun st => { st with
|
||||||
messages := st.messages.add {
|
messages := st.messages.add {
|
||||||
fileName := inputCtx.fileName
|
fileName := inputCtx.fileName
|
||||||
severity := MessageSeverity.error
|
severity := MessageSeverity.error
|
||||||
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
||||||
data := s
|
data := s }}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
-- TODO: use HashSet for allowed tactics?
|
-- TODO: use HashSet for allowed tactics?
|
||||||
/-- Find all tactics in syntax object that are forbidden according to a
|
/--
|
||||||
set `allowed` of allowed tactics. -/
|
Find all tactics in syntax object that are forbidden according to a
|
||||||
partial def findForbiddenTactics (inputCtx : Parser.InputContext)
|
set `allowed` of allowed tactics.
|
||||||
(gameWorkerState : GameWorkerState) (stx : Syntax) :
|
-/
|
||||||
Elab.Command.CommandElabM Unit := do
|
partial def findForbiddenTactics (inputCtx : Parser.InputContext) (workerState : WorkerState)
|
||||||
let levelInfo := gameWorkerState.levelInfo
|
(stx : Syntax) : Elab.Command.CommandElabM Unit := do
|
||||||
|
let levelInfo := workerState.levelInfo
|
||||||
|
-- Parse the syntax object and look for tactics and declarations.
|
||||||
match stx with
|
match stx with
|
||||||
| .missing => return ()
|
| .missing => return ()
|
||||||
| .node _info _kind args =>
|
| .node _info _kind args =>
|
||||||
|
-- Go inside a node.
|
||||||
for arg in args do
|
for arg in args do
|
||||||
findForbiddenTactics inputCtx gameWorkerState arg
|
findForbiddenTactics inputCtx workerState arg
|
||||||
| .atom info val =>
|
| .atom info val =>
|
||||||
-- ignore syntax elements that do not start with a letter
|
-- Atoms might be tactic names or other keywords.
|
||||||
-- and ignore "with" keyword
|
-- Note: We whitelisted known keywords because we cannot
|
||||||
let allowed := ["with", "fun", "at", "only", "by", "to"]
|
-- distinguish keywords from tactic names.
|
||||||
|
let allowed := ["with", "fun", "at", "only", "by", "to", "generalizing", "says"]
|
||||||
|
-- Ignore syntax elements that do not start with a letter or are listed above.
|
||||||
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
|
||||||
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
|
-- Treat `simp?` and `simp!` like `simp`
|
||||||
|
let val := val.dropRightWhile (fun c => c == '!' || c == '?')
|
||||||
match levelInfo.tactics.find? (·.name.toString == val) with
|
match levelInfo.tactics.find? (·.name.toString == val) with
|
||||||
| none =>
|
| none =>
|
||||||
-- Note: This case means that the tactic will never be introduced in the game.
|
-- Tactic will never be introduced in the game.
|
||||||
match gameWorkerState.inventory.find? (· == val) with
|
match workerState.inventory.find? (· == val) with
|
||||||
|
| some _ =>
|
||||||
|
-- Tactic is in the inventory, allow it.
|
||||||
|
-- Note: This case shouldn't be possible...
|
||||||
|
pure ()
|
||||||
| none =>
|
| none =>
|
||||||
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
-- Tactic is not in the inventory.
|
||||||
| some _ => pure () -- tactic is in the inventory, allow it.
|
addMessageByDifficulty info s!"The tactic '{val}' is not available in this game!"
|
||||||
| some tac =>
|
| some tac =>
|
||||||
if tac.locked then
|
-- Tactic is introduced at some point in the game.
|
||||||
match gameWorkerState.inventory.find? (· == val) with
|
if tac.disabled then
|
||||||
|
-- Tactic is disabled in this level.
|
||||||
|
addMessageByDifficulty info s!"The tactic '{val}' is disabled in this level!"
|
||||||
|
else if tac.locked then
|
||||||
|
match workerState.inventory.find? (· == val) with
|
||||||
| none =>
|
| none =>
|
||||||
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
|
-- Tactic is marked as locked and not in the inventory.
|
||||||
| some _ => pure () -- tactic is in the inventory, allow it.
|
addMessageByDifficulty info s!"You have not unlocked the tactic '{val}' yet!"
|
||||||
else if tac.disabled then
|
| some _ =>
|
||||||
addWarningMessage info s!"The tactic '{val}' is disabled in this level!"
|
-- Tactic is in the inventory, allow it.
|
||||||
|
pure ()
|
||||||
| .ident info _rawVal val _preresolved =>
|
| .ident info _rawVal val _preresolved =>
|
||||||
let ns ←
|
-- Try to resolve the name
|
||||||
try resolveGlobalConst (mkIdent val)
|
let ns ←
|
||||||
catch | _ => pure [] -- catch "unknown constant" error
|
try resolveGlobalConst (mkIdent val)
|
||||||
for n in ns do
|
-- Catch "unknown constant" error
|
||||||
let some (.thmInfo ..) := (← getEnv).find? n
|
catch | _ => pure []
|
||||||
| return () -- not a theorem -> ignore
|
for n in ns do
|
||||||
|
let some (.thmInfo ..) := (← getEnv).find? n
|
||||||
|
-- Not a theorem, no checks needed.
|
||||||
|
| return ()
|
||||||
|
if some n = levelInfo.statementName then
|
||||||
-- Forbid the theorem we are proving currently
|
-- Forbid the theorem we are proving currently
|
||||||
if some n = levelInfo.statementName then
|
addMessage info inputCtx (severity := .error)
|
||||||
addErrorMessage info inputCtx s!"Structural recursion: you can't use '{n}' to proof itself!"
|
s!"Structural recursion: you can't use '{n}' to proof itself!"
|
||||||
|
let theoremsAndDefs := levelInfo.lemmas ++ levelInfo.definitions
|
||||||
|
match theoremsAndDefs.find? (·.name == n) with
|
||||||
|
| none =>
|
||||||
|
-- Theorem will never be introduced in this game
|
||||||
|
addMessageByDifficulty info s!"You have not unlocked the theorem/definition '{n}' yet!"
|
||||||
|
| some thm =>
|
||||||
|
-- Theorem is introduced at some point in the game.
|
||||||
|
if thm.disabled then
|
||||||
|
-- Theorem is disabled in this level.
|
||||||
|
addMessageByDifficulty info s!"The theorem/definition '{n}' is disabled in this level!"
|
||||||
|
else if thm.locked then
|
||||||
|
-- Theorem is still locked.
|
||||||
|
addMessageByDifficulty info s!"You have not unlocked the theorem/definition '{n}' yet!"
|
||||||
|
|
||||||
let lemmasAndDefs := levelInfo.lemmas ++ levelInfo.definitions
|
where addMessageByDifficulty (info : SourceInfo) (s : MessageData) :=
|
||||||
match lemmasAndDefs.find? (fun l => l.name == n) with
|
-- See `GameServer.FileWorker.WorkerState.difficulty`. Send nothing/warnings/errors
|
||||||
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
-- deppending on difficulty.
|
||||||
| some lem =>
|
let difficulty := workerState.difficulty
|
||||||
if lem.locked then
|
|
||||||
addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
|
|
||||||
else if lem.disabled then
|
|
||||||
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
|
|
||||||
where addWarningMessage (info : SourceInfo) (s : MessageData) :=
|
|
||||||
let difficulty := gameWorkerState.difficulty
|
|
||||||
if difficulty > 0 then
|
if difficulty > 0 then
|
||||||
modify fun st => { st with
|
addMessage info inputCtx (if difficulty > 1 then .error else .warning) s
|
||||||
messages := st.messages.add {
|
|
||||||
fileName := inputCtx.fileName
|
|
||||||
severity := if difficulty > 1 then MessageSeverity.error else MessageSeverity.warning
|
|
||||||
pos := inputCtx.fileMap.toPosition (info.getPos?.getD 0)
|
|
||||||
data := s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else pure ()
|
else pure ()
|
||||||
-- where addErrorMessage (info : SourceInfo) (s : MessageData) :=
|
|
||||||
-- pure ()
|
|
||||||
|
|
||||||
open Elab Meta Expr in
|
open Elab Meta Expr in
|
||||||
|
|
||||||
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
|
def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets : Bool)
|
||||||
(couldBeEndSnap : Bool) (gameWorkerState : GameWorkerState)
|
(couldBeEndSnap : Bool) (gameWorkerState : WorkerState)
|
||||||
(initParams : Lsp.InitializeParams) : IO Snapshot := do
|
(initParams : Lsp.InitializeParams) : IO Snapshot := do
|
||||||
-- Recognize end snap
|
-- Recognize end snap
|
||||||
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
if inputCtx.input.atEnd snap.mpState.pos ∧ couldBeEndSnap then
|
||||||
@@ -287,7 +333,7 @@ where
|
|||||||
|
|
||||||
/-- Elaborates the next command after `parentSnap` and emits diagnostics into `hOut`. -/
|
/-- Elaborates the next command after `parentSnap` and emits diagnostics into `hOut`. -/
|
||||||
private def nextSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
|
private def nextSnap (ctx : WorkerContext) (m : DocumentMeta) (cancelTk : CancelToken)
|
||||||
(gameWorkerState : GameWorkerState) (initParams : Lsp.InitializeParams)
|
(gameWorkerState : WorkerState) (initParams : Lsp.InitializeParams)
|
||||||
: AsyncElabM (Option Snapshot) := do
|
: AsyncElabM (Option Snapshot) := do
|
||||||
cancelTk.check
|
cancelTk.check
|
||||||
let s ← get
|
let s ← get
|
||||||
@@ -327,7 +373,7 @@ where
|
|||||||
|
|
||||||
/-- Elaborates all commands after the last snap (at least the header snap is assumed to exist), emitting the diagnostics into `hOut`. -/
|
/-- Elaborates all commands after the last snap (at least the header snap is assumed to exist), emitting the diagnostics into `hOut`. -/
|
||||||
def unfoldSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
|
def unfoldSnaps (m : DocumentMeta) (snaps : Array Snapshot) (cancelTk : CancelToken)
|
||||||
(startAfterMs : UInt32) (gameWorkerState : GameWorkerState)
|
(startAfterMs : UInt32) (gameWorkerState : WorkerState)
|
||||||
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
|
: ReaderT WorkerContext IO (AsyncList ElabTaskError Snapshot) := do
|
||||||
let ctx ← read
|
let ctx ← read
|
||||||
let some headerSnap := snaps[0]? | panic! "empty snapshots"
|
let some headerSnap := snaps[0]? | panic! "empty snapshots"
|
||||||
@@ -350,224 +396,254 @@ end Elab
|
|||||||
section Updates
|
section Updates
|
||||||
|
|
||||||
/-- Given the new document, updates editable doc state. -/
|
/-- Given the new document, updates editable doc state. -/
|
||||||
def updateDocument (newMeta : DocumentMeta) : GameWorkerM Unit := do
|
def updateDocument (newMeta : DocumentMeta) : WorkerM Unit := do
|
||||||
let s ← get
|
let s ← get
|
||||||
let ctx ← read
|
let ctx ← read
|
||||||
let oldDoc := (← StateT.lift get).doc
|
let oldDoc := (← StateT.lift get).doc
|
||||||
oldDoc.cancelTk.set
|
oldDoc.cancelTk.set
|
||||||
let initHeaderStx := (← StateT.lift get).initHeaderStx
|
let initHeaderStx := (← StateT.lift get).initHeaderStx
|
||||||
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
let (newHeaderStx, newMpState, _) ← Parser.parseHeader newMeta.mkInputContext
|
||||||
let cancelTk ← CancelToken.new
|
let cancelTk ← CancelToken.new
|
||||||
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
let headSnapTask := oldDoc.cmdSnaps.waitHead?
|
||||||
let newSnaps ← if initHeaderStx != newHeaderStx then
|
let newSnaps ← if initHeaderStx != newHeaderStx then
|
||||||
EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
|
EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
|
||||||
IO.sleep ctx.initParams.editDelay.toUInt32
|
IO.sleep ctx.initParams.editDelay.toUInt32
|
||||||
cancelTk.check
|
cancelTk.check
|
||||||
IO.Process.exit 2
|
IO.Process.exit 2
|
||||||
else EIO.mapTask (ε := ElabTaskError) (t := headSnapTask) (prio := .dedicated) fun headSnap?? => do
|
else EIO.mapTask (ε := ElabTaskError) (t := headSnapTask) (prio := .dedicated) fun headSnap?? => do
|
||||||
-- There is always at least one snapshot absent exceptions
|
-- There is always at least one snapshot absent exceptions
|
||||||
let some headSnap ← MonadExcept.ofExcept headSnap?? | panic! "empty snapshots"
|
let some headSnap ← MonadExcept.ofExcept headSnap?? | panic! "empty snapshots"
|
||||||
let newHeaderSnap := { headSnap with stx := newHeaderStx, mpState := newMpState }
|
let newHeaderSnap := { headSnap with stx := newHeaderStx, mpState := newMpState }
|
||||||
let changePos := oldDoc.meta.text.source.firstDiffPos newMeta.text.source
|
let changePos := oldDoc.meta.text.source.firstDiffPos newMeta.text.source
|
||||||
-- Ignore exceptions, we are only interested in the successful snapshots
|
-- Ignore exceptions, we are only interested in the successful snapshots
|
||||||
let (cmdSnaps, _) ← oldDoc.cmdSnaps.getFinishedPrefix
|
let (cmdSnaps, _) ← oldDoc.cmdSnaps.getFinishedPrefix
|
||||||
-- NOTE(WN): we invalidate eagerly as `endPos` consumes input greedily. To re-elaborate only
|
-- NOTE(WN): we invalidate eagerly as `endPos` consumes input greedily. To re-elaborate only
|
||||||
-- when really necessary, we could do a whitespace-aware `Syntax` comparison instead.
|
-- when really necessary, we could do a whitespace-aware `Syntax` comparison instead.
|
||||||
let mut validSnaps ← pure (cmdSnaps.takeWhile (fun s => s.endPos < changePos))
|
let mut validSnaps ← pure (cmdSnaps.takeWhile (fun s => s.endPos < changePos))
|
||||||
if h : validSnaps.length ≤ 1 then
|
if h : validSnaps.length ≤ 1 then
|
||||||
validSnaps := [newHeaderSnap]
|
validSnaps := [newHeaderSnap]
|
||||||
else
|
else
|
||||||
/- When at least one valid non-header snap exists, it may happen that a change does not fall
|
/- When at least one valid non-header snap exists, it may happen that a change does not fall
|
||||||
within the syntactic range of that last snap but still modifies it by appending tokens.
|
within the syntactic range of that last snap but still modifies it by appending tokens.
|
||||||
We check for this here. We do not currently handle crazy grammars in which an appended
|
We check for this here. We do not currently handle crazy grammars in which an appended
|
||||||
token can merge two or more previous commands into one. To do so would require reparsing
|
token can merge two or more previous commands into one. To do so would require reparsing
|
||||||
the entire file. -/
|
the entire file. -/
|
||||||
have : validSnaps.length ≥ 2 := Nat.gt_of_not_le h
|
have : validSnaps.length ≥ 2 := Nat.gt_of_not_le h
|
||||||
let mut lastSnap := validSnaps.getLast (by subst ·; simp at h)
|
let mut lastSnap := validSnaps.getLast (by subst ·; simp at h)
|
||||||
let preLastSnap :=
|
let preLastSnap :=
|
||||||
have : 0 < validSnaps.length := Nat.lt_of_lt_of_le (by decide) this
|
have : 0 < validSnaps.length := Nat.lt_of_lt_of_le (by decide) this
|
||||||
have : validSnaps.length - 2 < validSnaps.length := Nat.sub_lt this (by decide)
|
have : validSnaps.length - 2 < validSnaps.length := Nat.sub_lt this (by decide)
|
||||||
validSnaps[validSnaps.length - 2]
|
validSnaps[validSnaps.length - 2]
|
||||||
let newLastStx ← parseNextCmd newMeta.mkInputContext preLastSnap
|
let newLastStx ← parseNextCmd newMeta.mkInputContext preLastSnap
|
||||||
if newLastStx != lastSnap.stx then
|
if newLastStx != lastSnap.stx then
|
||||||
validSnaps := validSnaps.dropLast
|
validSnaps := validSnaps.dropLast
|
||||||
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger
|
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger
|
||||||
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
|
||||||
unfoldSnaps newMeta validSnaps.toArray cancelTk s ctx
|
unfoldSnaps newMeta validSnaps.toArray cancelTk s ctx
|
||||||
(startAfterMs := ctx.initParams.editDelay.toUInt32)
|
(startAfterMs := ctx.initParams.editDelay.toUInt32)
|
||||||
StateT.lift <| modify fun st => { st with
|
StateT.lift <| modify fun st => { st with
|
||||||
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
|
doc := { meta := newMeta, cmdSnaps := AsyncList.delayed newSnaps, cancelTk }}
|
||||||
|
|
||||||
end Updates
|
end Updates
|
||||||
|
|
||||||
section Initialization
|
section Initialization
|
||||||
|
|
||||||
def DocumentMeta.mkInputContext (doc : DocumentMeta) : Parser.InputContext where
|
def DocumentMeta.mkInputContext (doc : DocumentMeta) : Parser.InputContext where
|
||||||
input := "" -- No header!
|
input := "" -- No header!
|
||||||
fileName := (System.Uri.fileUriToPath? doc.uri).getD doc.uri |>.toString
|
fileName := (System.Uri.fileUriToPath? doc.uri).getD doc.uri |>.toString
|
||||||
fileMap := default
|
fileMap := default
|
||||||
|
|
||||||
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
|
def compileHeader (m : DocumentMeta) (hOut : FS.Stream) (opts : Options) (hasWidgets : Bool)
|
||||||
(gameDir : String) (module : Name):
|
(gameDir : String) (module : Name):
|
||||||
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
|
IO (Syntax × Task (Except Error (Snapshot × SearchPath))) := do
|
||||||
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
|
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
|
||||||
let out ← IO.Process.output
|
let out ← IO.Process.output
|
||||||
{ cwd := gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
{ cwd := gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
|
||||||
if out.exitCode != 0 then
|
if out.exitCode != 0 then
|
||||||
throwServerError s!"Error while running Lake: {out.stderr}"
|
throwServerError s!"Error while running Lake: {out.stderr}"
|
||||||
|
|
||||||
-- Make the paths relative to the current directory
|
-- Make the paths relative to the current directory
|
||||||
let paths : List System.FilePath := System.SearchPath.parse out.stdout.trim
|
let paths : List System.FilePath := System.SearchPath.parse out.stdout.trim
|
||||||
let currentDir ← IO.currentDir
|
let currentDir ← IO.currentDir
|
||||||
let paths := paths.map fun p => currentDir / (gameDir : System.FilePath) / p
|
let paths := paths.map fun p => currentDir / (gameDir : System.FilePath) / p
|
||||||
|
|
||||||
-- Set the search path
|
-- Set the search path
|
||||||
Lean.searchPathRef.set paths
|
Lean.searchPathRef.set paths
|
||||||
|
|
||||||
let env ← importModules' #[{ module := `Init : Import }, { module := module : Import }]
|
let env ← importModules' #[{ module := `Init : Import }, { module := module : Import }]
|
||||||
|
|
||||||
-- use empty header
|
-- use empty header
|
||||||
let (headerStx, headerParserState, msgLog) ← Parser.parseHeader
|
let (headerStx, headerParserState, msgLog) ← Parser.parseHeader
|
||||||
{m.mkInputContext with
|
{m.mkInputContext with
|
||||||
input := ""
|
input := ""
|
||||||
fileMap := FileMap.ofString ""}
|
fileMap := FileMap.ofString ""}
|
||||||
(headerStx, ·) <$> EIO.asTask do
|
(headerStx, ·) <$> EIO.asTask do
|
||||||
let mut srcSearchPath : SearchPath := paths --← initSrcSearchPath (← getBuildDir)
|
let mut srcSearchPath : SearchPath := paths --← initSrcSearchPath (← getBuildDir)
|
||||||
let headerEnv := env
|
let headerEnv := env
|
||||||
let mut headerEnv := headerEnv
|
let mut headerEnv := headerEnv
|
||||||
try
|
try
|
||||||
if let some path := System.Uri.fileUriToPath? m.uri then
|
if let some path := System.Uri.fileUriToPath? m.uri then
|
||||||
headerEnv := headerEnv.setMainModule (← moduleNameOfFileName path none)
|
headerEnv := headerEnv.setMainModule (← moduleNameOfFileName path none)
|
||||||
catch _ => pure ()
|
catch _ => pure ()
|
||||||
let cmdState := Elab.Command.mkState headerEnv {} opts
|
let cmdState := Elab.Command.mkState headerEnv {} opts
|
||||||
let cmdState := { cmdState with infoState := {
|
let cmdState := { cmdState with infoState := {
|
||||||
enabled := true
|
enabled := true
|
||||||
trees := #[Elab.InfoTree.context ({
|
trees := #[Elab.InfoTree.context ({
|
||||||
env := headerEnv
|
env := headerEnv
|
||||||
fileMap := m.text
|
fileMap := m.text
|
||||||
ngen := { namePrefix := `_worker }
|
ngen := { namePrefix := `_worker }
|
||||||
}) (Elab.InfoTree.node
|
}) (Elab.InfoTree.node
|
||||||
(Elab.Info.ofCommandInfo { elaborator := `header, stx := headerStx })
|
(Elab.Info.ofCommandInfo { elaborator := `header, stx := headerStx })
|
||||||
(headerStx[1].getArgs.toList.map (fun importStx =>
|
(headerStx[1].getArgs.toList.map (fun importStx =>
|
||||||
Elab.InfoTree.node (Elab.Info.ofCommandInfo {
|
Elab.InfoTree.node (Elab.Info.ofCommandInfo {
|
||||||
elaborator := `import
|
elaborator := `import
|
||||||
stx := importStx
|
stx := importStx
|
||||||
}) #[].toPArray'
|
}) #[].toPArray'
|
||||||
)).toPArray'
|
)).toPArray'
|
||||||
)].toPArray'
|
)].toPArray'
|
||||||
}}
|
}}
|
||||||
let headerSnap := {
|
let headerSnap := {
|
||||||
beginPos := 0
|
beginPos := 0
|
||||||
stx := headerStx
|
stx := headerStx
|
||||||
mpState := {}
|
mpState := {}
|
||||||
cmdState := cmdState
|
cmdState := cmdState
|
||||||
interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets)
|
interactiveDiags := ← cmdState.messages.msgs.mapM (Widget.msgToInteractiveDiagnostic m.text · hasWidgets)
|
||||||
tacticCache := (← IO.mkRef {})
|
tacticCache := (← IO.mkRef {})
|
||||||
}
|
}
|
||||||
publishDiagnostics m headerSnap.diagnostics.toArray hOut
|
publishDiagnostics m headerSnap.diagnostics.toArray hOut
|
||||||
return (headerSnap, srcSearchPath)
|
return (headerSnap, srcSearchPath)
|
||||||
|
|
||||||
def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
|
def initializeWorker (meta : DocumentMeta) (i o e : FS.Stream) (initParams : InitializeParams) (opts : Options)
|
||||||
(gameDir : String) (gameWorkerState : GameWorkerState) : IO (WorkerContext × WorkerState) := do
|
(gameDir : String) (gameWorkerState : WorkerState) : IO (WorkerContext × Server.FileWorker.WorkerState) := do
|
||||||
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
|
let clientHasWidgets := initParams.initializationOptions?.bind (·.hasWidgets?) |>.getD false
|
||||||
|
|
||||||
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
|
let (headerStx, headerTask) ← compileHeader meta o opts (hasWidgets := clientHasWidgets)
|
||||||
gameDir gameWorkerState.levelInfo.module
|
gameDir gameWorkerState.levelInfo.module
|
||||||
let cancelTk ← CancelToken.new
|
let cancelTk ← CancelToken.new
|
||||||
let ctx :=
|
let ctx :=
|
||||||
{ hIn := i
|
{ hIn := i
|
||||||
hOut := o
|
hOut := o
|
||||||
hLog := e
|
hLog := e
|
||||||
headerTask
|
headerTask
|
||||||
initParams
|
initParams
|
||||||
clientHasWidgets
|
clientHasWidgets
|
||||||
}
|
}
|
||||||
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
|
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
|
||||||
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0)
|
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk gameWorkerState ctx (startAfterMs := 0)
|
||||||
| Except.error e => throw (e : ElabTaskError))
|
| Except.error e => throw (e : ElabTaskError))
|
||||||
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
|
let doc : EditableDocument := { meta, cmdSnaps := AsyncList.delayed cmdSnaps, cancelTk }
|
||||||
return (ctx,
|
return (ctx,
|
||||||
{ doc := doc
|
{ doc := doc
|
||||||
initHeaderStx := headerStx
|
initHeaderStx := headerStx
|
||||||
pendingRequests := RBMap.empty
|
currHeaderStx := headerStx
|
||||||
rpcSessions := RBMap.empty
|
importCachingTask? := none
|
||||||
})
|
pendingRequests := RBMap.empty
|
||||||
|
rpcSessions := RBMap.empty
|
||||||
|
})
|
||||||
|
|
||||||
end Initialization
|
end Initialization
|
||||||
|
|
||||||
section NotificationHandling
|
section NotificationHandling
|
||||||
|
|
||||||
def handleDidChange (p : DidChangeTextDocumentParams) : GameWorkerM Unit := do
|
def handleDidChange (p : DidChangeTextDocumentParams) : WorkerM Unit := do
|
||||||
let docId := p.textDocument
|
let docId := p.textDocument
|
||||||
let changes := p.contentChanges
|
let changes := p.contentChanges
|
||||||
let oldDoc := (← StateT.lift get).doc
|
let oldDoc := (← StateT.lift get).doc
|
||||||
let some newVersion ← pure docId.version?
|
let some newVersion ← pure docId.version?
|
||||||
| throwServerError "Expected version number"
|
| throwServerError "Expected version number"
|
||||||
if newVersion ≤ oldDoc.meta.version then
|
if newVersion ≤ oldDoc.meta.version then
|
||||||
-- TODO(WN): This happens on restart sometimes.
|
-- TODO(WN): This happens on restart sometimes.
|
||||||
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
|
IO.eprintln s!"Got outdated version number: {newVersion} ≤ {oldDoc.meta.version}"
|
||||||
else if ¬ changes.isEmpty then
|
else if ¬ changes.isEmpty then
|
||||||
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
let newDocText := foldDocumentChanges changes oldDoc.meta.text
|
||||||
updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩
|
updateDocument ⟨docId.uri, newVersion, newDocText, .always⟩
|
||||||
|
|
||||||
end NotificationHandling
|
end NotificationHandling
|
||||||
|
|
||||||
section MessageHandling
|
section MessageHandling
|
||||||
|
|
||||||
def handleNotification (method : String) (params : Json) : GameWorkerM Unit := do
|
|
||||||
let handle := fun paramType [FromJson paramType] (handler : paramType → GameWorkerM Unit) =>
|
/--
|
||||||
(StateT.lift <| parseParams paramType params) >>= handler
|
Modified notification handler.
|
||||||
match method with
|
|
||||||
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
|
Compare to `Lean.Server.FileWorker.handleNotification`.
|
||||||
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
|
We use the modified `WorkerM` and use our custom `handleDidChange`.
|
||||||
| "$/setTrace" => pure ()
|
|
||||||
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
|
-/
|
||||||
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
|
def handleNotification (method : String) (params : Json) : WorkerM Unit := do
|
||||||
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
let handle := fun paramType [FromJson paramType] (handler : paramType → WorkerM Unit) =>
|
||||||
|
(StateT.lift <| parseParams paramType params) >>= handler
|
||||||
|
match method with
|
||||||
|
-- Modified `textDocument/didChange`, using a custom `handleDidChange`
|
||||||
|
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
|
||||||
|
-- unmodified
|
||||||
|
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
|
||||||
|
-- unmodified
|
||||||
|
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
|
||||||
|
-- unmodified
|
||||||
|
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
|
||||||
|
-- New. TODO: What is this for?
|
||||||
|
| "$/setTrace" => pure ()
|
||||||
|
| _ => throwServerError s!"Got unsupported notification method: {method}"
|
||||||
|
|
||||||
end MessageHandling
|
end MessageHandling
|
||||||
|
|
||||||
section MainLoop
|
section MainLoop
|
||||||
partial def mainLoop : GameWorkerM Unit := do
|
|
||||||
let ctx ← read
|
|
||||||
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
|
|
||||||
if (← hasFinished task) then
|
|
||||||
/- Handler tasks are constructed so that the only possible errors here
|
|
||||||
are failures of writing a response into the stream. -/
|
|
||||||
if let Except.error e := task.get then
|
|
||||||
throwServerError s!"Failed responding to request {id}: {e}"
|
|
||||||
pure <| acc.erase id
|
|
||||||
else pure acc
|
|
||||||
let pendingRequests ← st.pendingRequests.foldM (fun acc id task => filterFinishedTasks acc id task) st.pendingRequests
|
|
||||||
st := { st with pendingRequests }
|
|
||||||
|
|
||||||
-- Opportunistically (i.e. when we wake up on messages) check if any RPC session has expired.
|
/--
|
||||||
for (id, seshRef) in st.rpcSessions do
|
Erase finished tasks if there are no errors.
|
||||||
let sesh ← seshRef.get
|
-/
|
||||||
if (← sesh.hasExpired) then
|
private def filterFinishedTasks (acc : PendingRequestMap) (id : RequestID)
|
||||||
st := { st with rpcSessions := st.rpcSessions.erase id }
|
(task : Task (Except IO.Error Unit)) : IO PendingRequestMap := do
|
||||||
|
if (← hasFinished task) then
|
||||||
|
/- Handler tasks are constructed so that the only possible errors here
|
||||||
|
are failures of writing a response into the stream. -/
|
||||||
|
if let Except.error e := task.get then
|
||||||
|
throwServerError s!"Failed responding to request {id}: {e}"
|
||||||
|
pure <| acc.erase id
|
||||||
|
else pure acc
|
||||||
|
|
||||||
|
/--
|
||||||
|
The main-loop.
|
||||||
|
-/
|
||||||
|
partial def mainLoop : WorkerM Unit := do
|
||||||
|
let ctx ← read
|
||||||
|
let mut st ← StateT.lift get
|
||||||
|
let msg ← ctx.hIn.readLspMessage
|
||||||
|
let pendingRequests ← st.pendingRequests.foldM (fun acc id task =>
|
||||||
|
filterFinishedTasks acc id task) st.pendingRequests
|
||||||
|
st := { st with pendingRequests }
|
||||||
|
-- Opportunistically (i.e. when we wake up on messages) check if any RPC session has expired.
|
||||||
|
for (id, seshRef) in st.rpcSessions do
|
||||||
|
let sesh ← seshRef.get
|
||||||
|
if (← sesh.hasExpired) then
|
||||||
|
st := { st with rpcSessions := st.rpcSessions.erase id }
|
||||||
|
|
||||||
|
set st
|
||||||
|
|
||||||
|
-- Process the RPC-message and restart main-loop.
|
||||||
|
match msg with
|
||||||
|
| Message.request id "shutdown" none =>
|
||||||
|
ctx.hOut.writeLspResponse ⟨id, Json.null⟩
|
||||||
|
mainLoop
|
||||||
|
| Message.request id method (some params) =>
|
||||||
|
-- Requests are handled by the unmodified lean server.
|
||||||
|
handleRequest id method (toJson params)
|
||||||
|
mainLoop
|
||||||
|
| Message.notification "exit" none =>
|
||||||
|
let doc := st.doc
|
||||||
|
doc.cancelTk.set
|
||||||
|
return ()
|
||||||
|
| Message.notification method (some params) =>
|
||||||
|
-- Custom notification handler
|
||||||
|
handleNotification method (toJson params)
|
||||||
|
mainLoop
|
||||||
|
| _ =>
|
||||||
|
throwServerError s!"Got invalid JSON-RPC message: {toJson msg}"
|
||||||
|
|
||||||
set st
|
|
||||||
match msg with
|
|
||||||
| Message.request id method (some params) =>
|
|
||||||
handleRequest id method (toJson params)
|
|
||||||
mainLoop
|
|
||||||
| Message.notification "exit" none =>
|
|
||||||
let doc := st.doc
|
|
||||||
doc.cancelTk.set
|
|
||||||
return ()
|
|
||||||
| Message.request id "shutdown" none =>
|
|
||||||
ctx.hOut.writeLspResponse ⟨id, Json.null⟩
|
|
||||||
mainLoop
|
|
||||||
| Message.notification method (some params) =>
|
|
||||||
handleNotification method (toJson params)
|
|
||||||
mainLoop
|
|
||||||
| _ => throwServerError s!"Got invalid JSON-RPC message: {toJson msg}"
|
|
||||||
end MainLoop
|
end MainLoop
|
||||||
|
|
||||||
|
|
||||||
def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do
|
def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : IO UInt32 := do
|
||||||
let i ← maybeTee "fwIn.txt" false i
|
let i ← maybeTee "fwIn.txt" false i
|
||||||
let o ← maybeTee "fwOut.txt" true o
|
let o ← maybeTee "fwOut.txt" true o
|
||||||
@@ -606,12 +682,13 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
|
|||||||
let levelInfo ← loadLevelData gameDir levelId.world levelId.level
|
let levelInfo ← loadLevelData gameDir levelId.world levelId.level
|
||||||
let some initializationOptions := initRequest.param.initializationOptions?
|
let some initializationOptions := initRequest.param.initializationOptions?
|
||||||
| throwServerError "no initialization options found"
|
| throwServerError "no initialization options found"
|
||||||
let gameWorkerState : GameWorkerState:= {
|
let gameWorkerState : WorkerState := {
|
||||||
inventory := initializationOptions.inventory
|
inventory := initializationOptions.inventory
|
||||||
difficulty := initializationOptions.difficulty
|
difficulty := initializationOptions.difficulty
|
||||||
levelInfo
|
levelInfo
|
||||||
}
|
}
|
||||||
let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState
|
let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState
|
||||||
|
-- Run the main loop
|
||||||
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
|
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
|
||||||
StateT.run (s := gameWorkerState) <| (mainLoop)
|
StateT.run (s := gameWorkerState) <| (mainLoop)
|
||||||
return (0 : UInt32)
|
return (0 : UInt32)
|
||||||
@@ -623,6 +700,11 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) (gameDir : String) : I
|
|||||||
message := e.toString }] o
|
message := e.toString }] o
|
||||||
return (1 : UInt32)
|
return (1 : UInt32)
|
||||||
|
|
||||||
|
/--
|
||||||
|
The main function. Simply wrapping `initAndRunWorker`.
|
||||||
|
|
||||||
|
TODO: The first arg `args[0]` is always expected to be `--server`. We could drop this completely.
|
||||||
|
-/
|
||||||
def workerMain (opts : Options) (args : List String): IO UInt32 := do
|
def workerMain (opts : Options) (args : List String): IO UInt32 := do
|
||||||
let i ← IO.getStdin
|
let i ← IO.getStdin
|
||||||
let o ← IO.getStdout
|
let o ← IO.getStdout
|
||||||
@@ -630,8 +712,9 @@ def workerMain (opts : Options) (args : List String): IO UInt32 := do
|
|||||||
try
|
try
|
||||||
let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir"
|
let some gameDir := args[1]? | throwServerError "Expected second argument: gameDir"
|
||||||
let exitCode ← initAndRunWorker i o e opts gameDir
|
let exitCode ← initAndRunWorker i o e opts gameDir
|
||||||
-- HACK: all `Task`s are currently "foreground", i.e. we join on them on main thread exit, but we definitely don't
|
-- HACK: all `Task`s are currently "foreground", i.e. we join on them on main thread exit,
|
||||||
-- want to do that in the case of the worker processes, which can produce non-terminating tasks evaluating user code
|
-- but we definitely don't want to do that in the case of the worker processes,
|
||||||
|
-- which can produce non-terminating tasks evaluating user code.
|
||||||
o.flush
|
o.flush
|
||||||
e.flush
|
e.flush
|
||||||
IO.Process.exit exitCode.toUInt8
|
IO.Process.exit exitCode.toUInt8
|
||||||
@@ -639,4 +722,4 @@ def workerMain (opts : Options) (args : List String): IO UInt32 := do
|
|||||||
e.putStrLn s!"worker initialization error: {err}"
|
e.putStrLn s!"worker initialization error: {err}"
|
||||||
return (1 : UInt32)
|
return (1 : UInt32)
|
||||||
|
|
||||||
end MyServer.FileWorker
|
end GameServer.FileWorker
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
[{"url": "https://github.com/leanprover/std4.git",
|
[{"url": "https://github.com/leanprover/std4.git",
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"subDir": null,
|
"subDir": null,
|
||||||
"rev": "2e4a3586a8f16713f16b2d2b3af3d8e65f3af087",
|
"rev": "08ec2584b1892869e3a5f4122b029989bcb4ca79",
|
||||||
"name": "std",
|
"name": "std",
|
||||||
"manifestFile": "lake-manifest.json",
|
"manifestFile": "lake-manifest.json",
|
||||||
"inputRev": "v4.3.0",
|
"inputRev": "v4.5.0",
|
||||||
"inherited": false,
|
"inherited": false,
|
||||||
"configFile": "lakefile.lean"}],
|
"configFile": "lakefile.lean"}],
|
||||||
"name": "GameServer",
|
"name": "GameServer",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
leanprover/lean4:v4.3.0
|
leanprover/lean4:v4.5.0
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cd server
|
|
||||||
|
|
||||||
mkdir -p .lake/toolchains
|
|
||||||
if [ ! -f .lake/toolchains/lean-4.3.0-rc2-linux_wasm32.tar.zst ]
|
|
||||||
then
|
|
||||||
wget -P .lake/toolchains https://github.com/leanprover/lean4/releases/download/v4.3.0-rc2/lean-4.3.0-rc2-linux_wasm32.tar.zst
|
|
||||||
tar --use-compress-program=unzstd -xvf .lake/toolchains/lean-4.3.0-rc2-linux_wasm32.tar.zst -C .lake/toolchains
|
|
||||||
fi
|
|
||||||
if [ ! -f .lake/toolchains/lean-4.3.0-rc2-linux_x86.tar.zst ]
|
|
||||||
then
|
|
||||||
wget -P .lake/toolchains https://github.com/leanprover/lean4/releases/download/v4.3.0-rc2/lean-4.3.0-rc2-linux_x86.tar.zst
|
|
||||||
tar --use-compress-program=unzstd -xvf .lake/toolchains/lean-4.3.0-rc2-linux_x86.tar.zst -C .lake/toolchains
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Linking will fail, but that's ok. We only need the c files.
|
|
||||||
.lake/toolchains/lean-4.3.0-rc2-linux_x86/bin/lake build -f=lakefile32.lean
|
|
||||||
|
|
||||||
|
|
||||||
lake build
|
|
||||||
|
|
||||||
|
|
||||||
OUT_DIR=../client/public
|
|
||||||
LEAN_SYSROOT=.lake/toolchains/lean-4.3.0-rc2-linux_wasm32
|
|
||||||
LEAN_LIBDIR=$LEAN_SYSROOT/lib/lean
|
|
||||||
|
|
||||||
emcc -o $OUT_DIR/server.js main.c -I $LEAN_SYSROOT/include -L $LEAN_LIBDIR .lake/build/ir/GameServer/*.c -lInit -lLean -lleancpp -lleanrt \
|
|
||||||
-sFORCE_FILESYSTEM -lnodefs.js -s EXIT_RUNTIME=0 -s MAIN_MODULE=1 -s LINKABLE=1 -s EXPORT_ALL=1 -s ALLOW_MEMORY_GROWTH=1 -fwasm-exceptions -pthread -flto \
|
|
||||||
-sPTHREAD_POOL_SIZE_STRICT=2 \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Init"@/lib/Init \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Init.olean"@/lib/Init.olean \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Init.ilean"@/lib/Init.ilean \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean"@/lib/Lean \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean.olean"@/lib/Lean.olean \
|
|
||||||
--preload-file "${LEAN_SYSROOT}/lib/lean/Lean.ilean"@/lib/Lean.ilean \
|
|
||||||
--preload-file "./.lake/build32/lib"@/gamelib
|
|
||||||
Reference in New Issue
Block a user