Merge branch 'v4.4.0'

v4.5.0-bump v4.4.0
Jon Eugster 3 years ago
commit e579071a3b

1
.gitignore vendored

@ -1,5 +1,4 @@
node_modules node_modules
games/
client/dist client/dist
games/ games/
server/.lake server/.lake

@ -9,25 +9,37 @@ 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 { MobileContext } from './components/infoview/context';
import { useWindowDimensions } from './window_width'; import { useMobile } from './hooks';
import { connection } from './connection'; 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 {width, height} = useWindowDimensions()
const [mobile, setMobile] = React.useState(width < 800)
React.useEffect(() => { const automaticallyAdjustLayout = () => {
connection.startLeanClient(gameId); const {width} = getWindowDimensions()
}, [gameId]) 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}}> <MobileContext.Provider value={{mobile, setMobile, lockMobile, setLockMobile}}>
<Outlet /> <Outlet />
</MobileContext.Provider> </MobileContext.Provider>
</GameIdContext.Provider> </GameIdContext.Provider>

@ -5,7 +5,7 @@ import * as React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome, import { faDownload, faUpload, faEraser, faBook, faBookOpen, faGlobe, faHome,
faArrowRight, faArrowLeft, faXmark, faBars, faCode, faArrowRight, faArrowLeft, faXmark, faBars, faCode,
faCircleInfo, faTerminal } from '@fortawesome/free-solid-svg-icons' faCircleInfo, faTerminal, faMobileScreenButton, faDesktop, faGear } from '@fortawesome/free-solid-svg-icons'
import { GameIdContext } from "../app" import { GameIdContext } from "../app"
import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context" import { InputModeContext, MobileContext, WorldLevelIdContext } from "./infoview/context"
import { GameInfo, useGetGameInfoQuery } from '../state/api' import { GameInfo, useGetGameInfoQuery } from '../state/api'
@ -150,22 +150,23 @@ function InventoryButton({pageNumber, setPageNumber}) {
} }
/** the navigation bar on the welcome page */ /** the navigation bar on the welcome page */
export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpressum, toggleEraseMenu, toggleUploadMenu, toggleInfo} : { export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpressum, toggleEraseMenu, toggleUploadMenu, toggleInfo, togglePreferencesPopup} : {
pageNumber: number, pageNumber: number,
setPageNumber: any, setPageNumber: any,
gameInfo: GameInfo, gameInfo: GameInfo,
toggleImpressum: any, toggleImpressum: any,
toggleEraseMenu: any, toggleEraseMenu: any,
toggleUploadMenu: any, toggleUploadMenu: any,
toggleInfo: any toggleInfo: any,
togglePreferencesPopup: () => void;
}) { }) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const gameProgress = useAppSelector(selectProgress(gameId)) const gameProgress = useAppSelector(selectProgress(gameId))
const {mobile} = React.useContext(MobileContext) const {mobile, setMobile} = React.useContext(MobileContext)
const [navOpen, setNavOpen] = React.useState(false) const [navOpen, setNavOpen] = React.useState(false)
return <div className="app-bar"> return <div className="app-bar">
<div> <div className='app-bar-left'>
<Button inverted="false" title="back to games selection" to="/"> <Button inverted="false" title="back to games selection" to="/">
<FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} /> <FontAwesomeIcon icon={faArrowLeft} />&nbsp;<FontAwesomeIcon icon={faGlobe} />
</Button> </Button>
@ -194,6 +195,9 @@ export function WelcomeAppBar({pageNumber, setPageNumber, gameInfo, toggleImpres
<Button title="Impressum, privacy policy" inverted="true" to="" onClick={() => {toggleImpressum(); setNavOpen(false)}}> <Button title="Impressum, privacy policy" inverted="true" to="" onClick={() => {toggleImpressum(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum <FontAwesomeIcon icon={faCircleInfo} />&nbsp;Impressum
</Button> </Button>
<Button title="Preferences" inverted="true" to="" onClick={() => {togglePreferencesPopup(); setNavOpen(false)}}>
<FontAwesomeIcon icon={faGear} />&nbsp;Preferences
</Button>
</div> </div>
</div> </div>
} }
@ -237,7 +241,7 @@ export function LevelAppBar({isLoading, levelTitle, toggleImpressum, pageNumber=
</> : </> :
<> <>
{/* DESKTOP VERSION */} {/* DESKTOP VERSION */}
<div> <div className='app-bar-left'>
<HomeButton isDropdown={false} /> <HomeButton isDropdown={false} />
<span className="app-bar-title">{worldTitle && `World: ${worldTitle}`}</span> <span className="app-bar-title">{worldTitle && `World: ${worldTitle}`}</span>
</div> </div>

@ -1,6 +1,7 @@
import { GameHint } from "./infoview/rpc_api"; import { GameHint } from "./infoview/rpc_api";
import * as React from 'react'; import * as React from 'react';
import Markdown from './markdown'; import Markdown from './markdown';
import { ProofStep } from "./infoview/context";
export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) { export function Hint({hint, step, selected, toggleSelection, lastLevel} : {hint: GameHint, step: number, selected: number, toggleSelection: any, lastLevel?: boolean}) {
return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}> return <div className={`message information step-${step}` + (step == selected ? ' selected' : '') + (lastLevel ? ' recent' : '')} onClick={toggleSelection}>
@ -43,3 +44,24 @@ export function DeletedHints({hints} : {hints: GameHint[]}) {
{hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)} {hiddenHints.map((hint, i) => <DeletedHint key={`deleted-hidden-hint-${i}`} hint={hint}/>)}
</> </>
} }
/** Filter hints to not show consequtive identical hints twice.
*
* This function takes a `ProofStep[]` and extracts the hints in form of an
* element of type `GameHint[][]` where it removes hints that are identical to hints
* appearing in the previous step. Hidden hints are not filtered.
*
* This effectively means we prevent consequtive identical hints from being shown.
*/
export function filterHints(proof: ProofStep[]): GameHint[][] {
return proof.map((step, i) => {
if (i == 0){
return step.hints
} else {
// TODO: Writing all fields explicitely is somewhat fragile to changes, is there a
// good way to shallow-compare objects?
return step.hints.filter((hint) => hint.hidden ||
(proof[i-1].hints.find((x) => (x.text == hint.text && x.hidden == hint.hidden)) === undefined))
}
})
}

@ -62,12 +62,18 @@ export const ProofStateContext = React.createContext<{
setProofState: () => {}, setProofState: () => {},
}) })
export const MobileContext = React.createContext<{ export interface IMobileContext {
mobile : boolean, mobile : boolean,
setMobile: React.Dispatch<React.SetStateAction<Boolean>>, setMobile: React.Dispatch<React.SetStateAction<Boolean>>,
}>({ lockMobile: boolean,
mobile : false, setLockMobile: React.Dispatch<React.SetStateAction<Boolean>>,
}
export const MobileContext = React.createContext<IMobileContext>({
mobile: false,
setMobile: () => {}, setMobile: () => {},
lockMobile: false,
setLockMobile: () => {}
}) })
export const WorldLevelIdContext = React.createContext<{ export const WorldLevelIdContext = React.createContext<{

@ -34,7 +34,7 @@ import { Button } from '../button';
import { CircularProgress } from '@mui/material'; import { CircularProgress } from '@mui/material';
import { GameHint } from './rpc_api'; import { GameHint } from './rpc_api';
import { store } from '../../state/store'; import { store } from '../../state/store';
import { Hints } from '../hints'; import { Hints, filterHints } from '../hints';
/** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is /** Wrapper for the two editors. It is important that the `div` with `codeViewRef` is
* always present, or the monaco editor cannot start. * always present, or the monaco editor cannot start.
@ -367,9 +367,9 @@ export function TypewriterInterface({props}) {
function deleteProof(line: number) { function deleteProof(line: number) {
return (ev) => { return (ev) => {
let deletedChat: Array<GameHint> = [] let deletedChat: Array<GameHint> = []
proof.slice(line).map((step, i) => { filterHints(proof).slice(line).map((hintsAtStep, i) => {
// Only add these hidden hints to the deletion stack which were visible // Only add these hidden hints to the deletion stack which were visible
deletedChat = [...deletedChat, ...step.hints.filter(hint => (!hint.hidden || showHelp.has(line + i)))] deletedChat = [...deletedChat, ...hintsAtStep.filter(hint => (!hint.hidden || showHelp.has(line + i)))]
}) })
setDeletedChat(deletedChat) setDeletedChat(deletedChat)
@ -493,18 +493,20 @@ export function TypewriterInterface({props}) {
<Markdown>{props.data?.introduction}</Markdown> <Markdown>{props.data?.introduction}</Markdown>
</div> </div>
} }
{mobile && <> {mobile &&
<Hints key={`hints-${i}`} <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i} hints={step.hints} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelectStep(i)}/> selected={selectedStep} toggleSelection={toggleSelectStep(i)}/>
{i == proof.length - 1 && hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button className="btn btn-help" to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
</>
} }
<GoalsTabs proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/> <GoalsTabs proofStep={step} last={i == proof.length - (lastStepErrors ? 2 : 1)} onClick={toggleSelectStep(i)} onGoalChange={i == proof.length - 1 - withErr ? (n) => setDisableInput(n > 0) : (n) => {}}/>
{mobile && i == proof.length - 1 &&
hasHiddenHints(proof.length - 1) && !showHelp.has(k - withErr) &&
<Button className="btn btn-help" to="" onClick={activateHiddenHints}>
Show more help!
</Button>
}
{/* Show a message that there are no goals left */} {/* Show a message that there are no goals left */}
{!step.goals.length && ( {!step.goals.length && (
<div className="message information"> <div className="message information">
@ -521,7 +523,7 @@ export function TypewriterInterface({props}) {
} }
})} })}
{mobile && completed && {mobile && completed &&
<div className="button-row"> <div className="button-row mobile">
{props.level >= props.worldSize ? {props.level >= props.worldSize ?
<Button to={`/${gameId}`}> <Button to={`/${gameId}`}>
<FontAwesomeIcon icon={faHome} />&nbsp;Leave World <FontAwesomeIcon icon={faHome} />&nbsp;Leave World

@ -2,7 +2,8 @@ import * as React from 'react';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import '../css/inventory.css' import '../css/inventory.css'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faLock, faBan } from '@fortawesome/free-solid-svg-icons' import { faLock, faBan, faCheck } from '@fortawesome/free-solid-svg-icons'
import { faClipboard } from '@fortawesome/free-regular-svg-icons'
import { GameIdContext } from '../app'; import { GameIdContext } from '../app';
import Markdown from './markdown'; import Markdown from './markdown';
import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api'; import { useLoadDocQuery, InventoryTile, LevelInfo, InventoryOverview, useLoadInventoryOverviewQuery } from '../state/api';
@ -10,10 +11,12 @@ import { selectDifficulty, selectInventory } from '../state/progress';
import { store } from '../state/store'; import { store } from '../state/store';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
export function Inventory({levelInfo, openDoc, enableAll=false} : export function Inventory({levelInfo, openDoc, lemmaTab, setLemmaTab, enableAll=false} :
{ {
levelInfo: LevelInfo|InventoryOverview, levelInfo: LevelInfo|InventoryOverview,
openDoc: (props: {name: string, type: string}) => void, openDoc: (props: {name: string, type: string}) => void,
lemmaTab: any,
setLemmaTab: any,
enableAll?: boolean, enableAll?: boolean,
}) { }) {
@ -31,19 +34,20 @@ export function Inventory({levelInfo, openDoc, enableAll=false} :
} }
<h2>Theorems</h2> <h2>Theorems</h2>
{levelInfo?.lemmas && {levelInfo?.lemmas &&
<InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} defaultTab={levelInfo?.lemmaTab} level={levelInfo} enableAll={enableAll}/> <InventoryList items={levelInfo?.lemmas} docType="Lemma" openDoc={openDoc} level={levelInfo} enableAll={enableAll} tab={lemmaTab} setTab={setLemmaTab}/>
} }
</div> </div>
) )
} }
function InventoryList({items, docType, openDoc, defaultTab=null, level=undefined, enableAll=false} : function InventoryList({items, docType, openDoc, tab=null, setTab=undefined, level=undefined, enableAll=false} :
{ {
items: InventoryTile[], items: InventoryTile[],
docType: string, docType: string,
openDoc(props: {name: string, type: string}): void, openDoc(props: {name: string, type: string}): void,
defaultTab? : string, tab?: any,
level? : LevelInfo|InventoryOverview, setTab?: any,
level?: LevelInfo|InventoryOverview,
enableAll?: boolean, enableAll?: boolean,
}) { }) {
// TODO: `level` is only used in the `useEffect` below to check if a new level has // TODO: `level` is only used in the `useEffect` below to check if a new level has
@ -59,8 +63,6 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
} }
const categories = Array.from(categorySet).sort() const categories = Array.from(categorySet).sort()
const [tab, setTab] = useState(defaultTab)
// Add inventory items from local store as unlocked. // Add inventory items from local store as unlocked.
// Items are unlocked if they are in the local store, or if the server says they should be // Items are unlocked if they are in the local store, or if the server says they should be
// given the dependency graph. (OR-connection) (TODO: maybe add different logic for different // given the dependency graph. (OR-connection) (TODO: maybe add different logic for different
@ -68,13 +70,6 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
let inv: string[] = selectInventory(gameId)(store.getState()) let inv: string[] = selectInventory(gameId)(store.getState())
let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile) let modifiedItems : InventoryTile[] = items.map(tile => inv.includes(tile.name) ? {...tile, locked: false} : tile)
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (defaultTab) {
setTab(defaultTab)
}}, [level])
return <> return <>
{categories.length > 1 && {categories.length > 1 &&
<div className="tab-bar"> <div className="tab-bar">
@ -89,21 +84,26 @@ function InventoryList({items, docType, openDoc, defaultTab=null, level=undefine
(x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName) (x, y) => +(docType == "Lemma") * (+x.locked - +y.locked || +x.disabled - +y.disabled) || x.displayName.localeCompare(y.displayName)
).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => { ).filter(item => !item.hidden && ((tab ?? categories[0]) == item.category)).map((item, i) => {
return <InventoryItem key={`${item.category}-${item.name}`} return <InventoryItem key={`${item.category}-${item.name}`}
item={item}
showDoc={() => {openDoc({name: item.name, type: docType})}} showDoc={() => {openDoc({name: item.name, type: docType})}}
name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false} name={item.name} displayName={item.displayName} locked={difficulty > 0 ? item.locked : false}
disabled={item.disabled} newly={item.new} enableAll={enableAll}/> disabled={item.disabled} newly={item.new} enableAll={enableAll} />
}) })
} }
</div> </div>
</> </>
} }
function InventoryItem({name, displayName, locked, disabled, newly, showDoc, enableAll=false}) { function InventoryItem({item, name, displayName, locked, disabled, newly, showDoc, enableAll=false}) {
const icon = locked ? <FontAwesomeIcon icon={faLock} /> : const icon = locked ? <FontAwesomeIcon icon={faLock} /> :
disabled ? <FontAwesomeIcon icon={faBan} /> : "" disabled ? <FontAwesomeIcon icon={faBan} /> : item.st
const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : "" const className = locked ? "locked" : disabled ? "disabled" : newly ? "new" : ""
// Note: This is somewhat a hack as the statement of lemmas comes currently in the form
// `Namespace.statement_name (x y : Nat) : some type`
const title = locked ? "Not unlocked yet" : const title = locked ? "Not unlocked yet" :
disabled ? "Not available in this level" : "" disabled ? "Not available in this level" : (item.altTitle ? item.altTitle.substring(item.altTitle.indexOf(' ') + 1) : '')
const [copied, setCopied] = useState(false)
const handleClick = () => { const handleClick = () => {
if (enableAll || !locked) { if (enableAll || !locked) {
@ -111,7 +111,21 @@ function InventoryItem({name, displayName, locked, disabled, newly, showDoc, ena
} }
} }
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>{icon} {displayName}</div> const copyItemName = (ev) => {
navigator.clipboard.writeText(displayName)
setCopied(true)
setInterval(() => {
setCopied(false)
}, 3000);
ev.stopPropagation()
}
return <div className={`item ${className}${enableAll ? ' enabled' : ''}`} onClick={handleClick} title={title}>
{icon} {displayName}
<div className="copy-button" onClick={copyItemName}>
{copied ? <FontAwesomeIcon icon={faCheck} /> : <FontAwesomeIcon icon={faClipboard} />}
</div>
</div>
} }
export function Documentation({name, type, handleClose}) { export function Documentation({name, type, handleClose}) {
@ -131,16 +145,25 @@ export function Documentation({name, type, handleClose}) {
export function InventoryPanel({levelInfo, visible = true}) { export function InventoryPanel({levelInfo, visible = true}) {
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const [lemmaTab, setLemmaTab] = useState(levelInfo?.lemmaTab)
// The inventory is overlayed by the doc entry of a clicked item // The inventory is overlayed by the doc entry of a clicked item
const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null) const [inventoryDoc, setInventoryDoc] = useState<{name: string, type: string}>(null)
// Set `inventoryDoc` to `null` to close the doc // Set `inventoryDoc` to `null` to close the doc
function closeInventoryDoc() {setInventoryDoc(null)} function closeInventoryDoc() {setInventoryDoc(null)}
useEffect(() => {
// If the level specifies `LemmaTab "Nat"`, we switch to this tab on loading.
// `defaultTab` is `null` or `undefined` otherwise, in which case we don't want to switch.
if (levelInfo?.lemmaTab) {
setLemmaTab(levelInfo?.lemmaTab)
}}, [levelInfo])
return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}> return <div className={`column inventory-panel ${visible ? '' : 'hidden'}`}>
{inventoryDoc ? {inventoryDoc ?
<Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/> <Documentation name={inventoryDoc.name} type={inventoryDoc.type} handleClose={closeInventoryDoc}/>
: :
<Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true}/> <Inventory levelInfo={levelInfo} openDoc={setInventoryDoc} enableAll={true} lemmaTab={lemmaTab} setLemmaTab={setLemmaTab}/>
} }
</div> </div>
} }

@ -20,6 +20,7 @@ const flag = {
'French': '🇫🇷', 'French': '🇫🇷',
'German': '🇩🇪', 'German': '🇩🇪',
'Italian': '🇮🇹', 'Italian': '🇮🇹',
'Spanish': '🇪🇸',
} }
function GithubIcon({url='https://github.com'}) { function GithubIcon({url='https://github.com'}) {
@ -131,8 +132,10 @@ function LandingPage() {
// //
let allGames = [ let allGames = [
"leanprover-community/nng4", "leanprover-community/nng4",
"hhu-adam/robo",
"djvelleman/stg4", "djvelleman/stg4",
"hhu-adam/robo"] "miguelmarco/STG4",
]
let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile)) let allTiles = allGames.map((gameId) => (useGetGameInfoQuery({game: `g/${gameId}`}).data?.tile))
return <div className="landing-page"> return <div className="landing-page">
@ -162,38 +165,14 @@ function LandingPage() {
/> />
)) ))
} }
{/* <GameTile
title="Natural Number Game"
gameId="g/hhu-adam/NNG4"
intro="The classical introduction game for Lean."
description="In this game you recreate the natural numbers $\mathbb{N}$ from the Peano axioms,
learning the basics about theorem proving in Lean.
This is a good first introduction to Lean!"
worlds="8"
levels="67"
image={coverNNG}
language="English"
/>
<GameTile
title="Set Theory Game"
gameId="g/djvelleman/STG4"
intro="A game about set theory"
description=""
worlds="5"
levels="30"
language="English"
/>
*/}
</div> </div>
<section> <section>
<div className="wrapper"> <div className="wrapper">
<h2>Development notes</h2> <h2>Development notes</h2>
<p> <p>
As this server runs lean on our university machines, it has a limited capacity. As this server runs lean on our university machines, it has a limited capacity.
Our current estimate is about 55 copies of the NNG or 25 copies of games importing Our current estimate is about 70 simultaneous games.
mathlib. We hope to address this limitation in the future. We hope to address and test this limitation better in the future.
</p> </p>
<p> <p>
Most aspects of the games and the infrastructure are still in development. Feel free to Most aspects of the games and the infrastructure are still in development. Feel free to
@ -207,18 +186,19 @@ This is a good first introduction to Lean!"
<h2>Adding new games</h2> <h2>Adding new games</h2>
<p> <p>
If you are considering writing your own game, you should use If you are considering writing your own game, you should use
the <a target="_blank" href="https://github.com/hhu-adam/NNG4">NNG Github Repo</a> as the <a target="_blank" href="https://github.com/hhu-adam/GameSkeleton">GameSkeleton Github Repo</a> as
a template. a template and read <a target="_blank" href="https://github.com/leanprover-community/lean4game/">How to Create a Game</a>.
</p> </p>
<p> <p>
There is an option to load and run your own games directly on the server, You can directly load your games into the server and play it using
instructions are in the NNG repo. Since this is still in development we'd like to the correct URL. The <a target="_blank" href="https://github.com/leanprover-community/lean4game/">instructions above</a> also
encourage you to contact us for support creating your own game. The documentation is explain the details for how to load your game to the server.
not polished yet.
We'd like to encourage you to contact us if you have any questions.
</p> </p>
<p> <p>
To add games to this main page, you should get in contact as Featured games on this page are added manually.
games will need to be added manually. Please get in contact and we-ll happily add yours.
</p> </p>
</div> </div>
</section> </section>
@ -236,9 +216,6 @@ This is a good first introduction to Lean!"
<a className="link" onClick={openImpressum}>Impressum</a> <a className="link" onClick={openImpressum}>Impressum</a>
{impressum? <PrivacyPolicyPopup handleClose={closeImpressum} />: null} {impressum? <PrivacyPolicyPopup handleClose={closeImpressum} />: null}
</footer> </footer>
{/* <PrivacyPolicy/> */}
</div> </div>
} }

@ -18,7 +18,6 @@ import { EditorConnection, EditorEvents } from '../../../node_modules/lean4-info
import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event' import { EventEmitter } from '../../../node_modules/lean4-infoview/src/infoview/event'
import { GameIdContext } from '../app' import { GameIdContext } from '../app'
import { ConnectionContext, connection, useLeanClient } from '../connection'
import { useAppDispatch, useAppSelector } from '../hooks' import { useAppDispatch, useAppSelector } from '../hooks'
import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api' import { useGetGameInfoQuery, useLoadInventoryOverviewQuery, useLoadLevelQuery } from '../state/api'
import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited, import { changedSelection, codeEdited, selectCode, selectSelections, selectCompleted, helpEdited,
@ -32,7 +31,7 @@ import { DeletedChatContext, InputModeContext, MobileContext, MonacoEditorContex
ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context' ProofContext, ProofStep, SelectionContext, WorldLevelIdContext } from './infoview/context'
import { DualEditor } from './infoview/main' import { DualEditor } from './infoview/main'
import { GameHint } from './infoview/rpc_api' import { GameHint } from './infoview/rpc_api'
import { DeletedHints, Hint, Hints } from './hints' import { DeletedHints, Hint, Hints, filterHints } from './hints'
import { PrivacyPolicyPopup } from './popup/privacy_policy' import { PrivacyPolicyPopup } from './popup/privacy_policy'
import path from 'path'; import path from 'path';
@ -44,6 +43,15 @@ import 'lean4web/client/src/editor/infoview.css'
import 'lean4web/client/src/editor/vscode.css' import 'lean4web/client/src/editor/vscode.css'
import '../css/level.css' import '../css/level.css'
import { LevelAppBar } from './app_bar' import { LevelAppBar } from './app_bar'
import { LeanClient } from 'lean4web/client/src/editor/leanclient'
import { DisposingWebSocketMessageReader } from 'lean4web/client/src/reader'
import { WebSocketMessageWriter, toSocket } from 'vscode-ws-jsonrpc'
import { IConnectionProvider } from 'monaco-languageclient'
import { monacoSetup } from 'lean4web/client/src/monacoSetup'
import { onigasmH } from 'onigasm/lib/onigasmH'
monacoSetup()
function Level() { function Level() {
const params = useParams() const params = useParams()
@ -138,19 +146,24 @@ function ChatPanel({lastLevel}) {
let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/) let introText: Array<string> = level?.data?.introduction.split(/\n(\s*\n)+/)
// experimental: Remove all hints that appeared identically in the previous step
// This effectively prevent consequtive hints being shown.
let modifiedHints : GameHint[][] = filterHints(proof)
return <div className="chat-panel"> return <div className="chat-panel">
<div ref={chatRef} className="chat"> <div ref={chatRef} className="chat">
{introText?.filter(t => t.trim()).map(((t, i) => {introText?.filter(t => t.trim()).map(((t, i) =>
// Show the level's intro text as hints, too
<Hint key={`intro-p-${i}`} <Hint key={`intro-p-${i}`}
hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} /> hint={{text: t, hidden: false}} step={0} selected={selectedStep} toggleSelection={toggleSelection(0)} />
))} ))}
{proof.map((step, i) => { {modifiedHints.map((step, i) => {
// It the last step has errors, it will have the same hints // It the last step has errors, it will have the same hints
// as the second-to-last step. Therefore we should not display them. // as the second-to-last step. Therefore we should not display them.
if (!(i == proof.length - 1 && withErr)) { if (!(i == proof.length - 1 && withErr)) {
// TODO: Should not use index as key. // TODO: Should not use index as key.
return <Hints key={`hints-${i}`} return <Hints key={`hints-${i}`}
hints={step.hints} showHidden={showHelp.has(i)} step={i} hints={step} showHidden={showHelp.has(i)} step={i}
selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.length - 1}/> selected={selectedStep} toggleSelection={toggleSelection(i)} lastLevel={i == proof.length - 1}/>
} }
})} })}
@ -206,10 +219,8 @@ function PlayableLevel({impressum, setImpressum}) {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const difficulty = useSelector(selectDifficulty(gameId))
const initialCode = useAppSelector(selectCode(gameId, worldId, levelId)) const initialCode = useAppSelector(selectCode(gameId, worldId, levelId))
const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId)) const initialSelections = useAppSelector(selectSelections(gameId, worldId, levelId))
const inventory: Array<String> = useSelector(selectInventory(gameId))
const typewriterMode = useSelector(selectTypewriterMode(gameId)) const typewriterMode = useSelector(selectTypewriterMode(gameId))
const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode})) const setTypewriterMode = (newTypewriterMode: boolean) => dispatch(changeTypewriterMode({game: gameId, typewriterMode: newTypewriterMode}))
@ -288,12 +299,6 @@ function PlayableLevel({impressum, setImpressum}) {
// a hint at the beginning of the proof... // a hint at the beginning of the proof...
const [selectedStep, setSelectedStep] = useState<number>() const [selectedStep, setSelectedStep] = useState<number>()
// if the user inventory changes, notify the server
useEffect(() => {
let leanClient = connection.getLeanClient(gameId)
leanClient.sendNotification('$/game/setInventory', {inventory: inventory, difficulty: difficulty})
}, [inventory])
useEffect (() => { useEffect (() => {
// Lock editor mode // Lock editor mode
if (level?.data?.template) { if (level?.data?.template) {
@ -367,7 +372,7 @@ function PlayableLevel({impressum, setImpressum}) {
// Effect when command line mode gets enabled // Effect when command line mode gets enabled
useEffect(() => { useEffect(() => {
if (editor && typewriterMode) { if (onigasmH && editor && typewriterMode) {
let code = editor.getModel().getLinesContent().filter(line => line.trim()) let code = editor.getModel().getLinesContent().filter(line => line.trim())
editor.executeEdits("typewriter", [{ editor.executeEdits("typewriter", [{
range: editor.getModel().getFullModelRange(), range: editor.getModel().getFullModelRange(),
@ -390,7 +395,7 @@ function PlayableLevel({impressum, setImpressum}) {
// editor.setSelection(monaco.Selection.fromPositions(endPos, endPos)) // editor.setSelection(monaco.Selection.fromPositions(endPos, endPos))
// } // }
} }
}, [editor, typewriterMode]) }, [editor, typewriterMode, onigasmH == null])
return <> return <>
<div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div> <div style={level.isLoading ? null : {display: "none"}} className="app-content loading"><CircularProgress /></div>
@ -436,6 +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)
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)+/)
@ -446,7 +452,7 @@ function IntroductionPanel({gameInfo}) {
hint={{text: t, hidden: false}} step={0} selected={null} toggleSelection={undefined} /> hint={{text: t, hidden: false}} step={0} selected={null} toggleSelection={undefined} />
))} ))}
</div> </div>
<div className="button-row"> <div className={`button-row${mobile ? ' mobile' : ''}`}>
{gameInfo.data?.worldSize[worldId] == 0 ? {gameInfo.data?.worldSize[worldId] == 0 ?
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> : <Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}> <Button to={`/${gameId}/world/${worldId}/level/1`}>
@ -488,7 +494,8 @@ function Introduction({impressum, setImpressum}) {
<IntroductionPanel gameInfo={gameInfo} /> <IntroductionPanel gameInfo={gameInfo} />
<div className="world-image-container empty"> <div className="world-image-container empty">
{image && {image &&
<img src={path.join("data", gameId, image)} alt="" /> // TODO: Temporary for testing
<img className={worldId=="Proposition" ? "cover" : "contain"} src={path.join("data", gameId, image)} alt="" />
} }
</div> </div>
@ -524,21 +531,32 @@ function Introduction({impressum, setImpressum}) {
function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) { function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChangeContent, onDidChangeSelection) {
const connection = React.useContext(ConnectionContext)
const gameId = React.useContext(GameIdContext) const gameId = React.useContext(GameIdContext)
const {worldId, levelId} = useContext(WorldLevelIdContext) const {worldId, levelId} = useContext(WorldLevelIdContext)
const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null) const [editor, setEditor] = useState<monaco.editor.IStandaloneCodeEditor|null>(null)
const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null) const [infoProvider, setInfoProvider] = useState<null|InfoProvider>(null)
const [infoviewApi, setInfoviewApi] = useState<null|InfoviewApi>(null)
const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null) const [editorConnection, setEditorConnection] = useState<null|EditorConnection>(null)
// Create Editor const uriStr = `file:///${worldId}/${levelId}`
const uri = monaco.Uri.parse(uriStr)
const inventory: Array<String> = useSelector(selectInventory(gameId))
const difficulty: number = useSelector(selectDifficulty(gameId))
useEffect(() => { useEffect(() => {
const model = monaco.editor.createModel(initialCode ?? '', 'lean4', uri)
if (onDidChangeContent) {
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
}
const editor = monaco.editor.create(codeviewRef.current!, { const editor = monaco.editor.create(codeviewRef.current!, {
model,
glyphMargin: true, glyphMargin: true,
quickSuggestions: false, quickSuggestions: false,
lineDecorationsWidth: 5,
folding: false,
lineNumbers: 'on',
lightbulb: { lightbulb: {
enabled: true enabled: true
}, },
@ -550,11 +568,61 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
enabled: false enabled: false
}, },
lineNumbersMinChars: 3, lineNumbersMinChars: 3,
tabSize: 2,
'semanticHighlighting.enabled': true, 'semanticHighlighting.enabled': true,
theme: 'vs-code-theme-converted' theme: 'vs-code-theme-converted'
}) })
if (onDidChangeSelection) {
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
}
if (initialSelections) {
console.debug("Initial Selection: ", initialSelections)
// BUG: Somehow I get an `invalid arguments` bug here
// editor.setSelections(initialSelections)
}
setEditor(editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
const socketUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + '/websocket/' + gameId
const connectionProvider : IConnectionProvider = {
get: async () => {
return await new Promise((resolve, reject) => {
console.log(`connecting ${socketUrl}`)
const websocket = new WebSocket(socketUrl)
websocket.addEventListener('error', (ev) => {
reject(ev)
})
websocket.addEventListener('message', (msg) => {
// console.log(msg.data)
})
websocket.addEventListener('open', () => {
const socket = toSocket(websocket)
const reader = new DisposingWebSocketMessageReader(socket)
const writer = new WebSocketMessageWriter(socket)
resolve({
reader,
writer
})
})
})
}
}
const infoProvider = new InfoProvider(connection.getLeanClient(gameId)) // Following `vscode-lean4/webview/index.ts`
const client = new LeanClient(connectionProvider, showRestartMessage, {inventory, difficulty})
const infoProvider = new InfoProvider(client)
// const div: HTMLElement = infoviewRef.current!
const imports = {
'@leanprover/infoview': `${window.location.origin}/index.production.min.js`,
'react': `${window.location.origin}/react.production.min.js`,
'react/jsx-runtime': `${window.location.origin}/react-jsx-runtime.production.min.js`,
'react-dom': `${window.location.origin}/react-dom.production.min.js`,
'react-popper': `${window.location.origin}/react-popper.production.min.js`
}
// loadRenderInfoview(imports, [infoProvider.getApi(), div], setInfoviewApi)
setInfoProvider(infoProvider)
client.restart()
const editorApi = infoProvider.getApi() const editorApi = infoProvider.getApi()
@ -599,54 +667,27 @@ function useLevelEditor(codeviewRef, initialCode, initialSelections, onDidChange
setEditor(editor) setEditor(editor)
setInfoProvider(infoProvider) setInfoProvider(infoProvider)
setInfoviewApi(infoviewApi)
return () => { infoProvider.dispose(); editor.dispose() } infoProvider.openPreview(editor, infoviewApi)
}, []) const taskgutter = new LeanTaskGutter(infoProvider.client, editor)
const {leanClient, leanClientStarted} = useLeanClient(gameId)
const uriStr = `file:///${worldId}/${levelId}`
const uri = monaco.Uri.parse(uriStr)
// Create model when level changes // TODO:
useEffect(() => { // setRestart(() => restart)
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
if (!model) {
model = monaco.editor.createModel(initialCode, 'lean4', uri)
}
model.onDidChangeContent(() => onDidChangeContent(model.getValue()))
editor.onDidChangeCursorSelection(() => onDidChangeSelection(editor.getSelections()))
editor.setModel(model)
if (initialSelections) {
console.debug("Initial Selection: ", initialSelections)
// BUG: Somehow I get an `invalid arguments` bug here
// editor.setSelections(initialSelections)
}
return () => { return () => {
editorConnection.api.sendClientNotification(uriStr, "textDocument/didClose", {textDocument: {uri: uriStr}}) editor.dispose();
model.dispose(); model.dispose();
} abbrevRewriter.dispose();
taskgutter.dispose();
infoProvider.dispose();
client.dispose();
} }
}, [editor, levelId, connection, leanClientStarted]) }, [gameId, worldId, levelId])
useEffect(() => {
if (editor && leanClientStarted) {
let model = monaco.editor.getModel(uri)
infoviewApi.serverRestarted(leanClient.initializeResult)
infoProvider.openPreview(editor, infoviewApi)
const taskGutter = new LeanTaskGutter(infoProvider.client, editor)
const abbrevRewriter = new AbbreviationRewriter(new AbbreviationProvider(), model, editor)
return () => { abbrevRewriter.dispose(); taskGutter.dispose(); } const showRestartMessage = () => {
} // setRestartMessage(true)
}, [editor, connection, leanClientStarted]) console.log("TODO: SHOW RESTART MESSAGE")
}
return {editor, infoProvider, editorConnection} return {editor, infoProvider, editorConnection}
} }

@ -0,0 +1,55 @@
import * as React from 'react'
import { Input, Typography } from '@mui/material'
import Markdown from '../markdown'
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import { IMobileContext } from "../infoview/context"
interface PreferencesPopupProps extends IMobileContext{
handleClose: () => void
}
export function PreferencesPopup({ mobile, setMobile, lockMobile, setLockMobile, handleClose }: PreferencesPopupProps) {
return <div className="modal-wrapper">
<div className="modal-backdrop" onClick={handleClose} />
<div className="modal">
<div className="codicon codicon-close modal-close" onClick={handleClose}></div>
<Typography variant="body1" component="div" className="settings">
<div className='preferences-category'>
<div className='category-title'>
<h3>Mobile layout</h3>
</div>
<div className='preferences-item'>
<FormControlLabel
control={
<Switch
checked={mobile}
onChange={() => setMobile(!mobile)}
name="checked"
color="primary"
/>
}
label="Enable"
labelPlacement="start"
/>
</div>
<div className='preferences-item'>
<FormControlLabel
control={
<Switch
checked={!lockMobile}
onChange={() => setLockMobile(!lockMobile)}
name="checked"
color="primary"
/>
}
label="Auto"
labelPlacement="start"
/>
</div>
</div>
</Typography>
</div>
</div>
}

@ -17,6 +17,7 @@ import { InfoPopup } from './popup/game_info'
import { PrivacyPolicyPopup } from './popup/privacy_policy' import { PrivacyPolicyPopup } from './popup/privacy_policy'
import { RulesHelpPopup } from './popup/rules_help' import { RulesHelpPopup } from './popup/rules_help'
import { UploadPopup } from './popup/upload' import { UploadPopup } from './popup/upload'
import { PreferencesPopup} from "./popup/preferences"
import { WorldTreePanel } from './world_tree' import { WorldTreePanel } from './world_tree'
import '../css/welcome.css' import '../css/welcome.css'
@ -63,7 +64,7 @@ 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} = React.useContext(MobileContext) const {mobile, setMobile, lockMobile, setLockMobile} = React.useContext(MobileContext)
const gameInfo = useGetGameInfoQuery({game: gameId}) const gameInfo = useGetGameInfoQuery({game: gameId})
const inventory = useLoadInventoryOverviewQuery({game: gameId}) const inventory = useLoadInventoryOverviewQuery({game: gameId})
@ -77,15 +78,20 @@ function Welcome() {
const [info, setInfo] = React.useState(false) const [info, setInfo] = React.useState(false)
const [rulesHelp, setRulesHelp] = React.useState(false) const [rulesHelp, setRulesHelp] = React.useState(false)
const [uploadMenu, setUploadMenu] = React.useState(false) const [uploadMenu, setUploadMenu] = React.useState(false)
const [preferencesPopup, setPreferencesPopup] = React.useState(false)
function closeEraseMenu() {setEraseMenu(false)} function closeEraseMenu() {setEraseMenu(false)}
function closeImpressum() {setImpressum(false)} function closeImpressum() {setImpressum(false)}
function closeInfo() {setInfo(false)} function closeInfo() {setInfo(false)}
function closeRulesHelp() {setRulesHelp(false)} function closeRulesHelp() {setRulesHelp(false)}
function closeUploadMenu() {setUploadMenu(false)} function closeUploadMenu() {setUploadMenu(false)}
function closePreferencesPopup() {setPreferencesPopup(false)}
function toggleEraseMenu() {setEraseMenu(!eraseMenu)} function toggleEraseMenu() {setEraseMenu(!eraseMenu)}
function toggleImpressum() {setImpressum(!impressum)} function toggleImpressum() {setImpressum(!impressum)}
function toggleInfo() {setInfo(!info)} function toggleInfo() {setInfo(!info)}
function toggleUploadMenu() {setUploadMenu(!uploadMenu)} function toggleUploadMenu() {setUploadMenu(!uploadMenu)}
function togglePreferencesPopup() {setPreferencesPopup(!preferencesPopup)}
// set the window title // set the window title
useEffect(() => { useEffect(() => {
@ -101,7 +107,7 @@ function Welcome() {
: <> : <>
<WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum} <WelcomeAppBar pageNumber={pageNumber} setPageNumber={setPageNumber} gameInfo={gameInfo.data} toggleImpressum={toggleImpressum}
toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu} toggleEraseMenu={toggleEraseMenu} toggleUploadMenu={toggleUploadMenu}
toggleInfo={toggleInfo} /> toggleInfo={toggleInfo} togglePreferencesPopup={togglePreferencesPopup}/>
<div className="app-content"> <div className="app-content">
{ mobile ? { mobile ?
<div className="welcome mobile"> <div className="welcome mobile">
@ -128,6 +134,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}
</> </>
} }

@ -11,7 +11,7 @@ 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 } from '../hooks' import { useAppDispatch, useMobile } 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'
@ -197,13 +197,15 @@ 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()
function label(x : number) { function label(x : number) {
return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular' return x == 0 ? 'none' : x == 1 ? 'relaxed' : 'regular'
} }
return <nav className="world-selection-menu"> return <nav className={`world-selection-menu${mobile ? '' : ' desktop'}`}>
<div className="slider-wrap"> <div className="slider-wrap">
<span className="difficulty-label">Rules <span className="difficulty-label">Rules
<FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/> <FontAwesomeIcon icon={rulesHelp ? faXmark : faCircleQuestion} className='helpButton' onClick={() => (setRulesHelp(!rulesHelp))}/>
@ -213,7 +215,7 @@ export function WorldSelectionMenu({rulesHelp, setRulesHelp}) {
title="Game Rules" title="Game Rules"
min={0} max={2} min={0} max={2}
aria-label="Game Rules" aria-label="Game Rules"
defaultValue={difficulty} value={difficulty}
marks={[ marks={[
{value: 0, label: label(0)}, {value: 0, label: label(0)},
{value: 1, label: label(1)}, {value: 1, label: label(1)},

@ -1,68 +0,0 @@
/**
* @fileOverview todo
*/
import * as React from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { LeanClient } from 'lean4web/client/src/editor/leanclient';
export class Connection {
private game: string = undefined // We only keep a connection to a single game at a time
private leanClient: LeanClient = null
getLeanClient(game): LeanClient {
if (this.game !== game) {
if (this.leanClient) {
this.leanClient.stop() // Stop previous Lean client
}
this.game = game
// Start a new Lean client for the new `gameId`.
const socketUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + '/websocket/' + game
const uri = monaco.Uri.parse('file:///')
this.leanClient = new LeanClient(socketUrl, undefined, uri, () => {})
}
return this.leanClient
}
/** If not already started, starts the Lean client. resolves the returned promise as soon as a
* Lean client is running.
*/
startLeanClient = (game) => {
return new Promise<LeanClient>((resolve) => {
const leanClient = this.getLeanClient(game)
if (leanClient.isRunning()) {
resolve(leanClient)
} else {
if (!leanClient.isStarted()) {
leanClient.start()
}
leanClient.restarted(() => {
// This keep alive message is not recognized by the server,
// but it makes sure that the websocket connection does not
// time out after 60 seconds.
setInterval(() => {leanClient.sendNotification('$/keepAlive', {}) }, 5000)
resolve(leanClient)
})
}
})
}
}
export const connection = new Connection()
export const ConnectionContext = React.createContext(null);
export const useLeanClient = (gameId) => {
const leanClient = connection.getLeanClient(gameId)
const [leanClientStarted, setLeanClientStarted] = React.useState(leanClient.isStarted())
React.useEffect(() => {
const t1 = leanClient.restarted(() => { console.log("START"); setLeanClientStarted(true) })
const t2 = leanClient.stopped(() => { console.log("STOP"); setLeanClientStarted(false) })
return () => {t1.dispose(); t2.dispose()}
}, [leanClient, setLeanClientStarted])
return {leanClientStarted, leanClient}
}

@ -105,11 +105,18 @@ em {
position: relative; position: relative;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center;
padding: 1.1em; padding: 1.1em;
filter: drop-shadow(0 0 5px rgba(0,0,0,0.5)); filter: drop-shadow(0 0 5px rgba(0,0,0,0.5));
z-index: 2; z-index: 2;
} }
.app-bar > .app-bar-left{
display: flex;
align-items: center;
gap: .5em;
}
.app-bar-title, .app-bar-subtitle { .app-bar-title, .app-bar-subtitle {
color: white; color: white;
font-weight: 500; font-weight: 500;

@ -26,7 +26,11 @@
.inventory .item { .inventory .item {
background: #fff; background: #fff;
border: solid 1px #777; border: solid 1px #777;
padding: .1em .5em; padding-left: .5rem;
padding-right: 1.0rem;
padding-top: .1rem;
padding-bottom: .1rem;
position: relative;
} }
.inventory .item.locked { .inventory .item.locked {
@ -72,3 +76,21 @@
color: black; color: black;
border-bottom: 0.3em solid #999; border-bottom: 0.3em solid #999;
} }
.inventory .item .copy-button {
min-width: 3px;
min-height: 3px;
display: inline-block;
color: #ccc;
font-size: 0.6em;
padding-right: .2rem;
vertical-align: top;
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 1rem;
align-items: end;
text-align: end;
}

@ -232,6 +232,20 @@ td code {
height: 100%; height: 100%;
} */ } */
.button-row.mobile {
margin: .5rem;
padding-top: .2rem;
}
.button-row.mobile .btn {
padding: .5em;
border-radius: .2em;
width: 100%;
margin: 0;
text-align: center;
}
.typewriter-interface { .typewriter-interface {
display: flex; display: flex;
flex-flow: column; flex-flow: column;
@ -317,11 +331,6 @@ td code {
margin-right: 0; margin-right: 0;
} }
#home-btn {
margin-right: .5em;
margin-left: 0;
}
.menu.dropdown .svg-inline--fa { .menu.dropdown .svg-inline--fa {
width: 1.8rem; width: 1.8rem;
} }
@ -342,10 +351,15 @@ td code {
justify-content: center; justify-content: center;
} }
.world-image-container img { .world-image-container img.contain {
object-fit: contain; object-fit: contain;
} }
.world-image-container img.cover {
height: 100%;
object-fit: cover;
}
.typewriter-interface .proof { .typewriter-interface .proof {
background-color: #fff; background-color: #fff;
} }

@ -49,7 +49,6 @@ svg .disabled {
} }
.world-selection-menu { .world-selection-menu {
position: absolute;
right: 1em; right: 1em;
top: 1em; top: 1em;
/* margin: 1em; */ /* margin: 1em; */
@ -60,6 +59,10 @@ svg .disabled {
filter: drop-shadow(4px 4px 5px rgba(0,0,0,0.5)); filter: drop-shadow(4px 4px 5px rgba(0,0,0,0.5));
} }
.world-selection-menu.desktop {
position: absolute;
}
.world-selection-menu .btn, .welcome .btn { .world-selection-menu .btn, .welcome .btn {
min-width: 5em; min-width: 5em;
text-align: center; text-align: center;

@ -1,6 +1,30 @@
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,
};
};

@ -1,7 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import App from './app' import App from './app'
import { ConnectionContext, connection } from './connection'
import { store } from './state/store' import { store } from './state/store'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import type { RouteObject } from "react-router" import type { RouteObject } from "react-router"
@ -10,11 +9,8 @@ import ErrorPage from './components/error_page'
import Welcome from './components/welcome' import Welcome from './components/welcome'
import LandingPage from './components/landing_page' import LandingPage from './components/landing_page'
import Level from './components/level' import Level from './components/level'
import { monacoSetup } from 'lean4web/client/src/monacoSetup'
monacoSetup()
// If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to // If `VITE_LEAN4GAME_SINGLE` is set to true, then `/` should be redirected to
// `/g/local/game`. This is used for the devcontainer setup // `/g/local/game`. This is used for the devcontainer setup
@ -61,9 +57,7 @@ const root = createRoot(container!);
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Provider store={store}> <Provider store={store}>
<ConnectionContext.Provider value={connection}> <RouterProvider router={router} />
<RouterProvider router={router} />
</ConnectionContext.Provider>
</Provider> </Provider>
</React.StrictMode> </React.StrictMode>
); );

@ -35,6 +35,7 @@ export interface InventoryTile {
locked: boolean, locked: boolean,
new: boolean, new: boolean,
hidden: boolean hidden: boolean
altTitle: string,
} }
export interface LevelInfo { export interface LevelInfo {

@ -36,3 +36,24 @@ export async function saveState(state: any) {
// Ignore // Ignore
} }
} }
const PREFERENCES_KEY = "preferences"
/** Load from browser storage */
export function loadPreferences() {
try {
const serializedState = localStorage.getItem(PREFERENCES_KEY);
return JSON.parse(serializedState)
} catch (e) {
return undefined;
}
}
export function savePreferences(state: any) {
try {
const serializedState = JSON.stringify(state)
localStorage.setItem(PREFERENCES_KEY, serializedState);
} catch (e) {
// Ignore
}
}

@ -0,0 +1,37 @@
import { createSlice } from "@reduxjs/toolkit";
import { loadPreferences } from "./local_storage";
interface PreferencesState {
mobile: boolean;
lockMobile: boolean;
}
export function getWindowDimensions() {
const {innerWidth: width, innerHeight: height } = window
return {width, height}
}
const { width } = getWindowDimensions()
export const AUTO_SWITCH_THRESHOLD = 800
const initialState: PreferencesState = loadPreferences() ?? {
mobile: width < AUTO_SWITCH_THRESHOLD,
lockMobile: false
}
export const preferencesSlice = createSlice({
name: "preferences",
initialState,
reducers: {
setMobile: (state, action) => {
state.mobile = action.payload;
},
setLockMobile: (state, action) => {
state.lockMobile = action.payload;
},
},
});
export const { setMobile, setLockMobile } = preferencesSlice.actions;

@ -7,21 +7,19 @@ import { debounce } from "debounce";
import { connection } from '../connection' import { connection } from '../connection'
import { apiSlice } from './api' import { apiSlice } from './api'
import { progressSlice } from './progress' import { progressSlice } from './progress'
import { saveState } from "./local_storage"; import { preferencesSlice } from "./preferences"
import { saveState, savePreferences } from "./local_storage";
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
[apiSlice.reducerPath]: apiSlice.reducer, [apiSlice.reducerPath]: apiSlice.reducer,
[progressSlice.name]: progressSlice.reducer, [progressSlice.name]: progressSlice.reducer,
[preferencesSlice.name]: preferencesSlice.reducer,
}, },
// Make connection available in thunks: // Make connection available in thunks:
middleware: getDefaultMiddleware => middleware: getDefaultMiddleware =>
getDefaultMiddleware({ getDefaultMiddleware().concat(apiSlice.middleware),
thunk: {
extraArgument: { connection }
}
}).concat(apiSlice.middleware),
}); });
/** /**
@ -31,6 +29,7 @@ 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]);
}, 800) }, 800)
); );

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

@ -2,7 +2,7 @@
module.exports = { module.exports = {
apps : [{ apps : [{
name : "lean4game", name : "lean4game",
script : "server/index.mjs", script : "relay/index.mjs",
env: { env: {
LEAN4GAME_GITHUB_USER: "", LEAN4GAME_GITHUB_USER: "",
LEAN4GAME_GITHUB_TOKEN: "", LEAN4GAME_GITHUB_TOKEN: "",

505
package-lock.json generated

@ -13,6 +13,10 @@
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8", "@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^4.5.8", "@fontsource/roboto-mono": "^4.5.8",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@leanprover/infoview": "^0.4.3", "@leanprover/infoview": "^0.4.3",
"@mui/icons-material": "^5.11.0", "@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1", "@mui/material": "^5.11.1",
@ -27,7 +31,7 @@
"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", "lean4web": "github:hhu-adam/lean4web#b91645a7b88814675ba9f99817436d0a2ce3a0ec",
"octokit": "^2.0.14", "octokit": "^2.0.14",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -2221,231 +2225,6 @@
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz",
"integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww=="
}, },
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
"integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz",
"integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz",
"integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz",
"integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz",
"integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz",
"integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz",
"integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz",
"integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz",
"integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz",
"integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz",
"integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==",
"cpu": [
"loong64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz",
"integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==",
"cpu": [
"mips64el"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz",
"integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz",
"integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz",
"integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": { "node_modules/@esbuild/linux-x64": {
"version": "0.18.20", "version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz",
@ -2461,96 +2240,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@esbuild/netbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz",
"integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz",
"integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz",
"integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz",
"integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz",
"integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz",
"integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz",
@ -2596,33 +2285,45 @@
"integrity": "sha512-KrJdmkqz6DszT2wV/bbhXef4r0hV3B0vw2mAqei8A2kRnvq+gcJLmmIeQ94vu9VEXrUQzos5M9lH1TAAXpRphw==" "integrity": "sha512-KrJdmkqz6DszT2wV/bbhXef4r0hV3B0vw2mAqei8A2kRnvq+gcJLmmIeQ94vu9VEXrUQzos5M9lH1TAAXpRphw=="
}, },
"node_modules/@fortawesome/fontawesome-common-types": { "node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.2", "version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz",
"integrity": "sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==", "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==",
"hasInstallScript": true, "hasInstallScript": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fortawesome/fontawesome-svg-core": { "node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.2", "version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.2.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz",
"integrity": "sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==", "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2" "@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz",
"integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@fortawesome/free-solid-svg-icons": { "node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.2", "version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.2.tgz", "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz",
"integrity": "sha512-sYwXurXUEQS32fZz9hVCUUv/xu49PEJEyUOsA51l6PU/qVgfbTb2glsTEaJngVVT8VqBATRIdh7XVgV1JF1LkA==", "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.2" "@fortawesome/fontawesome-common-types": "6.5.1"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -2838,9 +2539,9 @@
} }
}, },
"node_modules/@leanprover/infoview": { "node_modules/@leanprover/infoview": {
"version": "0.4.3", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/@leanprover/infoview/-/infoview-0.4.3.tgz", "resolved": "https://registry.npmjs.org/@leanprover/infoview/-/infoview-0.4.4.tgz",
"integrity": "sha512-SufdOr2myHAbZNUmobfQdAhsEC5H9ddi3KS0z1v/8riWSMm+yJk3u4LxVuzCmmSmV2QxFqtFzn5z+HQqj1Vo7g==", "integrity": "sha512-OxHffFaHcEudLyBEWpicOl7TfXuTYxW5Sz1RkHdUINWJpQsQn60YDF5fNRKmSb0d/fm7p+LVeBvM273jvfR5wQ==",
"dependencies": { "dependencies": {
"@leanprover/infoview-api": "~0.2.1", "@leanprover/infoview-api": "~0.2.1",
"@vscode/codicons": "^0.0.32", "@vscode/codicons": "^0.0.32",
@ -5077,81 +4778,6 @@
} }
} }
}, },
"node_modules/@swc/core-darwin-arm64": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.95.tgz",
"integrity": "sha512-VAuBAP3MNetO/yBIBzvorUXq7lUBwhfpJxYViSxyluMwtoQDhE/XWN598TWMwMl1ZuImb56d7eUsuFdjgY7pJw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.95.tgz",
"integrity": "sha512-20vF2rvUsN98zGLZc+dsEdHvLoCuiYq/1B+TDeE4oolgTFDmI1jKO+m44PzWjYtKGU9QR95sZ6r/uec0QC5O4Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.95.tgz",
"integrity": "sha512-oEudEM8PST1MRNGs+zu0cx5i9uP8TsLE4/L9HHrS07Ck0RJ3DCj3O2fU832nmLe2QxnAGPwBpSO9FntLfOiWEQ==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.95.tgz",
"integrity": "sha512-pIhFI+cuC1aYg+0NAPxwT/VRb32f2ia8oGxUjQR6aJg65gLkUYQzdwuUmpMtFR2WVf7WVFYxUnjo4UyMuyh3ng==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.95.tgz",
"integrity": "sha512-ZpbTr+QZDT4OPJfjPAmScqdKKaT+wGurvMU5AhxLaf85DuL8HwUwwlL0n1oLieLc47DwIJEMuKQkYhXMqmJHlg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": { "node_modules/@swc/core-linux-x64-gnu": {
"version": "1.3.95", "version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.95.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.95.tgz",
@ -5182,51 +4808,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.95.tgz",
"integrity": "sha512-YaP4x/aZbUyNdqCBpC2zL8b8n58MEpOUpmOIZK6G1SxGi+2ENht7gs7+iXpWPc0sy7X3YPKmSWMAuui0h8lgAA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.95.tgz",
"integrity": "sha512-w0u3HI916zT4BC/57gOd+AwAEjXeUlQbGJ9H4p/gzs1zkSHtoDQghVUNy3n/ZKp9KFod/95cA8mbVF9t1+6epQ==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.3.95",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.95.tgz",
"integrity": "sha512-5RGnMt0S6gg4Gc6QtPUJ3Qs9Un4sKqccEzgH/tj7V/DVTJwKdnBKxFZfgQ34OR2Zpz7zGOn889xwsFVXspVWNA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.2", "version": "0.1.2",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.2.tgz",
@ -8535,19 +8116,6 @@
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@ -10513,7 +10081,8 @@
}, },
"node_modules/lean4web": { "node_modules/lean4web": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "git+ssh://git@github.com/hhu-adam/lean4web.git#6fc9c11179934cce7ca1f78140c57b6931186b42", "resolved": "git+ssh://git@github.com/hhu-adam/lean4web.git#b91645a7b88814675ba9f99817436d0a2ce3a0ec",
"integrity": "sha512-s9qYeXuMNBGDPKC5IuFTjV/j8tQCRkZr+poYKEWljrM95rsz4JHIvjdEt891fE9JhPDLSN+iXNRXwIOCT6FlMg==",
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
@ -10522,7 +10091,7 @@
"@fortawesome/fontawesome-svg-core": "^6.2.0", "@fortawesome/fontawesome-svg-core": "^6.2.0",
"@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@leanprover/infoview": "^0.4.3", "@leanprover/infoview": "^0.4.4",
"@mui/material": "^5.13.7", "@mui/material": "^5.13.7",
"@vitejs/plugin-react-swc": "^3.4.0", "@vitejs/plugin-react-swc": "^3.4.0",
"express": "^4.18.2", "express": "^4.18.2",
@ -16122,9 +15691,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "4.5.0", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz",
"integrity": "sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==", "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==",
"dependencies": { "dependencies": {
"esbuild": "^0.18.10", "esbuild": "^0.18.10",
"postcss": "^8.4.27", "postcss": "^8.4.27",

@ -10,6 +10,10 @@
"@emotion/styled": "^11.10.5", "@emotion/styled": "^11.10.5",
"@fontsource/roboto": "^4.5.8", "@fontsource/roboto": "^4.5.8",
"@fontsource/roboto-mono": "^4.5.8", "@fontsource/roboto-mono": "^4.5.8",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@leanprover/infoview": "^0.4.3", "@leanprover/infoview": "^0.4.3",
"@mui/icons-material": "^5.11.0", "@mui/icons-material": "^5.11.0",
"@mui/material": "^5.11.1", "@mui/material": "^5.11.1",
@ -24,7 +28,7 @@
"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", "lean4web": "github:hhu-adam/lean4web#b91645a7b88814675ba9f99817436d0a2ce3a0ec",
"octokit": "^2.0.14", "octokit": "^2.0.14",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"react": "^18.2.0", "react": "^18.2.0",
@ -63,13 +67,13 @@
}, },
"scripts": { "scripts": {
"start": "concurrently -n server,client -c blue,green \"npm run start_server\" \"npm run start_client\"", "start": "concurrently -n server,client -c blue,green \"npm run start_server\" \"npm run start_client\"",
"start_server": "cd server && lake build && cross-env NODE_ENV=development nodemon -e mjs --exec \"node ./index.mjs\"", "start_server": "(cd server && lake build) && (cd relay && cross-env NODE_ENV=development nodemon -e mjs --exec \"node ./index.mjs\")",
"start_client": "cross-env NODE_ENV=development vite --host", "start_client": "cross-env NODE_ENV=development vite --host",
"build": "npm run build_server && npm run build_client", "build": "npm run build_server && npm run build_client",
"preview": "vite preview", "preview": "vite preview",
"build_server": "cd server && lake build", "build_server": "cd server && lake build",
"build_client": "cross-env NODE_ENV=production vite build", "build_client": "cross-env NODE_ENV=production vite build",
"production": "cross-env NODE_ENV=production node server/index.mjs" "production": "cross-env NODE_ENV=production node relay/index.mjs"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [

@ -1,8 +1,11 @@
#/bin/bash #/bin/bash
# Note: This fails if there is no default toolchain installed
ELAN_HOME=$(lake env printenv ELAN_HOME) ELAN_HOME=$(lake env printenv ELAN_HOME)
# $1 : the game directory
# $2 : the lean4game folder
# $3 : the gameserver executable
(exec bwrap\ (exec bwrap\
--bind $2 /lean4game \ --bind $2 /lean4game \
@ -24,6 +27,6 @@ ELAN_HOME=$(lake env printenv ELAN_HOME)
--unshare-uts \ --unshare-uts \
--unshare-cgroup \ --unshare-cgroup \
--die-with-parent \ --die-with-parent \
--chdir "/lean4game/server/.lake/build/bin/" \ --chdir "/game/.lake/packages/GameServer/server/.lake/build/bin/" \
./gameserver --server /game ./gameserver --server /game
) )

@ -79,17 +79,17 @@ async function doImport (owner, repo, id) {
artifactId = artifact.id artifactId = artifact.id
const url = artifact.archive_download_url const url = artifact.archive_download_url
// Make sure the download folder exists // Make sure the download folder exists
if (!fs.existsSync(`${__dirname}/../games`)){ if (!fs.existsSync(path.join(__dirname, "..", "games"))){
fs.mkdirSync(`${__dirname}/../games`); fs.mkdirSync(path.join(__dirname, "..", "games"));
} }
if (!fs.existsSync(`${__dirname}/../games/tmp`)){ if (!fs.existsSync(path.join(__dirname, "..", "games", "tmp"))){
fs.mkdirSync(`${__dirname}/../games/tmp`); fs.mkdirSync(path.join(__dirname, "..", "games", "tmp"));
} }
progress[id].output += `Download from ${url}\n` progress[id].output += `Download from ${url}\n`
await download(id, url, `${__dirname}/../games/tmp/${owner.toLowerCase()}_${repo.toLowerCase()}_${artifactId}.zip`) await download(id, url, path.join(__dirname, "..", "games", "tmp", `${owner.toLowerCase()}_${repo.toLowerCase()}_${artifactId}.zip`))
progress[id].output += `Download finished.\n` progress[id].output += `Download finished.\n`
await runProcess(id, "/bin/bash", [`${__dirname}/unpack.sh`, artifactId, owner.toLowerCase(), repo.toLowerCase()], `${__dirname}/..`) await runProcess(id, "/bin/bash", [path.join(__dirname, "unpack.sh"), artifactId, owner.toLowerCase(), repo.toLowerCase()], path.join(__dirname, ".."))
// let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`); // let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`);
@ -110,8 +110,8 @@ async function doImport (owner, repo, id) {
} finally { } finally {
// clean-up temp. files // clean-up temp. files
if (artifactId) { if (artifactId) {
fs.rmSync(`${__dirname}/../games/tmp/${owner}_${repo}_${artifactId}.zip`, {force: true, recursive: false}); fs.rmSync(path.join(__dirname, "..", "games", "tmp", `${owner}_${repo}_${artifactId}.zip`), {force: true, recursive: false});
fs.rmSync(`${__dirname}/../games/tmp/${owner}_${repo}_${artifactId}`, {force: true, recursive: true}); fs.rmSync(path.join(__dirname, "..", "games", "tmp", `${owner}_${repo}_${artifactId}`), {force: true, recursive: true});
} }
progress[id].done = true progress[id].done = true
} }

@ -35,7 +35,7 @@ router.get('/import/status/:owner/:repo', importStatus)
router.get('/import/trigger/:owner/:repo', importTrigger) router.get('/import/trigger/:owner/:repo', importTrigger)
const server = app const server = app
.use(express.static(path.join(__dirname, '../client/dist/'))) // TODO: add a dist folder from inside the game .use(express.static(path.join(__dirname, '..', 'client', 'dist'))) // TODO: add a dist folder from inside the game
.use('/data/g/:owner/:repo/*', (req, res, next) => { .use('/data/g/:owner/:repo/*', (req, res, next) => {
const owner = req.params.owner; const owner = req.params.owner;
const repo = req.params.repo const repo = req.params.repo
@ -95,13 +95,22 @@ function startServerProcess(owner, repo) {
let serverProcess let serverProcess
if (isDevelopment) { if (isDevelopment) {
let args = ["--server", game_dir] let args = ["--server", game_dir]
serverProcess = cp.spawn("./gameserver", args, // TODO: find gameserver inside the games let binDir = path.join(game_dir, ".lake", "packages", "GameServer", "server", ".lake", "build", "bin")
{ cwd: path.join(__dirname, "./.lake/build/bin/") }) // Note: `cwd` is important to be the `bin` directory as `Watchdog` calls `./gameserver` again
if (fs.existsSync(binDir)) {
// Try to use the game's own copy of `gameserver`.
serverProcess = cp.spawn("./gameserver", args, { cwd: binDir })
} else {
// If the game is built with `-Klean4game.local` there is no copy in the lake packages.
serverProcess = cp.spawn("./gameserver", args,
{ cwd: path.join(__dirname, "..", "server", ".lake", "build", "bin") })
}
} else { } else {
serverProcess = cp.spawn("./bubblewrap.sh", serverProcess = cp.spawn("./bubblewrap.sh",
[game_dir, path.join(__dirname, '..')], [ game_dir, path.join(__dirname, '..')],
{ cwd: __dirname }) { cwd: __dirname })
} }
serverProcess.on('error', error => serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`) console.error(`Launching Lean Server failed: ${error}`)
) )

@ -6,7 +6,7 @@ REPO=$3
# mkdir -p games # mkdir -p games
cd games cd games
pwd
# mkdir -p tmp # mkdir -p tmp
mkdir -p ${OWNER} mkdir -p ${OWNER}

3
server/.gitignore vendored

@ -1,3 +0,0 @@
build/
games/
.lake

@ -1,5 +1,4 @@
import GameServer.FileWorker import GameServer.FileWorker
import GameServer.Watchdog
import GameServer.Commands import GameServer.Commands
-- TODO: The only reason we import `Commands` is so that it gets built to on `lake build` -- TODO: The only reason we import `Commands` is so that it gets built to on `lake build`
@ -10,13 +9,9 @@ unsafe def main : List String → IO UInt32 := fun args => do
Lean.enableInitializersExecution Lean.enableInitializersExecution
-- TODO: remove this argument
if args[0]? == some "--server" then if args[0]? == some "--server" then
MyServer.Watchdog.watchdogMain args MyServer.FileWorker.workerMain {} args
else if args[0]? == some "--worker" then
MyServer.FileWorker.workerMain {}
else else
e.putStrLn s!"Expected `--server` or `--worker`" e.putStrLn s!"Expected `--server`"
return 1 return 1
-- TODO: Potentially it could be useful to pass in the `gameName` via the websocket connection

@ -1,23 +1,12 @@
import GameServer.EnvExtensions import GameServer.Helpers
import GameServer.Inventory
import GameServer.Options
import GameServer.SaveData
open Lean Meta Elab Command open Lean Meta Elab Command
set_option autoImplicit false set_option autoImplicit false
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
register_option lean4game.showDependencyReasons : Bool := {
defValue := false
descr := "show reasons for calculated world dependencies."
}
/-- Let `MakeGame` print the reasons why the worlds depend on each other.
Note: currently unused in favour of setting `set_option trace.debug true`. -/
register_option lean4game.verbose : Bool := {
defValue := false
descr := "display more info messages to help developing the game."
}
/-! # Game metadata -/ /-! # Game metadata -/
/-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/ /-- Switch to the specified `Game` (and create it if non-existent). Example: `Game "NNG"` -/
@ -52,13 +41,15 @@ elab "Title" t:str : command => do
/-- Define the introduction of the current game/world/level. -/ /-- Define the introduction of the current game/world/level. -/
elab "Introduction" t:str : command => do elab "Introduction" t:str : command => do
let intro := t.getString
match ← getCurLayer with match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with introduction := t.getString} | .Level => modifyCurLevel fun level => pure {level with introduction := intro}
| .World => modifyCurWorld fun world => pure {world with introduction := t.getString} | .World => modifyCurWorld fun world => pure {world with introduction := intro}
| .Game => modifyCurGame fun game => pure {game with introduction := t.getString} | .Game => modifyCurGame fun game => pure {game with introduction := intro}
/-- Define the info of the current game. Used for e.g. credits -/ /-- Define the info of the current game. Used for e.g. credits -/
elab "Info" t:str : command => do elab "Info" t:str : command => do
let info:= t.getString
match ← getCurLayer with match ← getCurLayer with
| .Level => | .Level =>
logError "Can't use `Info` in a level!" logError "Can't use `Info` in a level!"
@ -66,7 +57,7 @@ elab "Info" t:str : command => do
| .World => | .World =>
logError "Can't use `Info` in a world" logError "Can't use `Info` in a world"
pure () pure ()
| .Game => modifyCurGame fun game => pure {game with info := t.getString} | .Game => modifyCurGame fun game => pure {game with info := info}
/-- Provide the location of the image for the current game/world/level. /-- Provide the location of the image for the current game/world/level.
Paths are relative to the lean project's root. -/ Paths are relative to the lean project's root. -/
@ -90,10 +81,11 @@ elab "Image" t:str : command => do
/-- Define the conclusion of the current game or current level if some /-- Define the conclusion of the current game or current level if some
building a level. -/ building a level. -/
elab "Conclusion" t:str : command => do elab "Conclusion" t:str : command => do
let conclusion := t.getString
match ← getCurLayer with match ← getCurLayer with
| .Level => modifyCurLevel fun level => pure {level with conclusion := t.getString} | .Level => modifyCurLevel fun level => pure {level with conclusion := conclusion}
| .World => modifyCurWorld fun world => pure {world with conclusion := t.getString} | .World => modifyCurWorld fun world => pure {world with conclusion := conclusion}
| .Game => modifyCurGame fun game => pure {game with conclusion := t.getString} | .Game => modifyCurGame fun game => pure {game with conclusion := conclusion}
/-- A list of games that should be played before this one. Example `Prerequisites "NNG" "STG"`. -/ /-- A list of games that should be played before this one. Example `Prerequisites "NNG" "STG"`. -/
elab "Prerequisites" t:str* : command => do elab "Prerequisites" t:str* : command => do
@ -102,13 +94,15 @@ elab "Prerequisites" t:str* : command => do
/-- Short caption for the game (1 sentence) -/ /-- Short caption for the game (1 sentence) -/
elab "CaptionShort" t:str : command => do elab "CaptionShort" t:str : command => do
let caption := t.getString
modifyCurGame fun game => pure {game with modifyCurGame fun game => pure {game with
tile := {game.tile with short := t.getString}} tile := {game.tile with short := caption}}
/-- More detailed description what the game is about (2-4 sentences). -/ /-- More detailed description what the game is about (2-4 sentences). -/
elab "CaptionLong" t:str : command => do elab "CaptionLong" t:str : command => do
let caption := t.getString
modifyCurGame fun game => pure {game with modifyCurGame fun game => pure {game with
tile := {game.tile with long := t.getString}} tile := {game.tile with long := caption}}
/-- A list of Languages the game is translated to. For example `Languages "German" "English"`. /-- A list of Languages the game is translated to. For example `Languages "German" "English"`.
NOTE: For the time being, only a single language is supported. NOTE: For the time being, only a single language is supported.
@ -130,119 +124,12 @@ elab "CoverImage" t:str : command => do
/-! # Inventory /-! # Inventory
The inventory contains docs for tactics, lemmas, and definitions. These are all locked The inventory contains docs for tactics, theorems, and definitions. These are all locked
in the first level and get enabled during the game. in the first level and get enabled during the game.
-/ -/
/-! ## Doc entries -/ /-! ## Doc entries -/
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning.
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
I used this workaround, because I needed a new name (with correct namespace etc)
to be used, and I don't know how to create a new ident with same position but different name.
-/
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
(template : Option String := none) : CommandElabM Unit := do
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
let env ← getEnv
let n := name
-- Find a key with matching `(type, name)`.
match (inventoryTemplateExt.getState env).findIdx?
(fun x => x.name == n && x.type == type) with
-- Nothing to do if the entry exists
| some _ => pure ()
| none =>
match template with
-- Warn about missing documentation
| none =>
let docstring ← match (← getDocstring env name type) with
| some ds =>
logInfoAt ref (m!"Missing {type} Documentation. Using existing docstring. " ++
m!"Add {name}\nAdd `{type}Doc {name}` somewhere above this statement.")
pure s!"*(lean docstring)*\\\n{ds}"
| none =>
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
pure "(missing)"
-- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := docstring})
-- Add the default documentation
| some s =>
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := s })
logInfoAt ref (m!"Missing {type} Documentation: {name}, used default (e.g. provided " ++
m!"docstring) instead. If you want to write a different description, add " ++
m!"`{type}Doc {name}` somewhere above this statement.")
/-- Documentation entry of a tactic. Example: /-- Documentation entry of a tactic. Example:
``` ```
@ -252,37 +139,40 @@ TacticDoc rw "`rw` stands for rewrite, etc. "
* The identifier is the tactics name. Some need to be escaped like `«have»`. * The identifier is the tactics name. Some need to be escaped like `«have»`.
* The description is a string supporting Markdown. * The description is a string supporting Markdown.
-/ -/
elab "TacticDoc" name:ident content:str : command => elab doc:docComment ? "TacticDoc" name:ident content:str ? : command => do
let doc ← parseDocCommentLegacy doc content
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Tactic type := .Tactic
name := name.getId name := name.getId
displayName := name.getId.toString displayName := name.getId.toString
content := content.getString }) content := doc })
/-- Documentation entry of a lemma. Example: /-- Documentation entry of a theorem. Example:
``` ```
LemmaDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc." TheoremDoc Nat.succ_pos as "succ_pos" in "Nat" "says `0 < n.succ`, etc."
``` ```
* The first identifier is used in the commands `[New/Only/Disabled]Lemma`. * The first identifier is used in the commands `[New/Only/Disabled]Theorem`.
It is preferably the true name of the lemma. However, this is not required. It is preferably the true name of the theorem. However, this is not required.
* The string following `as` is the displayed name (in the Inventory). * The string following `as` is the displayed name (in the Inventory).
* The identifier after `in` is the category to group lemmas by (in the Inventory). * The identifier after `in` is the category to group theorems by (in the Inventory).
* The description is a string supporting Markdown. * The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires
The lemma/definition to have the same fully qualified name as in mathlib. The theorem/definition to have the same fully qualified name as in mathlib.
-/ -/
elab "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str : command => elab doc:docComment ? "TheoremDoc" name:ident "as" displayName:str "in" category:str content:str ? :
command => do
let doc ← parseDocCommentLegacy doc content
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Lemma type := .Lemma
name := name.getId name := name.getId
category := category.getString category := category.getString
displayName := displayName.getString displayName := displayName.getString
content := content.getString }) content := doc })
-- TODO: Catch the following behaviour. -- TODO: Catch the following behaviour.
-- 1. if `LemmaDoc` appears in the same file as `Statement`, it will silently use -- 1. if `TheoremDoc` appears in the same file as `Statement`, it will silently use
-- it but display the info that it wasn't found in `Statement` -- it but display the info that it wasn't found in `Statement`
-- 2. if it appears in a later file, however, it will silently not do anything and keep -- 2. if it appears in a later file, however, it will silently not do anything and keep
-- the first one. -- the first one.
@ -300,37 +190,25 @@ DefinitionDoc Function.Bijective as "Bijective" "defined as `Injective f ∧ Sur
* The description is a string supporting Markdown. * The description is a string supporting Markdown.
Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires Use `[[mathlib_doc]]` in the string to insert a link to the mathlib doc page. This requires
The lemma/definition to have the same fully qualified name as in mathlib. The theorem/definition to have the same fully qualified name as in mathlib.
-/ -/
elab "DefinitionDoc" name:ident "as" displayName:str template:str : command => elab doc:docComment ? "DefinitionDoc" name:ident "as" displayName:str template:str ? : command => do
let doc ← parseDocCommentLegacy doc template
modifyEnv (inventoryTemplateExt.addEntry · { modifyEnv (inventoryTemplateExt.addEntry · {
type := .Definition type := .Definition
name := name.getId, name := name.getId,
displayName := displayName.getString, displayName := displayName.getString,
content := template.getString }) content := doc })
/-! ## Add inventory items -/ /-! ## Add inventory items -/
def getStatement (name : Name) : CommandElabM MessageData := do def checkCommandNotDuplicated (items : Array Name) (cmd := "Command") : CommandElabM Unit := do
return ← addMessageContextPartial (.ofPPFormat { pp := fun if ¬ items.isEmpty then
| some ctx => ctx.runMetaM <| PrettyPrinter.ppSignature name logWarning s!"You should only use one `{cmd}` per level, but it takes multiple arguments: `{cmd} obj₁ obj₂ obj₃`!"
| none => return "that's a bug." })
-- Note: We use `String` because we can't send `MessageData` as json, but
-- `MessageData` might be better for interactive highlighting.
/-- Get a string of the form `my_lemma (n : ) : n + n = 2 * n`.
Note: A statement like `theorem abc : ∀ x : Nat, x ≥ 0` would be turned into
`theorem abc (x : Nat) : x ≥ 0` by `PrettyPrinter.ppSignature`. -/
def getStatementString (name : Name) : CommandElabM String := do
try
return ← (← getStatement name).toString
catch
| _ => throwError m!"Could not find {name} in context."
-- TODO: I think it would be nicer to unresolve Namespaces as much as possible.
/-- Declare tactics that are introduced by this level. -/ /-- Declare tactics that are introduced by this level. -/
elab "NewTactic" args:ident* : command => do elab "NewTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.new) "NewTactic"
for name in ↑args do for name in ↑args do
checkInventoryDoc .Tactic name -- TODO: Add (template := "[docstring]") checkInventoryDoc .Tactic name -- TODO: Add (template := "[docstring]")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
@ -338,14 +216,16 @@ elab "NewTactic" args:ident* : command => do
/-- Declare tactics that are introduced by this level but do not show up in inventory. -/ /-- Declare tactics that are introduced by this level but do not show up in inventory. -/
elab "NewHiddenTactic" args:ident* : command => do elab "NewHiddenTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.hidden) "NewHiddenTactic"
for name in ↑args do for name in ↑args do
checkInventoryDoc .Tactic name (template := "") checkInventoryDoc .Tactic name (template := "")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId), tactics := {level.tactics with new := level.tactics.new ++ args.map (·.getId),
hidden := level.tactics.hidden ++ args.map (·.getId)}} hidden := level.tactics.hidden ++ args.map (·.getId)}}
/-- Declare lemmas that are introduced by this level. -/ /-- Declare theorems that are introduced by this level. -/
elab "NewLemma" args:ident* : command => do elab "NewTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.new) "NewTheorem"
for name in ↑args do for name in ↑args do
try let _decl ← getConstInfo name.getId catch try let _decl ← getConstInfo name.getId catch
| _ => logErrorAt name m!"unknown identifier '{name}'." | _ => logErrorAt name m!"unknown identifier '{name}'."
@ -355,6 +235,7 @@ elab "NewLemma" args:ident* : command => do
/-- Declare definitions that are introduced by this level. -/ /-- Declare definitions that are introduced by this level. -/
elab "NewDefinition" args:ident* : command => do elab "NewDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.new) "NewDefinition"
for name in ↑args do checkInventoryDoc .Definition name -- TODO: Add (template := "[mathlib]") for name in ↑args do checkInventoryDoc .Definition name -- TODO: Add (template := "[mathlib]")
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
definitions := {level.definitions with new := args.map (·.getId)}} definitions := {level.definitions with new := args.map (·.getId)}}
@ -362,31 +243,36 @@ elab "NewDefinition" args:ident* : command => do
/-- Declare tactics that are temporarily disabled in this level. /-- Declare tactics that are temporarily disabled in this level.
This is ignored if `OnlyTactic` is set. -/ This is ignored if `OnlyTactic` is set. -/
elab "DisabledTactic" args:ident* : command => do elab "DisabledTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.disabled) "DisabledTactic"
for name in ↑args do checkInventoryDoc .Tactic name for name in ↑args do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
tactics := {level.tactics with disabled := args.map (·.getId)}} tactics := {level.tactics with disabled := args.map (·.getId)}}
/-- Declare lemmas that are temporarily disabled in this level. /-- Declare theorems that are temporarily disabled in this level.
This is ignored if `OnlyLemma` is set. -/ This is ignored if `OnlyTheorem` is set. -/
elab "DisabledLemma" args:ident* : command => do elab "DisabledTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.disabled) "DisabledTheorem"
for name in ↑args do checkInventoryDoc .Lemma name for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with disabled := args.map (·.getId)}} lemmas := {level.lemmas with disabled := args.map (·.getId)}}
/-- Declare definitions that are temporarily disabled in this level -/ /-- Declare definitions that are temporarily disabled in this level -/
elab "DisabledDefinition" args:ident* : command => do elab "DisabledDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.disabled) "DisabledDefinition"
for name in ↑args do checkInventoryDoc .Definition name for name in ↑args do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
definitions := {level.definitions with disabled := args.map (·.getId)}} definitions := {level.definitions with disabled := args.map (·.getId)}}
/-- Temporarily disable all tactics except the ones declared here -/ /-- Temporarily disable all tactics except the ones declared here -/
elab "OnlyTactic" args:ident* : command => do elab "OnlyTactic" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).tactics.only) "OnlyTactic"
for name in ↑args do checkInventoryDoc .Tactic name for name in ↑args do checkInventoryDoc .Tactic name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
tactics := {level.tactics with only := args.map (·.getId)}} tactics := {level.tactics with only := args.map (·.getId)}}
/-- Temporarily disable all lemmas except the ones declared here -/ /-- Temporarily disable all theorems except the ones declared here -/
elab "OnlyLemma" args:ident* : command => do elab "OnlyTheorem" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).lemmas.only) "OnlyTheorem"
for name in ↑args do checkInventoryDoc .Lemma name for name in ↑args do checkInventoryDoc .Lemma name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with only := args.map (·.getId)}} lemmas := {level.lemmas with only := args.map (·.getId)}}
@ -394,65 +280,66 @@ elab "OnlyLemma" args:ident* : command => do
/-- Temporarily disable all definitions except the ones declared here. /-- Temporarily disable all definitions except the ones declared here.
This is ignored if `OnlyDefinition` is set. -/ This is ignored if `OnlyDefinition` is set. -/
elab "OnlyDefinition" args:ident* : command => do elab "OnlyDefinition" args:ident* : command => do
checkCommandNotDuplicated ((←getCurLevel).definitions.only) "OnlyDefinition"
for name in ↑args do checkInventoryDoc .Definition name for name in ↑args do checkInventoryDoc .Definition name
modifyCurLevel fun level => pure {level with modifyCurLevel fun level => pure {level with
definitions := {level.definitions with only := args.map (·.getId)}} definitions := {level.definitions with only := args.map (·.getId)}}
/-- Define which tab of Lemmas is opened by default. Usage: `LemmaTab "Nat"`. /-- Define which tab of Lemmas is opened by default. Usage: `TheoremTab "Nat"`.
If omitted, the current tab will remain open. -/ If omitted, the current tab will remain open. -/
elab "LemmaTab" category:str : command => elab "TheoremTab" category:str : command =>
modifyCurLevel fun level => pure {level with lemmaTab := category.getString} modifyCurLevel fun level => pure {level with lemmaTab := category.getString}
/-! # Exercise Statement -/
/-- A `attr := ...` option for `Statement`. Add attributes to the defined theorem. -/ /-! DEPRECATED -/
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO elab doc:docComment ? "LemmaDoc" name:ident "as" displayName:str "in" category:str content:str ? :
command => do
-- TODO: Reuse the following code for checking available tactics in user code: logWarning "Deprecated. Has been renamed to `TheoremDoc`"
structure UsedInventory where let doc ← parseDocCommentLegacy doc content
(tactics : HashSet Name := {}) modifyEnv (inventoryTemplateExt.addEntry · {
(definitions : HashSet Name := {}) type := .Lemma
(lemmas : HashSet Name := {}) name := name.getId
category := category.getString
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do displayName := displayName.getString
match stx with content := doc })
| .missing => return acc
| .node _info kind args => elab "NewLemma" args:ident* : command => do
if kind == `GameServer.Tactic.Hint || kind == `GameServer.Tactic.Branch then return acc logWarning "Deprecated. Has been renamed to `NewTheorem`"
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc checkCommandNotDuplicated ((←getCurLevel).lemmas.new) "NewLemma"
| .atom _info val => for name in ↑args do
-- ignore syntax elements that do not start with a letter try let _decl ← getConstInfo name.getId catch
-- and ignore some standard keywords | _ => logErrorAt name m!"unknown identifier '{name}'."
let allowed := ["with", "fun", "at", "only", "by"] checkInventoryDoc .Lemma name -- TODO: Add (template := "[mathlib]")
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then modifyCurLevel fun level => pure {level with
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp` lemmas := {level.lemmas with new := args.map (·.getId)}}
return {acc with tactics := acc.tactics.insert val}
else elab "DisabledLemma" args:ident* : command => do
return acc logWarning "Deprecated. Has been renamed to `DisabledTheorem`"
| .ident _info _rawVal val _preresolved => checkCommandNotDuplicated ((←getCurLevel).lemmas.disabled) "DisabledLemma"
let ns ← for name in ↑args do checkInventoryDoc .Lemma name
try resolveGlobalConst (mkIdent val) modifyCurLevel fun level => pure {level with
catch | _ => pure [] -- catch "unknown constant" error lemmas := {level.lemmas with disabled := args.map (·.getId)}}
return ← ns.foldlM (fun acc n => do
if let some (.thmInfo ..) := (← getEnv).find? n then elab "OnlyLemma" args:ident* : command => do
return {acc with lemmas := acc.lemmas.insertMany ns} logWarning "Deprecated. Has been renamed to `OnlyTheorem`"
else checkCommandNotDuplicated ((←getCurLevel).lemmas.only) "OnlyLemma"
return {acc with definitions := acc.definitions.insertMany ns} for name in ↑args do checkInventoryDoc .Lemma name
) acc modifyCurLevel fun level => pure {level with
lemmas := {level.lemmas with only := args.map (·.getId)}}
-- #check expandOptDocComment?
elab "LemmaTab" category:str : command => do
logWarning "Deprecated. Has been renamed to `TheoremTab`"
modifyCurLevel fun level => pure {level with lemmaTab := category.getString}
/-! # Exercise Statement -/
/-- Define the statement of the current level. -/ /-- Define the statement of the current level. -/
elab doc:docComment ? attrs:Parser.Term.attributes ? elab doc:docComment ? attrs:Parser.Term.attributes ?
"Statement" statementName:ident ? sig:declSig val:declVal : command => do "Statement" statementName:ident ? sig:declSig val:declVal : command => do
let lvlIdx ← getCurLevelIdx let lvlIdx ← getCurLevelIdx
let docContent : Option String := match doc with let docContent ← parseDocComment doc
| none => none
| some s => match s.raw[1] with
| .atom _ val => val.dropRight 2 |>.trim -- some (val.extract 0 (val.endPos - ⟨2⟩))
| _ => none --panic "not implemented error message" --throwErrorAt s "unexpected doc string{indentD s.raw[1]}"
-- Save the messages before evaluation of the proof. -- Save the messages before evaluation of the proof.
let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} }) let initMsgs ← modifyGet fun st => (st.messages, { st with messages := {} })
@ -553,23 +440,6 @@ elab doc:docComment ? attrs:Parser.Term.attributes ?
/-! # Hints -/ /-! # Hints -/
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-- Remove any spaces at the beginning of a new line -/
partial def removeIndentation (s : String) : String :=
let rec loop (i : String.Pos) (acc : String) (removeSpaces := false) : String :=
let c := s.get i
let i := s.next i
if s.atEnd i then
acc.push c
else if removeSpaces && c == ' ' then
loop i acc (removeSpaces := true)
else if c == '\n' then
loop i (acc.push c) (removeSpaces := true)
else
loop i (acc.push c)
loop ⟨0⟩ ""
/-- A tactic that can be used inside `Statement`s to indicate in which proof states players should /-- A tactic that can be used inside `Statement`s to indicate in which proof states players should
see hints. The tactic does not affect the goal state. see hints. The tactic does not affect the goal state.
-/ -/
@ -683,26 +553,6 @@ elab "Template" tacs:tacticSeq : tactic => do
return {level with template := s!"{template}"} return {level with template := s!"{template}"}
open IO.FS System FilePath in
/-- Copies the folder `images/` to `.lake/gamedata/images/` -/
def copyImages : IO Unit := do
let target : FilePath := ".lake" / "gamedata"
if ← FilePath.pathExists "images" then
for file in ← walkDir "images" do
let outFile := target.join file
-- create the directories
if ← file.isDir then
createDirAll outFile
else
if let some parent := outFile.parent then
createDirAll parent
-- copy file
let content ← readBinFile file
writeBinFile outFile content
-- TODO: Notes for testing if a declaration has the simp attribute -- TODO: Notes for testing if a declaration has the simp attribute
-- -- Test: From zulip -- -- Test: From zulip
@ -721,139 +571,6 @@ def copyImages : IO Unit := do
/-! # Make Game -/ /-! # Make Game -/
#eval IO.FS.createDirAll ".lake/gamedata/"
-- TODO: register all of this as ToJson instance?
def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name)) : CommandElabM Unit := do
let game ← getCurGame
let env ← getEnv
let path : System.FilePath := s!"{← IO.currentDir}" / ".lake" / "gamedata"
if ← path.isDir then
IO.FS.removeDirAll path
IO.FS.createDirAll path
-- copy the images folder
copyImages
for (worldId, world) in game.worlds.nodes.toArray do
for (levelId, level) in world.levels.toArray do
IO.FS.writeFile (path / s!"level__{worldId}__{levelId}.json") (toString (toJson (level.toInfo env)))
IO.FS.writeFile (path / s!"game.json") (toString (getGameJson game))
for inventoryType in [InventoryType.Lemma, .Tactic, .Definition] do
for name in allItemsByType.findD inventoryType {} do
let some item ← getInventoryItem? name inventoryType
| throwError "Expected item to exist: {name}"
IO.FS.writeFile (path / s!"doc__{inventoryType}__{name}.json") (toString (toJson item))
let getTiles (type : InventoryType) : CommandElabM (Array InventoryTile) := do
(allItemsByType.findD type {}).toArray.mapM (fun name => do
let some item ← getInventoryItem? name type
| throwError "Expected item to exist: {name}"
return item.toTile)
let inventory : InventoryOverview := {
lemmas := ← getTiles .Lemma
tactics := ← getTiles .Tactic
definitions := ← getTiles .Definition
lemmaTab := none
}
IO.FS.writeFile (path / s!"inventory.json") (toString (toJson inventory))
def GameLevel.getInventory (level : GameLevel) : InventoryType → InventoryInfo
| .Tactic => level.tactics
| .Definition => level.definitions
| .Lemma => level.lemmas
def GameLevel.setComputedInventory (level : GameLevel) :
InventoryType → Array InventoryTile → GameLevel
| .Tactic, v => {level with tactics := {level.tactics with tiles := v}}
| .Definition, v => {level with definitions := {level.definitions with tiles := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}}
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
match (newArrows.find? id, decendants.find? id) with
| (some _, some _) => return (newArrows, decendants)
| _ =>
let mut newArr := newArrows
let mut desc := decendants
desc := desc.insert id {} -- mark as worked in case of loops
newArr := newArr.insert id {} -- mark as worked in case of loops
let children := arrows.findD id {}
let mut trimmedChildren := children
let mut theseDescs := children
for child in children do
(newArr, desc) := removeTransitiveAux child arrows newArr desc
let childDescs := desc.findD child {}
theseDescs := theseDescs.insertMany childDescs
for d in childDescs do
trimmedChildren := trimmedChildren.erase d
desc := desc.insert id theseDescs
newArr := newArr.insert id trimmedChildren
return (newArr, desc)
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
let mut newArr := {}
let mut desc := {}
for id in arrows.toArray.map Prod.fst do
(newArr, desc) := removeTransitiveAux id arrows newArr desc
if (desc.findD id {}).contains id then
logError <| m!"Loop at {id}. " ++
m!"This should not happen and probably means that `findLoops` has a bug."
-- DEBUG:
-- for ⟨x, hx⟩ in desc.toList do
-- m := m ++ m!"{x}: {hx.toList}\n"
-- logError m
return newArr
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
For performance reason it returns a HashSet of visited
nodes as well. This is filled with all nodes ever looked at as they cannot be
part of a loop anymore. -/
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
(path : Array Name := #[]) (visited : HashSet Name := {}) :
Array Name × HashSet Name := Id.run do
let mut visited := visited
match path.getIdx? node with
| some i =>
-- Found a loop: `node` is already the iᵗʰ element of the path
return (path.extract i path.size, visited.insert node)
| none =>
for successor in arrows.findD node {} do
-- If we already visited the successor, it cannot be part of a loop anymore
if visited.contains successor then
continue
-- Find any loop involving `successor`
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
visited := visited.insert successor
-- No loop found in the dependants of `successor`
if loop.isEmpty then
continue
-- Found a loop, return it
return (loop, visited)
return (#[], visited.insert node)
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
let mut visited : HashSet Name := {}
for node in arrows.toArray.map (·.1) do
-- Skip a node if it was already visited
if visited.contains node then
continue
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
-- search starting from `node`
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
visited := moreVisited
if !loop.isEmpty then
return loop.toList
return []
/-- The worlds of a game are joint by dependencies. These are /-- The worlds of a game are joint by dependencies. These are
automatically computed but can also be defined with the syntax automatically computed but can also be defined with the syntax
`Dependency World₁ → World₂ → World₃`. -/ `Dependency World₁ → World₂ → World₃`. -/
@ -1120,6 +837,7 @@ elab "MakeGame" : command => do
name := item name := item
displayName := data.displayName displayName := data.displayName
category := data.category category := data.category
altTitle := data.statement
hidden := hiddenItems.contains item }) hidden := hiddenItems.contains item })
@ -1139,6 +857,7 @@ elab "MakeGame" : command => do
displayName := data.displayName displayName := data.displayName
category := data.category category := data.category
locked := false locked := false
altTitle := data.statement
hidden := hiddenItems.contains item } hidden := hiddenItems.contains item }
itemsInWorld := itemsInWorld.insert worldId items itemsInWorld := itemsInWorld.insert worldId items
@ -1158,7 +877,8 @@ elab "MakeGame" : command => do
displayName := data.displayName displayName := data.displayName
category := data.category category := data.category
locked := false locked := false
hidden := levelInfo.hidden.contains item } altTitle := data.statement
hidden := hiddenItems.contains item }
-- add the exercise statement from the previous level -- add the exercise statement from the previous level
-- if it was named -- if it was named
@ -1171,6 +891,7 @@ elab "MakeGame" : command => do
name := name name := name
displayName := data.displayName displayName := data.displayName
category := data.category category := data.category
altTitle := data.statement
locked := false } locked := false }
-- add marks for `disabled` and `new` lemmas here, so that they only apply to -- add marks for `disabled` and `new` lemmas here, so that they only apply to
@ -1190,18 +911,16 @@ elab "MakeGame" : command => do
return level.setComputedInventory inventoryType itemsArray return level.setComputedInventory inventoryType itemsArray
allItemsByType := allItemsByType.insert inventoryType allItems allItemsByType := allItemsByType.insert inventoryType allItems
saveGameData allItemsByType let getTiles (type : InventoryType) : CommandElabM (Array InventoryTile) := do
(allItemsByType.findD type {}).toArray.mapM (fun name => do
/-! # Debugging tools -/ let some item ← getInventoryItem? name type
| throwError "Expected item to exist: {name}"
-- /-- Print current game for debugging purposes. -/ return item.toTile)
-- elab "PrintCurGame" : command => do let inventory : InventoryOverview := {
-- logInfo (toJson (← getCurGame)) lemmas := (← getTiles .Lemma).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
tactics := (← getTiles .Tactic).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
/-- Print current level for debugging purposes. -/ definitions := (← getTiles .Definition).map (fun tile => {tile with hidden := hiddenItems.contains tile.name})
elab "PrintCurLevel" : command => do lemmaTab := none
logInfo (repr (← getCurLevel)) }
/-- Print levels for debugging purposes. -/ saveGameData allItemsByType inventory
elab "PrintLevels" : command => do
logInfo $ repr $ (← getCurWorld).levels.toArray

@ -106,6 +106,8 @@ structure InventoryTile where
new := false new := false
/-- hide the item in the inventory display -/ /-- hide the item in the inventory display -/
hidden := false hidden := false
/-- hover text -/
altTitle : String := default
deriving ToJson, FromJson, Repr, Inhabited deriving ToJson, FromJson, Repr, Inhabited
def InventoryItem.toTile (item : InventoryItem) : InventoryTile := { def InventoryItem.toTile (item : InventoryItem) : InventoryTile := {
@ -148,6 +150,12 @@ structure InventoryOverview where
lemmaTab : Option String lemmaTab : Option String
deriving ToJson, FromJson deriving ToJson, FromJson
-- TODO: Reuse the following code for checking available tactics in user code:
structure UsedInventory where
(tactics : HashSet Name := {})
(definitions : HashSet Name := {})
(lemmas : HashSet Name := {})
/-! ## Environment extensions for game specification -/ /-! ## Environment extensions for game specification -/
/-- Register a (non-persistent) environment extension to hold the current level -/ /-- Register a (non-persistent) environment extension to hold the current level -/
@ -285,6 +293,7 @@ structure LevelInfo where
descrText : Option String := none descrText : Option String := none
descrFormat : String := "" descrFormat : String := ""
lemmaTab : Option String lemmaTab : Option String
module : Name
displayName : Option String displayName : Option String
statementName : Option String statementName : Option String
template : Option String template : Option String
@ -309,6 +318,7 @@ def GameLevel.toInfo (lvl : GameLevel) (env : Environment) : LevelInfo :=
| some tile => tile.category | some tile => tile.category
| none => none | none => none
statementName := lvl.statementName.toString statementName := lvl.statementName.toString
module := lvl.module
displayName := match lvl.statementName with displayName := match lvl.statementName with
| .anonymous => none | .anonymous => none
| name => match (inventoryExt.getState env).find? | name => match (inventoryExt.getState env).find?
@ -373,7 +383,7 @@ structure GameTile where
TODO: What's the format? -/ TODO: What's the format? -/
image: String := default image: String := default
deriving Inhabited, ToJson deriving Inhabited, ToJson, FromJson
structure Game where structure Game where
/-- Internal name of the game. -/ /-- Internal name of the game. -/
@ -393,7 +403,7 @@ structure Game where
tile : GameTile := default tile : GameTile := default
/-- The path to the background image of the world. -/ /-- The path to the background image of the world. -/
image : String := default image : String := default
deriving Inhabited, ToJson deriving Inhabited, ToJson, FromJson
def getGameJson (game : «Game») : Json := Id.run do def getGameJson (game : «Game») : Json := Id.run do
let gameJson : Json := toJson game let gameJson : Json := toJson game

@ -1,7 +1,8 @@
/- This file is mostly copied from `Lean/Server/FileWorker.lean`. -/ /- This file is adapted from `Lean/Server/FileWorker.lean`. -/
import Lean.Server.FileWorker import Lean.Server.FileWorker
import GameServer.Game import GameServer.Game
import GameServer.ImportModules import GameServer.ImportModules
import GameServer.SaveData
namespace MyModule namespace MyModule
open Lean open Lean
@ -17,7 +18,7 @@ private def mkEOI (pos : String.Pos) : Syntax :=
mkNode ``Command.eoi #[atom] mkNode ``Command.eoi #[atom]
partial def parseTactic (inputCtx : InputContext) (pmctx : ParserModuleContext) partial def parseTactic (inputCtx : InputContext) (pmctx : ParserModuleContext)
(mps : ModuleParserState) (messages : MessageLog) (couldBeEndSnap : Bool) : (mps : ModuleParserState) (messages : MessageLog) :
Syntax × ModuleParserState × MessageLog × String.Pos := Id.run do Syntax × ModuleParserState × MessageLog × String.Pos := Id.run do
let mut pos := mps.pos let mut pos := mps.pos
let mut recovering := mps.recovering let mut recovering := mps.recovering
@ -56,6 +57,20 @@ open IO
open Snapshots open Snapshots
open JsonRpc open JsonRpc
structure GameWorkerState :=
inventory : Array String
/--
Check for tactics/theorems that are not unlocked.
0: no check
1: give warnings
2: give errors
-/
difficulty : Nat
levelInfo : LevelInfo
deriving ToJson, FromJson
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
section Elab section Elab
def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) : def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : MessageData) :
@ -73,29 +88,30 @@ def addErrorMessage (info : SourceInfo) (inputCtx : Parser.InputContext) (s : Me
/-- Find all tactics in syntax object that are forbidden according to a /-- Find all tactics in syntax object that are forbidden according to a
set `allowed` of allowed tactics. -/ set `allowed` of allowed tactics. -/
partial def findForbiddenTactics (inputCtx : Parser.InputContext) partial def findForbiddenTactics (inputCtx : Parser.InputContext)
(levelParams : Game.DidOpenLevelParams) (stx : Syntax) : (gameWorkerState : GameWorkerState) (stx : Syntax) :
Elab.Command.CommandElabM Unit := do Elab.Command.CommandElabM Unit := do
let levelInfo := gameWorkerState.levelInfo
match stx with match stx with
| .missing => return () | .missing => return ()
| .node _info _kind args => | .node _info _kind args =>
for arg in args do for arg in args do
findForbiddenTactics inputCtx levelParams arg findForbiddenTactics inputCtx gameWorkerState arg
| .atom info val => | .atom info val =>
-- ignore syntax elements that do not start with a letter -- ignore syntax elements that do not start with a letter
-- and ignore "with" keyword -- and ignore "with" keyword
let allowed := ["with", "fun", "at", "only", "by", "to"] let allowed := ["with", "fun", "at", "only", "by", "to"]
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` let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
match levelParams.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. -- Note: This case means that the tactic will never be introduced in the game.
match levelParams.inventory.find? (· == val) with match gameWorkerState.inventory.find? (· == val) with
| none => | none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!" addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
| some _ => pure () -- tactic is in the inventory, allow it. | some _ => pure () -- tactic is in the inventory, allow it.
| some tac => | some tac =>
if tac.locked then if tac.locked then
match levelParams.inventory.find? (· == val) with match gameWorkerState.inventory.find? (· == val) with
| none => | none =>
addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!" addWarningMessage info s!"You have not unlocked the tactic '{val}' yet!"
| some _ => pure () -- tactic is in the inventory, allow it. | some _ => pure () -- tactic is in the inventory, allow it.
@ -109,10 +125,10 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
let some (.thmInfo ..) := (← getEnv).find? n let some (.thmInfo ..) := (← getEnv).find? n
| return () -- not a theorem -> ignore | return () -- not a theorem -> ignore
-- Forbid the theorem we are proving currently -- Forbid the theorem we are proving currently
if n = levelParams.statementName then if some n = levelInfo.statementName then
addErrorMessage info inputCtx s!"Structural recursion: you can't use '{n}' to proof itself!" addErrorMessage info inputCtx s!"Structural recursion: you can't use '{n}' to proof itself!"
let lemmasAndDefs := levelParams.lemmas ++ levelParams.definitions let lemmasAndDefs := levelInfo.lemmas ++ levelInfo.definitions
match lemmasAndDefs.find? (fun l => l.name == n) with match lemmasAndDefs.find? (fun l => l.name == n) with
| none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!" | none => addWarningMessage info s!"You have not unlocked the lemma/definition '{n}' yet!"
| some lem => | some lem =>
@ -121,7 +137,7 @@ partial def findForbiddenTactics (inputCtx : Parser.InputContext)
else if lem.disabled then else if lem.disabled then
addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!" addWarningMessage info s!"The lemma/definition '{n}' is disabled in this level!"
where addWarningMessage (info : SourceInfo) (s : MessageData) := where addWarningMessage (info : SourceInfo) (s : MessageData) :=
let difficulty := levelParams.difficulty let difficulty := gameWorkerState.difficulty
if difficulty > 0 then if difficulty > 0 then
modify fun st => { st with modify fun st => { st with
messages := st.messages.add { messages := st.messages.add {
@ -137,7 +153,7 @@ where addWarningMessage (info : SourceInfo) (s : MessageData) :=
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) (levelParams : Game.DidOpenLevelParams) (couldBeEndSnap : Bool) (gameWorkerState : GameWorkerState)
(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
@ -168,7 +184,7 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
Elab.Command.catchExceptions Elab.Command.catchExceptions
(getResetInfoTrees *> do (getResetInfoTrees *> do
let some level ← GameServer.getLevelByFileName? initParams inputCtx.fileName let some level ← GameServer.getLevelByFileName? initParams inputCtx.fileName
| throwError "Level not found: {inputCtx.fileName}" | panic! s!"Level not found: {inputCtx.fileName} / {GameServer.levelIdFromFileName? initParams inputCtx.fileName}"
let scope := level.scope let scope := level.scope
-- use open namespaces and options as in the level file -- use open namespaces and options as in the level file
@ -186,12 +202,12 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
currNamespace := scope.currNamespace, currNamespace := scope.currNamespace,
openDecls := scope.openDecls } openDecls := scope.openDecls }
let (tacticStx, cmdParserState, msgLog, endOfWhitespace) := let (tacticStx, cmdParserState, msgLog, endOfWhitespace) :=
MyModule.parseTactic inputCtx pmctx snap.mpState snap.msgLog couldBeEndSnap MyModule.parseTactic inputCtx pmctx snap.mpState snap.msgLog
modify (fun s => { s with messages := msgLog }) modify (fun s => { s with messages := msgLog })
parseResultRef.set (tacticStx, cmdParserState) parseResultRef.set (tacticStx, cmdParserState)
-- Check for forbidden tactics -- Check for forbidden tactics
findForbiddenTactics inputCtx levelParams tacticStx findForbiddenTactics inputCtx gameWorkerState tacticStx
-- Insert invisible `skip` command to make sure we always display the initial goal -- Insert invisible `skip` command to make sure we always display the initial goal
let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[] let skip := Syntax.node (.original default 0 default endOfWhitespace) ``Lean.Parser.Tactic.skip #[]
@ -219,6 +235,7 @@ def compileProof (inputCtx : Parser.InputContext) (snap : Snapshot) (hasWidgets
} }
let (tacticStx, cmdParserState) ← parseResultRef.get let (tacticStx, cmdParserState) ← parseResultRef.get
if tacticStx.isMissing then throwServerError "Tactic execution went wrong. No stx found."
let postCmdSnap : Snapshot := { let postCmdSnap : Snapshot := {
beginPos := tacticStx.getPos?.getD 0 beginPos := tacticStx.getPos?.getD 0
@ -270,7 +287,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)
(levelParams : Game.DidOpenLevelParams) (initParams : Lsp.InitializeParams) (gameWorkerState : GameWorkerState) (initParams : Lsp.InitializeParams)
: AsyncElabM (Option Snapshot) := do : AsyncElabM (Option Snapshot) := do
cancelTk.check cancelTk.check
let s ← get let s ← get
@ -288,7 +305,7 @@ where
-- we can see the current goal even on an empty document -- we can see the current goal even on an empty document
let couldBeEndSnap := s.snaps.size > 1 let couldBeEndSnap := s.snaps.size > 1
let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap let snap ← compileProof m.mkInputContext lastSnap ctx.clientHasWidgets couldBeEndSnap
levelParams initParams gameWorkerState initParams
set { s with snaps := s.snaps.push snap } set { s with snaps := s.snaps.push snap }
-- TODO(MH): check for interrupt with increased precision -- TODO(MH): check for interrupt with increased precision
cancelTk.check cancelTk.check
@ -310,7 +327,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) (levelParams : Game.DidOpenLevelParams) (startAfterMs : UInt32) (gameWorkerState : GameWorkerState)
: 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"
@ -326,21 +343,15 @@ where
publishIleanInfoUpdate m ctx.hOut snaps publishIleanInfoUpdate m ctx.hOut snaps
return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do return AsyncList.ofList snaps.toList ++ AsyncList.delayed (← EIO.asTask (ε := ElabTaskError) (prio := .dedicated) do
IO.sleep startAfterMs IO.sleep startAfterMs
AsyncList.unfoldAsync (nextSnap ctx m cancelTk levelParams ctx.initParams) { snaps }) AsyncList.unfoldAsync (nextSnap ctx m cancelTk gameWorkerState ctx.initParams) { snaps })
end Elab end Elab
structure GameWorkerState :=
(levelParams : Game.DidOpenLevelParams)
abbrev GameWorkerM := StateT GameWorkerState Server.FileWorker.WorkerM
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) : GameWorkerM Unit := do
let s ← get let s ← get
let levelParams := s.levelParams
let ctx ← read let ctx ← read
let oldDoc := (← StateT.lift get).doc let oldDoc := (← StateT.lift get).doc
oldDoc.cancelTk.set oldDoc.cancelTk.set
@ -382,7 +393,7 @@ section Updates
validSnaps := validSnaps.dropLast validSnaps := validSnaps.dropLast
-- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger -- wait for a bit, giving the initial `cancelTk.check` in `nextCmdSnap` time to trigger
-- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable) -- before kicking off any expensive elaboration (TODO: make expensive elaboration cancelable)
unfoldSnaps newMeta validSnaps.toArray cancelTk levelParams 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 }}
@ -397,24 +408,23 @@ section Initialization
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)
(levelParams : Game.DidOpenLevelParams) (initParams : InitializeParams) : (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 := levelParams.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 / (levelParams.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 := levelParams.levelModule : Import }] let env ← importModules' #[{ module := `Init : Import }, { module := module : Import }]
-- return (env, paths)
-- use empty header -- use empty header
let (headerStx, headerParserState, msgLog) ← Parser.parseHeader let (headerStx, headerParserState, msgLog) ← Parser.parseHeader
@ -458,10 +468,11 @@ section Initialization
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)
(levelParams : Game.DidOpenLevelParams) : IO (WorkerContext × WorkerState) := do (gameDir : String) (gameWorkerState : GameWorkerState) : IO (WorkerContext × 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)
levelParams initParams gameDir gameWorkerState.levelInfo.module
let cancelTk ← CancelToken.new let cancelTk ← CancelToken.new
let ctx := let ctx :=
{ hIn := i { hIn := i
@ -472,12 +483,14 @@ section Initialization
clientHasWidgets clientHasWidgets
} }
let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with let cmdSnaps ← EIO.mapTask (t := headerTask) (match · with
| Except.ok (s, _) => unfoldSnaps meta #[s] cancelTk levelParams 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
currHeaderStx := headerStx
importCachingTask? := none
pendingRequests := RBMap.empty pendingRequests := RBMap.empty
rpcSessions := RBMap.empty rpcSessions := RBMap.empty
}) })
@ -509,6 +522,7 @@ section MessageHandling
match method with match method with
| "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange) | "textDocument/didChange" => handle DidChangeTextDocumentParams (handleDidChange)
| "$/cancelRequest" => handle CancelParams (handleCancelRequest ·) | "$/cancelRequest" => handle CancelParams (handleCancelRequest ·)
| "$/setTrace" => pure ()
| "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·) | "$/lean/rpc/release" => handle RpcReleaseParams (handleRpcRelease ·)
| "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·) | "$/lean/rpc/keepAlive" => handle RpcKeepAliveParams (handleRpcKeepAlive ·)
| _ => throwServerError s!"Got unsupported notification method: {method}" | _ => throwServerError s!"Got unsupported notification method: {method}"
@ -547,26 +561,32 @@ section MainLoop
let doc := st.doc let doc := st.doc
doc.cancelTk.set doc.cancelTk.set
return () return ()
| Message.notification "$/game/setInventory" params => | Message.request id "shutdown" none =>
let p := (← parseParams Game.SetInventoryParams (toJson params)) ctx.hOut.writeLspResponse ⟨id, Json.null⟩
let s ← get
set {s with levelParams := {s.levelParams with
inventory := p.inventory,
difficulty := p.difficulty}}
mainLoop mainLoop
| Message.notification method (some params) => | Message.notification method (some params) =>
handleNotification method (toJson params) handleNotification method (toJson params)
mainLoop mainLoop
| _ => throwServerError "Got invalid JSON-RPC message" | _ => throwServerError s!"Got invalid JSON-RPC message: {toJson msg}"
end MainLoop end MainLoop
def initAndRunWorker (i o e : FS.Stream) (opts : Options) : 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
let initParams ← i.readLspRequestAs "initialize" InitializeParams let initRequest ← i.readLspRequestAs "initialize" Game.InitializeParams
o.writeLspResponse {
id := initRequest.id
result := {
capabilities := Watchdog.mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Game Server"
version? := "0.1.1"
}
: InitializeResult
}
}
discard $ i.readLspNotificationAs "initialized" InitializedParams
let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams let ⟨_, param⟩ ← i.readLspNotificationAs "textDocument/didOpen" DidOpenTextDocumentParams
let ⟨_, levelParams⟩ ← i.readLspNotificationAs "$/game/didOpenLevel" Game.DidOpenLevelParams
let doc := param.textDocument let doc := param.textDocument
/- NOTE(WN): `toFileMap` marks line beginnings as immediately following /- NOTE(WN): `toFileMap` marks line beginnings as immediately following
@ -578,9 +598,24 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
let e := e.withPrefix s!"[{param.textDocument.uri}] " let e := e.withPrefix s!"[{param.textDocument.uri}] "
let _ ← IO.setStderr e let _ ← IO.setStderr e
try try
let (ctx, st) ← initializeWorker meta i o e initParams.param opts levelParams let game ← loadGameData gameDir
-- TODO: We misuse the `rootUri` field to the gameName
let rootUri? : Option String := some (toString game.name)
let initParams := {initRequest.param.toLeanInternal with rootUri?}
let some (levelId : LevelId) := GameServer.levelIdFromFileName?
initParams meta.mkInputContext.fileName
| throwServerError s!"Could not determine level ID: {meta.mkInputContext.fileName}"
let levelInfo ← loadLevelData gameDir levelId.world levelId.level
let some initializationOptions := initRequest.param.initializationOptions?
| throwServerError "no initialization options found"
let gameWorkerState : GameWorkerState:= {
inventory := initializationOptions.inventory
difficulty := initializationOptions.difficulty
levelInfo
}
let (ctx, st) ← initializeWorker meta i o e initParams opts gameDir gameWorkerState
let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <| let _ ← StateRefT'.run (s := st) <| ReaderT.run (r := ctx) <|
StateT.run (s := {levelParams := levelParams}) <| (mainLoop) StateT.run (s := gameWorkerState) <| (mainLoop)
return (0 : UInt32) return (0 : UInt32)
catch e => catch e =>
IO.eprintln e IO.eprintln e
@ -590,12 +625,13 @@ def initAndRunWorker (i o e : FS.Stream) (opts : Options) : IO UInt32 := do
message := e.toString }] o message := e.toString }] o
return (1 : UInt32) return (1 : UInt32)
def workerMain (opts : Options) : 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
let e ← IO.getStderr let e ← IO.getStderr
try try
let exitCode ← initAndRunWorker i o e opts let some gameDir := args[1]? | throwServerError "Expected second argument: 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, 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 -- want to do that in the case of the worker processes, which can produce non-terminating tasks evaluating user code
o.flush o.flush

@ -25,75 +25,56 @@ open Lsp
open JsonRpc open JsonRpc
open IO open IO
structure DidOpenLevelParams where /- Game-specific version of `InitializeParams` that allows for extra options: -/
uri : String
gameDir : String
levelModule : Name
tactics : Array InventoryTile
lemmas : Array InventoryTile
definitions : Array InventoryTile
inventory : Array String
/--
Check for tactics/theorems that are not unlocked.
0: no check
1: give warnings
2: give errors
-/
difficulty : Nat
/-- The name of the theorem to be proven in this level. -/
statementName : Name
deriving ToJson, FromJson
structure SetInventoryParams where structure InitializationOptions extends Lean.Lsp.InitializationOptions :=
inventory : Array String
difficulty : Nat difficulty : Nat
deriving ToJson, FromJson inventory : Array String
deriving ToJson, FromJson
def handleDidOpenLevel (params : Json) : GameServerM Unit := do structure InitializeParams where
let p ← parseParams _ params processId? : Option Int := none
let m := p.textDocument clientInfo? : Option ClientInfo := none
-- Execute the regular handling of the `didOpen` event /- We don't support the deprecated rootPath
handleDidOpen p (rootPath? : Option String) -/
let fw ← findFileWorker! m.uri rootUri? : Option String := none
-- let s ← get initializationOptions? : Option InitializationOptions := none
let c ← read capabilities : ClientCapabilities
let some lvl ← GameServer.getLevelByFileName? c.initParams ((System.Uri.fileUriToPath? m.uri).getD m.uri |>.toString) /-- If omitted, we default to off. -/
| do trace : Trace := Trace.off
c.hLog.putStr s!"Level not found: {m.uri} {c.initParams.rootUri?}" workspaceFolders? : Option (Array WorkspaceFolder) := none
c.hLog.flush deriving ToJson
-- Send an extra notification to the file worker to inform it about the level data
let s ← get
fw.stdin.writeLspNotification {
method := "$/game/didOpenLevel"
param := {
uri := m.uri
gameDir := s.gameDir
levelModule := lvl.module
tactics := lvl.tactics.tiles
lemmas := lvl.lemmas.tiles
definitions := lvl.definitions.tiles
inventory := s.inventory
difficulty := s.difficulty
statementName := lvl.statementName
: DidOpenLevelParams
}
}
partial def handleServerEvent (ev : ServerEvent) : GameServerM Bool := do instance : FromJson InitializeParams where
match ev with fromJson? j := do
| ServerEvent.clientMsg msg => let processId? := j.getObjValAs? Int "processId"
match msg with let clientInfo? := j.getObjValAs? ClientInfo "clientInfo"
| Message.notification "$/game/setInventory" params => let rootUri? := j.getObjValAs? String "rootUri"
let p := (← parseParams SetInventoryParams (toJson params)) let initializationOptions? := j.getObjValAs? InitializationOptions "initializationOptions"
let s ← get let capabilities ← j.getObjValAs? ClientCapabilities "capabilities"
set {s with inventory := p.inventory, difficulty := p.difficulty} let trace := (j.getObjValAs? Trace "trace").toOption.getD Trace.off
let st ← read let workspaceFolders? := j.getObjValAs? (Array WorkspaceFolder) "workspaceFolders"
let workers ← st.fileWorkersRef.get return ⟨
for (_, fw) in workers do processId?.toOption,
fw.stdin.writeLspMessage msg clientInfo?.toOption,
rootUri?.toOption,
initializationOptions?.toOption,
capabilities,
trace,
workspaceFolders?.toOption⟩
return true def InitializeParams.toLeanInternal (p : InitializeParams) : Lean.Lsp.InitializeParams :=
| _ => return false {
| _ => return false processId? := p.processId?
clientInfo? := p.clientInfo?
rootUri? := p.rootUri?
initializationOptions? := p.initializationOptions?.map fun o => {
editDelay? := o.editDelay?
hasWidgets? := o.hasWidgets?
}
capabilities := p.capabilities
trace := p.trace
workspaceFolders? := p.workspaceFolders?
}
end Game end Game

@ -18,6 +18,14 @@ instance [ToJson β] : ToJson (Graph Name β) := {
] ]
} }
-- Just a dummy implementation for now:
instance : FromJson (Graph Name β) := {
fromJson? := fun _ => .ok {
nodes := {}
edges := {}
}
}
instance : EmptyCollection (Graph α β) := ⟨default⟩ instance : EmptyCollection (Graph α β) := ⟨default⟩
def Graph.insertNode (g : Graph α β) (a : α) (b : β) := def Graph.insertNode (g : Graph α β) (a : α) (b : β) :=

@ -0,0 +1,190 @@
import Lean
/-! This document contains various things which cluttered `Commands.lean`. -/
open Lean Meta Elab Command
syntax hintArg := atomic(" (" (&"strict" <|> &"hidden") " := " withoutPosition(term) ")")
/-! ## Doc Comment Parsing -/
/-- Read a doc comment and get its content. Return `""` if no doc comment available. -/
def parseDocComment! (doc: Option (TSyntax `Lean.Parser.Command.docComment)) :
CommandElabM String := do
match doc with
| none =>
logWarning "Add a text to this command with `/-- yada yada -/ MyCommand`!"
pure ""
| some s => match s.raw[1] with
| .atom _ val => pure <| val.dropRight 2 |>.trim -- some (val.extract 0 (val.endPos - ⟨2⟩))
| _ => pure "" --panic "not implemented error message" --throwErrorAt s "unexpected doc string{indentD s.raw[1]}"
/-- Read a doc comment and get its content. Return `none` if no doc comment available. -/
def parseDocComment (doc: Option (TSyntax `Lean.Parser.Command.docComment)) :
CommandElabM <| Option String := do
match doc with
| none => pure none
| some _ => parseDocComment! doc
/-- TODO: This is only used to provide some backwards compatibility and you can
replace `parseDocCommentLegacy` with `parseDocComment` in the future. -/
def parseDocCommentLegacy (doc: Option (TSyntax `Lean.Parser.Command.docComment))
(t : Option (TSyntax `str)) : CommandElabM <| String := do
match doc with
| none =>
match t with
| none =>
pure <| ← parseDocComment! doc
| some t =>
logWarningAt t "You should use the new Syntax:
/-- yada yada -/
YourCommand
instead of
YourCommand \"yada yada\"
"
pure t.getString
| some _ =>
match t with
| none =>
pure <| ← parseDocComment! doc
| some t =>
logErrorAt t "You must not provide both, a docstring and a string following the command!
Only use
/-- yada yada -/
YourCommand
and remove the string following it!"
pure <| ← parseDocComment! doc
/-! ## Statement string -/
def getStatement (name : Name) : CommandElabM MessageData := do
return ← addMessageContextPartial (.ofPPFormat { pp := fun
| some ctx => ctx.runMetaM <| PrettyPrinter.ppSignature name
| none => return "that's a bug." })
-- Note: We use `String` because we can't send `MessageData` as json, but
-- `MessageData` might be better for interactive highlighting.
/-- Get a string of the form `my_lemma (n : ) : n + n = 2 * n`.
Note: A statement like `theorem abc : ∀ x : Nat, x ≥ 0` would be turned into
`theorem abc (x : Nat) : x ≥ 0` by `PrettyPrinter.ppSignature`. -/
def getStatementString (name : Name) : CommandElabM String := do
try
return ← (← getStatement name).toString
catch
| _ => throwError m!"Could not find {name} in context."
-- TODO: I think it would be nicer to unresolve Namespaces as much as possible.
/-- A `attr := ...` option for `Statement`. Add attributes to the defined theorem. -/
syntax statementAttr := "(" &"attr" ":=" Parser.Term.attrInstance,* ")"
-- TODO
/-- Remove any spaces at the beginning of a new line -/
partial def removeIndentation (s : String) : String :=
let rec loop (i : String.Pos) (acc : String) (removeSpaces := false) : String :=
let c := s.get i
let i := s.next i
if s.atEnd i then
acc.push c
else if removeSpaces && c == ' ' then
loop i acc (removeSpaces := true)
else if c == '\n' then
loop i (acc.push c) (removeSpaces := true)
else
loop i (acc.push c)
loop ⟨0⟩ ""
/-! ## Loops in Graph-like construct
TODO: Why are we not using graphs here but our own construct `HashMap Name (HashSet Name)`?
-/
partial def removeTransitiveAux (id : Name) (arrows : HashMap Name (HashSet Name))
(newArrows : HashMap Name (HashSet Name)) (decendants : HashMap Name (HashSet Name)) :
HashMap Name (HashSet Name) × HashMap Name (HashSet Name) := Id.run do
match (newArrows.find? id, decendants.find? id) with
| (some _, some _) => return (newArrows, decendants)
| _ =>
let mut newArr := newArrows
let mut desc := decendants
desc := desc.insert id {} -- mark as worked in case of loops
newArr := newArr.insert id {} -- mark as worked in case of loops
let children := arrows.findD id {}
let mut trimmedChildren := children
let mut theseDescs := children
for child in children do
(newArr, desc) := removeTransitiveAux child arrows newArr desc
let childDescs := desc.findD child {}
theseDescs := theseDescs.insertMany childDescs
for d in childDescs do
trimmedChildren := trimmedChildren.erase d
desc := desc.insert id theseDescs
newArr := newArr.insert id trimmedChildren
return (newArr, desc)
def removeTransitive (arrows : HashMap Name (HashSet Name)) : CommandElabM (HashMap Name (HashSet Name)) := do
let mut newArr := {}
let mut desc := {}
for id in arrows.toArray.map Prod.fst do
(newArr, desc) := removeTransitiveAux id arrows newArr desc
if (desc.findD id {}).contains id then
logError <| m!"Loop at {id}. " ++
m!"This should not happen and probably means that `findLoops` has a bug."
-- DEBUG:
-- for ⟨x, hx⟩ in desc.toList do
-- m := m ++ m!"{x}: {hx.toList}\n"
-- logError m
return newArr
/-- The recursive part of `findLoops`. Finds loops that appear as successors of `node`.
For performance reason it returns a HashSet of visited
nodes as well. This is filled with all nodes ever looked at as they cannot be
part of a loop anymore. -/
partial def findLoopsAux (arrows : HashMap Name (HashSet Name)) (node : Name)
(path : Array Name := #[]) (visited : HashSet Name := {}) :
Array Name × HashSet Name := Id.run do
let mut visited := visited
match path.getIdx? node with
| some i =>
-- Found a loop: `node` is already the iᵗʰ element of the path
return (path.extract i path.size, visited.insert node)
| none =>
for successor in arrows.findD node {} do
-- If we already visited the successor, it cannot be part of a loop anymore
if visited.contains successor then
continue
-- Find any loop involving `successor`
let (loop, _) := findLoopsAux arrows successor (path.push node) visited
visited := visited.insert successor
-- No loop found in the dependants of `successor`
if loop.isEmpty then
continue
-- Found a loop, return it
return (loop, visited)
return (#[], visited.insert node)
/-- Find a loop in the graph and return it. Returns `[]` if there are no loops. -/
partial def findLoops (arrows : HashMap Name (HashSet Name)) : List Name := Id.run do
let mut visited : HashSet Name := {}
for node in arrows.toArray.map (·.1) do
-- Skip a node if it was already visited
if visited.contains node then
continue
-- `findLoopsAux` returns a loop or `[]` together with a set of nodes it visited on its
-- search starting from `node`
let (loop, moreVisited) := (findLoopsAux arrows node (visited := visited))
visited := moreVisited
if !loop.isEmpty then
return loop.toList
return []

@ -0,0 +1,152 @@
import Lean
import GameServer.EnvExtensions
open Lean Elab Command
/-- Copied from `Mathlib.Tactic.HelpCmd`.
Gets the initial string token in a parser description. For example, for a declaration like
`syntax "bla" "baz" term : tactic`, it returns `some "bla"`. Returns `none` for syntax declarations
that don't start with a string constant. -/
partial def getHeadTk (e : Expr) : Option String :=
match (Expr.withApp e λ e a => (e.constName?.getD Name.anonymous, a)) with
| (``ParserDescr.node, #[_, _, p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "withPosition")), p]) => getHeadTk p
| (``ParserDescr.unary, #[.app _ (.lit (.strVal "atomic")), p]) => getHeadTk p
| (``ParserDescr.binary, #[.app _ (.lit (.strVal "andthen")), p, _]) => getHeadTk p
| (``ParserDescr.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``ParserDescr.symbol, #[.lit (.strVal tk)]) => some tk
| (``Parser.withAntiquot, #[_, p]) => getHeadTk p
| (``Parser.leadingNode, #[_, _, p]) => getHeadTk p
| (``HAndThen.hAndThen, #[_, _, _, _, p, _]) => getHeadTk p
| (``Parser.nonReservedSymbol, #[.lit (.strVal tk), _]) => some tk
| (``Parser.symbol, #[.lit (.strVal tk)]) => some tk
| _ => none
/-! ## Doc entries -/
/-- Modified from `#help` in `Mathlib.Tactic.HelpCmd` -/
def getTacticDocstring (env : Environment) (name: Name) : CommandElabM (Option String) := do
let name := name.toString (escape := false)
let mut decls : Lean.RBMap String (Array SyntaxNodeKind) compare := {}
let catName : Name := `tactic
let catStx : Ident := mkIdent catName -- TODO
let some cat := (Parser.parserExtension.getState env).categories.find? catName
| throwErrorAt catStx "{catStx} is not a syntax category"
liftTermElabM <| Term.addCategoryInfo catStx catName
for (k, _) in cat.kinds do
let mut used := false
if let some tk := do getHeadTk (← (← env.find? k).value?) then
let tk := tk.trim
if name ≠ tk then -- was `!name.isPrefixOf tk`
continue
used := true
decls := decls.insert tk ((decls.findD tk #[]).push k)
for (_name, ks) in decls do
for k in ks do
if let some doc ← findDocString? env k then
return doc
logWarning <| m!"Could not find a docstring for tactic {name}, consider adding one " ++
m!"using `TacticDoc {name} \"some doc\"`"
return none
/-- Retrieve the docstring associated to an inventory item. For Tactics, this
is not guaranteed to work. -/
def getDocstring (env : Environment) (name : Name) (type : InventoryType) :
CommandElabM (Option String) :=
match type with
-- for tactics it's a lookup following mathlib's `#help`. not guaranteed to be the correct one.
| .Tactic => getTacticDocstring env name
| .Lemma => findDocString? env name
-- TODO: for definitions not implemented yet, does it work?
| .Definition => findDocString? env name
/-- Checks if `inventoryTemplateExt` contains an entry with `(type, name)` and yields
a warning otherwise. If `template` is provided, it will add such an entry instead of yielding a
warning.
`ref` is the syntax piece. If `name` is not provided, it will use `ident.getId`.
I used this workaround, because I needed a new name (with correct namespace etc)
to be used, and I don't know how to create a new ident with same position but different name.
-/
def checkInventoryDoc (type : InventoryType) (ref : Ident) (name : Name := ref.getId)
(template : Option String := none) : CommandElabM Unit := do
-- note: `name` is an `Ident` (instead of `Name`) for the log messages.
let env ← getEnv
let n := name
-- Find a key with matching `(type, name)`.
match (inventoryTemplateExt.getState env).findIdx?
(fun x => x.name == n && x.type == type) with
-- Nothing to do if the entry exists
| some _ => pure ()
| none =>
match template with
-- Warn about missing documentation
| none =>
let docstring ← match (← getDocstring env name type) with
| some ds =>
logInfoAt ref (m!"Missing {type} Documentation. Using existing docstring. " ++
m!"Add {name}\nAdd `{type}Doc {name}` somewhere above this statement.")
pure s!"*(lean docstring)*\\\n{ds}"
| none =>
logWarningAt ref (m!"Missing {type} Documentation: {name}\nAdd `{type}Doc {name}` " ++
m!"somewhere above this statement.")
pure "(missing)"
-- We just add a dummy entry
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := docstring})
-- Add the default documentation
| some s =>
modifyEnv (inventoryTemplateExt.addEntry · {
type := type
name := name
category := if type == .Lemma then s!"{n.getPrefix}" else ""
content := s })
logInfoAt ref (m!"Missing {type} Documentation: {name}, used default (e.g. provided " ++
m!"docstring) instead. If you want to write a different description, add " ++
m!"`{type}Doc {name}` somewhere above this statement.")
partial def collectUsedInventory (stx : Syntax) (acc : UsedInventory := {}) : CommandElabM UsedInventory := do
match stx with
| .missing => return acc
| .node _info kind args =>
if kind == `GameServer.Tactic.Hint || kind == `GameServer.Tactic.Branch then return acc
return ← args.foldlM (fun acc arg => collectUsedInventory arg acc) acc
| .atom _info val =>
-- ignore syntax elements that do not start with a letter
-- and ignore some standard keywords
let allowed := ["with", "fun", "at", "only", "by"]
if 0 < val.length ∧ val.data[0]!.isAlpha ∧ not (allowed.contains val) then
let val := val.dropRightWhile (fun c => c == '!' || c == '?') -- treat `simp?` and `simp!` like `simp`
return {acc with tactics := acc.tactics.insert val}
else
return acc
| .ident _info _rawVal val _preresolved =>
let ns ←
try resolveGlobalConst (mkIdent val)
catch | _ => pure [] -- catch "unknown constant" error
return ← ns.foldlM (fun acc n => do
if let some (.thmInfo ..) := (← getEnv).find? n then
return {acc with lemmas := acc.lemmas.insertMany ns}
else
return {acc with definitions := acc.definitions.insertMany ns}
) acc
-- #check expandOptDocComment?
def GameLevel.getInventory (level : GameLevel) : InventoryType → InventoryInfo
| .Tactic => level.tactics
| .Definition => level.definitions
| .Lemma => level.lemmas
def GameLevel.setComputedInventory (level : GameLevel) :
InventoryType → Array InventoryTile → GameLevel
| .Tactic, v => {level with tactics := {level.tactics with tiles := v}}
| .Definition, v => {level with definitions := {level.definitions with tiles := v}}
| .Lemma, v => {level with lemmas := {level.lemmas with tiles := v}}

@ -0,0 +1,17 @@
import Lean
/-! This document contains custom options available in the game. -/
/-- Let `MakeGame` print the reasons why the worlds depend on each other. -/
register_option lean4game.showDependencyReasons : Bool := {
defValue := false
descr := "show reasons for calculated world dependencies."
}
/-- Let `MakeGame` print the reasons why the worlds depend on each other.
Note: currently unused in favour of setting `set_option trace.debug true`. -/
register_option lean4game.verbose : Bool := {
defValue := false
descr := "display more info messages to help developing the game."
}

@ -46,8 +46,8 @@ partial def matchExpr (pattern : Expr) (e : Expr) (bij : FVarBijection := {}) :
| .bvar i1, .bvar i2 => if i1 == i2 then bij else none | .bvar i1, .bvar i2 => if i1 == i2 then bij else none
| .fvar i1, .fvar i2 => bij.insert? i1 i2 | .fvar i1, .fvar i2 => bij.insert? i1 i2
| .mvar _, .mvar _ => bij | .mvar _, .mvar _ => bij
| .sort u1, .sort u2 => bij -- TODO? | .sort _u1, .sort _u2 => bij -- TODO?
| .const n1 ls1, .const n2 ls2 => | .const n1 _ls1, .const n2 _ls2 =>
if n1 == n2 then bij else none -- && (← (ls1.zip ls2).allM fun (l1, l2) => Meta.isLevelDefEq l1 l2) if n1 == n2 then bij else none -- && (← (ls1.zip ls2).allM fun (l1, l2) => Meta.isLevelDefEq l1 l2)
| .app f1 a1, .app f2 a2 => | .app f1 a1, .app f2 a2 =>
some bij some bij

@ -0,0 +1,78 @@
import GameServer.EnvExtensions
open Lean Meta Elab Command
/-! ## Copy images -/
open IO.FS System FilePath in
/-- Copies the folder `images/` to `.lake/gamedata/images/` -/
def copyImages : IO Unit := do
let target : FilePath := ".lake" / "gamedata"
if ← FilePath.pathExists "images" then
for file in ← walkDir "images" do
let outFile := target.join file
-- create the directories
if ← file.isDir then
createDirAll outFile
else
if let some parent := outFile.parent then
createDirAll parent
-- copy file
let content ← readBinFile file
writeBinFile outFile content
namespace GameData
def gameDataPath : System.FilePath := ".lake" / "gamedata"
def gameFileName := s!"game.json"
def docFileName := fun (inventoryType : InventoryType) (name : Name) => s!"doc__{inventoryType}__{name}.json"
def levelFileName := fun (worldId : Name) (levelId : Nat) => s!"level__{worldId}__{levelId}.json"
def inventoryFileName := s!"inventory.json"
end GameData
open GameData in
-- TODO: register all of this as ToJson instance?
def saveGameData (allItemsByType : HashMap InventoryType (HashSet Name))
(inventory : InventoryOverview): CommandElabM Unit := do
let game ← getCurGame
let env ← getEnv
let path := (← IO.currentDir) / gameDataPath
if ← path.isDir then
IO.FS.removeDirAll path
IO.FS.createDirAll path
-- copy the images folder
copyImages
for (worldId, world) in game.worlds.nodes.toArray do
for (levelId, level) in world.levels.toArray do
IO.FS.writeFile (path / levelFileName worldId levelId) (toString (toJson (level.toInfo env)))
IO.FS.writeFile (path / gameFileName) (toString (getGameJson game))
for inventoryType in [InventoryType.Lemma, .Tactic, .Definition] do
for name in allItemsByType.findD inventoryType {} do
let some item ← getInventoryItem? name inventoryType
| throwError "Expected item to exist: {name}"
IO.FS.writeFile (path / docFileName inventoryType name) (toString (toJson item))
IO.FS.writeFile (path / inventoryFileName) (toString (toJson inventory))
open GameData
def loadData (f : System.FilePath) (α : Type) [FromJson α] : IO α := do
let str ← IO.FS.readFile f
let json ← match Json.parse str with
| .ok v => pure v
| .error e => throw (IO.userError e)
let data ← match fromJson? json with
| .ok v => pure v
| .error e => throw (IO.userError e)
return data
def loadGameData (gameDir : System.FilePath) : IO Game :=
loadData (gameDir / gameDataPath / gameFileName) Game
def loadLevelData (gameDir : System.FilePath) (worldId : Name) (levelId : Nat) : IO LevelInfo :=
loadData (gameDir / gameDataPath / levelFileName worldId levelId) LevelInfo

@ -1,158 +0,0 @@
/- This file is mostly copied from `Lean/Server/Watchdog.lean`. -/
import Lean.Server.Watchdog
import GameServer.Game
namespace MyServer.Watchdog
open Lean
open Server
open Watchdog
open IO
open Lsp
open JsonRpc
open System.Uri
partial def mainLoop (clientTask : Task ServerEvent) : GameServerM Unit := do
let st ← read
let workers ← st.fileWorkersRef.get
let mut workerTasks := #[]
for (_, fw) in workers do
if let WorkerState.running := fw.state then
workerTasks := workerTasks.push <| fw.commTask.map (ServerEvent.workerEvent fw)
let ev ← IO.waitAny (clientTask :: workerTasks.toList)
if ← Game.handleServerEvent ev then -- handle Game requests
mainLoop (←runClientTask)
else
match ev with
| ServerEvent.clientMsg msg =>
match msg with
| Message.request id "shutdown" _ =>
shutdown
st.hOut.writeLspResponse ⟨id, Json.null⟩
| Message.request id method (some params) =>
handleRequest id method (toJson params)
mainLoop (←runClientTask)
| Message.response .. =>
-- TODO: handle client responses
mainLoop (←runClientTask)
| Message.responseError _ _ e .. =>
throwServerError s!"Unhandled response error: {e}"
| Message.notification method (some params) =>
if method == "textDocument/didOpen" then
-- for lean4game, we need to pass in extra information when a level is opened:
Game.handleDidOpenLevel (← parseParams _ (toJson params))
else
handleNotification method (toJson params)
mainLoop (←runClientTask)
| _ => throwServerError "Got invalid JSON-RPC message"
| ServerEvent.clientError e => throw e
| ServerEvent.workerEvent fw ev =>
match ev with
| WorkerEvent.ioError e =>
throwServerError s!"IO error while processing events for {fw.doc.uri}: {e}"
| WorkerEvent.crashed _ =>
handleCrash fw.doc.uri #[]
mainLoop clientTask
| WorkerEvent.terminated =>
throwServerError "Internal server error: got termination event for worker that should have been removed"
| .importsChanged =>
startFileWorker fw.doc
mainLoop clientTask
def initAndRunWatchdogAux : GameServerM Unit := do
let st ← read
try
discard $ st.hIn.readLspNotificationAs "initialized" InitializedParams
let clientTask ← runClientTask
mainLoop clientTask
catch err =>
shutdown
throw err
/- NOTE(WN): It looks like instead of sending the `exit` notification,
VSCode just closes the stream. In that case, pretend we got an `exit`. -/
let Message.notification "exit" none ←
try st.hIn.readLspMessage
catch _ => pure (Message.notification "exit" none)
| throwServerError "Got `shutdown` request, expected an `exit` notification"
def createEnv (gameDir : String) (module : String) : IO Environment := do
-- Determine search paths of the game project by running `lake env printenv LEAN_PATH`.
let out ← IO.Process.output
{ cwd := gameDir, cmd := "lake", args := #["env","printenv","LEAN_PATH"] }
if out.exitCode != 0 then
throwServerError s!"Error while running Lake: {out.stderr}"
-- Make the paths relative to the current directory
let paths : List System.FilePath := System.SearchPath.parse out.stdout.trim
let currentDir ← IO.currentDir
let paths := paths.map fun p => currentDir / (gameDir : System.FilePath) / p
-- Set the search path
Lean.searchPathRef.set paths
let env ← importModules #[{ module := `Init : Import }, { module := module : Import }] {} 0
return env
def initAndRunWatchdog (args : List String) (i o e : FS.Stream) : IO Unit := do
if args.length < 2 then
throwServerError s!"Expected 1-3 command line arguments in addition to `--server`:
game directory, the name of the main module (optional), and the name of the game (optional)."
let gameDir := args[1]!
let module := if args.length < 3 then defaultGameModule else args[2]!
let gameName := if args.length < 4 then defaultGameName else args[3]!
let workerPath := "./gameserver"
-- TODO: Do the following commands slow us down?
let srcSearchPath ← initSrcSearchPath (← getBuildDir)
let references ← IO.mkRef (← loadReferences)
let fileWorkersRef ← IO.mkRef (RBMap.empty : FileWorkerMap)
let i ← maybeTee "wdIn.txt" false i
let o ← maybeTee "wdOut.txt" true o
let e ← maybeTee "wdErr.txt" true e
let state := {
env := ← createEnv gameDir module,
game := gameName,
gameDir := gameDir,
inventory := #[]
difficulty := 0
}
let initRequest ← i.readLspRequestAs "initialize" InitializeParams
-- We misuse the `rootUri` field to the gameName
let rootUri? := gameName
let initRequest := {initRequest with param := {initRequest.param with rootUri?}}
o.writeLspResponse {
id := initRequest.id
result := {
capabilities := mkLeanServerCapabilities
serverInfo? := some {
name := "Lean 4 Game Server"
version? := "0.1.1"
}
: InitializeResult
}
}
let context : ServerContext := {
hIn := i
hOut := o
hLog := e
args := args
fileWorkersRef := fileWorkersRef
initParams := initRequest.param
workerPath
srcSearchPath
references
}
discard $ ReaderT.run (StateT.run initAndRunWatchdogAux state) context
def watchdogMain (args : List String) : IO UInt32 := do
let i ← IO.getStdin
let o ← IO.getStdout
let e ← IO.getStderr
try
initAndRunWatchdog args i o e
return 0
catch err =>
e.putStrLn s!"Watchdog error: {err}"
return 1
end MyServer.Watchdog

@ -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": "af7f36db6e7e9e395710a70635f915e8e3a0e69b",
"name": "std", "name": "std",
"manifestFile": "lake-manifest.json", "manifestFile": "lake-manifest.json",
"inputRev": "v4.3.0", "inputRev": "v4.4.0",
"inherited": false, "inherited": false,
"configFile": "lakefile.lean"}], "configFile": "lakefile.lean"}],
"name": "GameServer", "name": "GameServer",

@ -12,6 +12,16 @@ lean_lib GameServer
@[default_target] @[default_target]
lean_exe gameserver { lean_exe gameserver {
root := `Main root := `GameServer
supportInterpreter := true supportInterpreter := true
} }
/--
When a package depending on GameServer updates its dependencies,
build the `gameserver` executable.
-/
post_update pkg do
let rootPkg ← getRootPackage
if rootPkg.name = pkg.name then
return -- do not run in GameServer itself
discard <| runBuild gameserver.build >>= (·.await)

@ -1 +1 @@
leanprover/lean4:v4.3.0 leanprover/lean4:v4.4.0

@ -12,5 +12,5 @@
"experimentalDecorators": true, "experimentalDecorators": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
}, },
"exclude": ["server"] "exclude": ["server", "relay"]
} }

@ -8,7 +8,7 @@ export default defineConfig({
//root: 'client/src', //root: 'client/src',
build: { build: {
// Relative to the root // Relative to the root
// Note: This has to match the path in `server/index.mjs` // Note: This has to match the path in `relay/index.mjs`
outDir: 'client/dist', outDir: 'client/dist',
}, },
plugins: [ plugins: [

Loading…
Cancel
Save