Merge branch 'main' of github.com:leanprover-community/lean4game

pull/79/head
Jon Eugster 2 years ago
commit 572be60c65

@ -16,7 +16,7 @@ function App() {
const params = useParams();
return (
<div className="app">
<GameIdContext.Provider value={params.gameId}>
<GameIdContext.Provider value={"g/" + params.owner + "/" + params.repo}>
<Outlet />
</GameIdContext.Provider>
</div>

@ -1,164 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Lean Game Server</title>
<link rel="stylesheet" href="frontpage.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/katex.min.css" integrity="sha384-3UiQGuEI4TTMaFmGIZumfRPtfKQ3trwQE2JgosJxCnGmQpL/lJdjpcHkaaFwHlcI" crossorigin="anonymous">
<!-- The loading of KaTeX is deferred to speed up page rendering -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/katex.min.js" integrity="sha384-G0zcxDFp5LWZtDuRMnBkk3EphCK1lhEf4UEyEM693ka574TZGwo4IWwS6QLzM/2t" crossorigin="anonymous"></script>
<!-- To automatically render math in text elements, include the auto-render extension: -->
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.7/dist/contrib/auto-render.min.js" integrity="sha384-+VBxd3r6XgURycqtZ117nYw44OOcIax56Z4dCRWbxyPt0Koah1uHoK0o4+/RRE05" crossorigin="anonymous"
onload="renderMathInElement(document.body);"></script>
</head>
<body>
<h1>Lean Game Server</h1>
<p>Welcome to the Lean Game Server where you can find interactive learning
games about <a target="_blank" href="https://leanprover-community.github.io/">Lean</a>.<p>
<div class="game-list">
<div class="game" onclick="location.href='#/game/adam';" style="cursor: pointer;">
<div class="wrapper">
<div class="title">Formaloversum</div>
<div class="short-description">Erkunde das Leansche Universum mit deinem Robo,
welcher dir bei der Verständigung mit den Formalosophen zur Seite steht.
</div>
<img class="image" src="data/formaloversum.png" alt="">
<div class="long description">
<p>Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach
verschiedene Bereiche des Bachelorstudiums an.</p>
<p>(Das Spiel befindet sich noch in der Entstehungsphase.)</p>
<p>Das Spiel wurde im Rahmen des Projekts <a target="_blank" href="https://hhu-adam.github.io/">ADAM</a>
an der HHU in Düsseldorf
entwickelt.</p>
</div>
</div>
<table class="info">
<tr>
<td title="consider playing these games first.">Prerequisites</td>
<td>-</td>
<!-- <td>
<ul>
<li>-</li>
</ul>
</td> -->
</tr>
<tr>
<td>Worlds</td>
<td>?</td>
</tr>
<tr>
<td>Levels</td>
<td>?</td>
</tr>
<tr>
<td>Language</td>
<td title="in German">🇩🇪</td>
</tr>
</table>
</div>
<div class="game" onclick="location.href='#/game/nng';" style="cursor: pointer;">
<div class="wrapper">
<div class="title">Natural Number Game</div>
<div class="short-description">
The classical introduction game for Lean.
</div>
<div class="image" src="data/formaloversum.png" alt=""></div>
<div class="long description">
<p>In this game you recreate the natural numbers
\(\mathbb{N}\) from the Peano axioms, learning the basics
about theorem proving in Lean.</p>
<p>This is a good first introduction to Lean!
</p>
</div>
</div>
<table class="info">
<tr>
<td title="consider playing these games first.">Prerequisites</td>
<td>-</td>
<!-- <td>
<ul>
<li>-</li>
</ul>
</td> -->
</tr>
<tr>
<td>Worlds</td>
<td>9</td>
</tr>
<tr>
<td>Levels</td>
<td>72</td>
</tr>
<tr>
<td>Language</td>
<td title="in English">🇬🇧</td>
</tr>
</table>
</div>
</div>
<h2>Adding new games</h2>
<p>
If you consider writing your own game, you should use the
<a target="_blank" href="https://github.com/hhu-adam/NNG4">NNG Github Repo</a>
as a template.
</p>
<p>
There will be an option to load and run games through the server
directly by specifying a URL, but this is still in development.
</p>
<p>
To add games to this page, you should get in contact as
games will need to be added manually.
</p>
<h2>Impressum</h2>
<p>
When running a game, our server collects metadata
(such as IP address, browser, operating system)
and the data that the user enters into the editor.
The data is used to compute the Lean output and display it to the user.
The information will be stored as long as the user stays on our
website and will be deleted immediately afterwards.
We keep logs to improve our software, but the contained
data is anonymized.
</p>
<p>
We do not use cookies, but the game progress is stored in the
browser as site data. The game progress is not saved on the server;
if you delete your browser storage, it will be completely gone.
</p>
<p>Our server is located in Germany.</p>
<h3>Contact information</h3>
<p>
Jon Eugster<br>
Mathematisches Institut der Heinrich-Heine-Universität Düsseldorf<br>
Universitätsstr. 1<br>
40225 Düsseldorf<br>
Germany<br>
<a target="_blank" href="https://www.math.hhu.de/en/lehrstuehle-/-personen-/-ansprechpartner/innen/lehrstuehle-des-mathematischen-instituts/lehrstuhl-fuer-algebraische-geometrie/team/jon-eugster">Contact Details</a>
<p>
<div class="github-link">
<a target="_blank" href="https://github.com/leanprover-community/lean4game"
title="View the lean game server on github">
<svg height="32" aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="24" data-view-component="true" class="octicon octicon-mark-github v-align-middle">
<path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>
</a>
</div>
</body>
</html>

@ -47,8 +47,7 @@ function GameTile({
let navigate = useNavigate();
const routeChange = () =>{
let path = `game/${gameId}`;
navigate(path);
navigate(gameId);
}
return <div className="game" onClick={routeChange}>
@ -108,7 +107,7 @@ function LandingPage() {
<GameTile
title="Formaloversum"
gameId="adam"
gameId="g/hhu-adam/Robo"
intro="Erkunde das Leansche Universum mit deinem Robo, welcher dir bei der Verständigung mit den Formalosophen zur Seite steht."
description="
Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet danach verschiedene Bereiche des Bachelorstudiums an.
@ -120,7 +119,7 @@ Dieses Spiel führt die Grundlagen zur Beweisführung in Lean ein und schneidet
<GameTile
title="Natural Number Game"
gameId="nng"
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.

@ -221,8 +221,8 @@ function PlayableLevel({worldId, levelId}) {
</div>
}
{levelId >= gameInfo.data?.worldSize[worldId] ?
<Button to={`/game/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/game/${gameId}/world/${worldId}/level/${levelId + 1}`}>
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}>
Next&nbsp;<FontAwesomeIcon icon={faArrowRight} /></Button>}
</div>}
@ -257,8 +257,8 @@ function Introduction({worldId}) {
</div>
<div className="conclusion">
{0 == gameInfo.data?.worldSize[worldId] ?
<Button to={`/game/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/game/${gameId}/world/${worldId}/level/1`}>
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button> :
<Button to={`/${gameId}/world/${worldId}/level/1`}>
Start&nbsp;<FontAwesomeIcon icon={faArrowRight} />
</Button>}
</div>
@ -272,7 +272,7 @@ function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
return <div className="app-bar" style={isLoading ? {display: "none"} : null} >
<div>
<Button to={`/game/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button>
<Button to={`/${gameId}`}><FontAwesomeIcon icon={faHome} /></Button>
<span className="app-bar-title">
{gameInfo.data?.worlds.nodes[worldId].title && `World: ${gameInfo.data?.worlds.nodes[worldId].title}`}
</span>
@ -282,10 +282,10 @@ function LevelAppBar({isLoading, levelId, worldId, levelTitle}) {
{levelTitle}
</span>
<Button disabled={levelId <= 0} inverted={true}
to={`/game/${gameId}/world/${worldId}/level/${levelId - 1}`}
to={`/${gameId}/world/${worldId}/level/${levelId - 1}`}
><FontAwesomeIcon icon={faArrowLeft} />&nbsp;Previous</Button>
<Button disabled={levelId >= gameInfo.data?.worldSize[worldId]} inverted={true}
to={`/game/${gameId}/world/${worldId}/level/${levelId + 1}`}
to={`/${gameId}/world/${worldId}/level/${levelId + 1}`}
>Next&nbsp;<FontAwesomeIcon icon={faArrowRight} /></Button>
</div>

@ -39,7 +39,7 @@ function LevelIcon({ worldId, levelId, position }) {
// TODO: relative positioning?
return (
<Link to={`/game/${gameId}/world/${worldId}/level/${levelId}`}>
<Link to={`/${gameId}/world/${worldId}/level/${levelId}`}>
<circle fill={completed ? "green" :"#999"} cx={x} cy={y} r={r} />
</Link>
)
@ -76,13 +76,13 @@ function Welcome() {
for (let i = 1; i <= gameInfo.data.worldSize[id]; i++) {
svgElements.push(
<LevelIcon
key={`/game/${gameId}/world/${id}/level/${i}`}
key={`/${gameId}/world/${id}/level/${i}`}
position={position} worldId={id} levelId={i} />
)
}
svgElements.push(
<Link key={`world${id}`} to={`/game/${gameId}/world/${id}/level/0`}>
<Link key={`world${id}`} to={`/${gameId}/world/${id}/level/0`}>
<circle className="world-circle" cx={s*position.x} cy={s*position.y} r={R}
fill="#1976d2"/>
<foreignObject className="world-title-wrapper" x={s*position.x} y={s*position.y}

@ -22,19 +22,18 @@ const router = createHashRouter([
{
path: "/",
element: <LandingPage />,
// loader: () => redirect("/game/adam")
},
{
path: "/game/:gameId",
path: "/g/:owner/:repo",
element: <App />,
errorElement: <ErrorPage />,
children: [
{
path: "/game/:gameId",
path: "/g/:owner/:repo",
element: <Welcome />,
},
{
path: "/game/:gameId/world/:worldId/level/:levelId",
path: "/g/:owner/:repo/world/:worldId/level/:levelId",
element: <Level />,
},
],

1416
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -17,9 +17,11 @@
"cytoscape-elk": "^2.1.0",
"cytoscape-klay": "^3.1.4",
"debounce": "^1.2.1",
"decompress": "^4.2.1",
"express": "^4.18.2",
"lean4-infoview": "https://gitpkg.now.sh/leanprover/vscode-lean4/lean4-infoview?de0062c",
"lean4web": "github:hhu-adam/lean4web",
"octokit": "^2.0.14",
"path-browserify": "^1.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@ -30,6 +32,8 @@
"rehype-katex": "^6.0.2",
"remark-gfm": "^3.0.1",
"remark-math": "^5.1.1",
"request": "^2.88.2",
"request-progress": "^3.0.0",
"vscode-ws-jsonrpc": "^2.0.1",
"web-worker": "^1.2.0",
"ws": "^8.11.0"
@ -62,10 +66,10 @@
"start": "concurrently -n server,client -c blue,green \"npm run start_server\" \"npm run start_client\"",
"start_server": "cd server && lake build && NODE_ENV=development nodemon -e mjs --exec \"node ./index.mjs\"",
"start_client": "NODE_ENV=development webpack-dev-server --hot",
"build": "npm run build_server && npm run build_client",
"build_server": "server/build.sh",
"build_client": "NODE_ENV=production webpack",
"build": "NODE_ENV=production webpack",
"production": "NODE_ENV=production node server/index.mjs",
"build_robo": "rm -rf ./Robo && git clone https://github.com/hhu-adam/Robo && docker build ./Robo --file ./Robo/Dockerfile --tag github-hhu-adam:Robo && rm -rf ./Robo",
"build_nng": "rm -rf ./NNG4 && git clone https://github.com/hhu-adam/NNG4 && docker build ./NNG4 --file ./NNG4/Dockerfile --tag github-hhu-adam:NNG4 && rm -rf ./NNG4",
"update_lean": "./UPDATE_LEAN.sh"
},
"eslintConfig": {

@ -1,22 +0,0 @@
#!/usr/bin/env sh
# Operate in the directory where this file is located
cd $(dirname $0)
# Build Adam
( rm -rf adam
git clone https://github.com/hhu-adam/Robo adam/
cd adam
docker rmi adam:latest || true
docker build \
--rm -f Dockerfile -t adam:latest .
)
# Build NNG
( rm -rf nng
git clone https://github.com/hhu-adam/NNG4 nng/
cd nng
docker rmi nng:latest || true
docker build \
--rm -f Dockerfile -t nng:latest .
)

@ -0,0 +1,128 @@
import { spawn } from 'child_process'
import fs from 'fs';
import request from 'request'
import decompress from 'decompress'
import requestProgress from 'request-progress'
import { Octokit } from 'octokit';
const TOKEN = process.env.LEAN4GAME_GITHUB_TOKEN
const octokit = new Octokit({
auth: TOKEN
})
const progress = {}
async function runProcess(id, cmd, args, cwd) {
return new Promise((resolve, reject) => {
const ls = spawn(cmd, args, {cwd});
ls.stdout.on('data', (data) => {
progress[id].output += data.toString()
});
ls.stderr.on('data', (data) => {
progress[id].output += data.toString()
});
ls.on('close', (code) => {
resolve()
});
})
}
async function download(id, url, dest) {
return new Promise((resolve, reject) => {
// The options argument is optional so you can omit it
requestProgress(request({
url,
headers: {
'User-Agent': 'abentkamp',
'X-GitHub-Api-Version': '2022-11-28',
'Authorization': 'Bearer '+TOKEN
}
}))
.on('progress', function (state) {
progress[id].output += `Downloaded ${Math.round(state.size.transferred/1024/1024)}MB\n`
})
.on('error', function (err) {
reject(err)
})
.on('end', function () {
resolve()
})
.pipe(fs.createWriteStream(dest));
})
}
async function doImport (owner, repo, id) {
progress[id].output += `Import starting in a few seconds...\n`
await new Promise(resolve => setTimeout(resolve, 3000))
let artifactId = null
try {
const artifacts = await octokit.request('GET /repos/{owner}/{repo}/actions/artifacts', {
owner,
repo,
headers: {
'X-GitHub-Api-Version': '2022-11-28'
}
})
// choose latest artifact
const artifact = artifacts.data.artifacts
.reduce((acc, cur) => acc.created_at < cur.created_at ? cur : acc)
artifactId = artifact.id
const url = artifact.archive_download_url
if (!fs.existsSync("tmp")){
fs.mkdirSync("tmp");
}
progress[id].output += `Download from ${url}\n`
await download(id, url, `tmp/artifact_${artifactId}.zip`)
progress[id].output += `Download finished.\n`
progress[id].output += `Unpacking ZIP.\n`
const files = await decompress(`tmp/artifact_${artifactId}.zip`, `tmp/artifact_${artifactId}`)
if (files.length != 1) { throw Error(`Unexpected number of files in ZIP: ${files.length}`) }
progress[id].output += `Unpacking TAR.\n`
const files_inner = await decompress(`tmp/artifact_${artifactId}/${files[0].path}`, `tmp/artifact_${artifactId}_inner`)
let manifest = fs.readFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`);
manifest = JSON.parse(manifest);
if (manifest.length !== 1) {
throw `Unexpected manifest: ${JSON.stringify(manifest)}`
}
manifest[0].RepoTags = [`github-${owner}:${repo}`]
fs.writeFileSync(`tmp/artifact_${artifactId}_inner/manifest.json`, JSON.stringify(manifest));
await runProcess(id, "tar", ["-cvf", `../archive_${artifactId}.tar`, "."], `tmp/artifact_${artifactId}_inner/`)
await runProcess(id, "docker", ["load", "-i", `tmp/archive_${artifactId}.tar`])
progress[id].done = true
progress[id].output += `Done.\n`
} catch (e) {
progress[id].output += `Error: ${e.toString()}\n${e.stack}`
} finally {
if (artifactId) {
fs.rmSync(`tmp/artifact_${artifactId}.zip`, {force: true, recursive: true});
fs.rmSync(`tmp/artifact_${artifactId}`, {force: true, recursive: true});
fs.rmSync(`tmp/artifact_${artifactId}_inner`, {force: true, recursive: true});
fs.rmSync(`tmp/archive_${artifactId}.tar`, {force: true, recursive: true});
}
progress[id].done = true
}
}
export const importTrigger = (req, res) => {
const owner = req.params.owner
const repo = req.params.repo
const id = req.params.owner + '/' + req.params.repo
if(!/^[\w.-]+\/[\w.-]+$/.test(id)) { res.send(`Invalid repo name ${id}`); return }
if(!progress[id] || progress[id].done) {
progress[id] = {output: "", done: false}
doImport(owner, repo, id)
}
res.redirect(`/import/status/${owner}/${repo}`)
}
export const importStatus = (req, res) => {
const owner = req.params.owner
const repo = req.params.repo
const id = req.params.owner + '/' + req.params.repo
res.send(`<html><head><meta http-equiv="refresh" content="5"></head><body><pre>${progress[id]?.output ?? "Nothing here."}</pre></body></html>`)
}

@ -7,15 +7,22 @@ import * as rpc from 'vscode-ws-jsonrpc';
import * as jsonrpcserver from 'vscode-ws-jsonrpc/server';
import os from 'os';
import anonymize from 'ip-anonymize';
import { importTrigger, importStatus } from './import.mjs'
/** Preloaded games. The keys refer to the docker tags of the virtual machines.
* The number `queueLength` determines how many instances of the docker container
* get started before any user shows up to have them up and running immediately.
* The values `name`, `module`, and `dir` are just used for development where we
* use a project directory instead of a docker container.
*/
const games = {
adam: {
"github-hhu-adam:Robo": {
name: "Adam",
module: "Adam",
dir: "../../../../Robo",
queueLength: 5
},
nng: {
"github-hhu-adam:NNG4": {
name: "NNG",
module: "NNG",
dir: "../../../../NNG4",
@ -30,8 +37,14 @@ const app = express()
const PORT = process.env.PORT || 8080;
var router = express.Router();
router.get('/import/status/:owner/:repo', importStatus)
router.get('/import/trigger/:owner/:repo', importTrigger)
const server = app
.use(express.static(path.join(__dirname, '../client/dist/')))
.use('/', router)
.listen(PORT, () => console.log(`Listening on ${PORT}`));
const wss = new WebSocketServer({ server })
@ -43,17 +56,18 @@ const isDevelopment = environment === 'development'
/** We keep queues of started Lean Server processes to be ready when a user arrives */
const queue = {}
const queueLength = 5
function startServerProcess(gameId) {
const serverProcess = isDevelopment
? cp.spawn("./gameserver",
["--server", games[gameId].dir, games[gameId].module, games[gameId].name],
function startServerProcess(tag) {
let serverProcess
if (isDevelopment && games[tag]?.dir) {
serverProcess = cp.spawn("./gameserver",
["--server", games[tag].dir, games[tag].module, games[tag].name],
{ cwd: "./build/bin/" })
: cp.spawn("docker",
["run", "--runtime=runsc", "--network=none", "--rm", "-i", `${gameId}:latest`,
"./gameserver", "--server", "/game/", games[gameId].module, games[gameId].name],
} else {
serverProcess = cp.spawn("docker",
["run", "--runtime=runsc", "--network=none", "--rm", "-i", `${tag}`],
{ cwd: "." })
}
serverProcess.on('error', error =>
console.error(`Launching Lean Server failed: ${error}`)
);
@ -66,32 +80,35 @@ function startServerProcess(gameId) {
}
/** start Lean Server processes to refill the queue */
function fillQueue(gameId) {
while (queue[gameId].length < games[gameId].queueLength) {
const serverProcess = startServerProcess(gameId)
queue[gameId].push(serverProcess)
function fillQueue(tag) {
while (queue[tag].length < games[tag].queueLength) {
const serverProcess = startServerProcess(tag)
queue[tag].push(serverProcess)
}
}
for (let gameId in games) {
queue[gameId] = []
fillQueue(gameId)
if (!isDevelopment) { // Don't use queue in development
for (let tag in games) {
queue[tag] = []
fillQueue(tag)
}
}
const urlRegEx = new RegExp("^/websocket/(.*)$")
const urlRegEx = /^\/websocket\/g\/([\w.-]+)\/([\w.-]+)$/
wss.addListener("connection", function(ws, req) {
const reRes = urlRegEx.exec(req.url)
if (!reRes) { console.error(`Connection refused because of invalid URL: ${req.url}`); return; }
const gameId = reRes[1]
if (!games[gameId]) { console.error(`Unknown game: ${gameId}`); return; }
const owner = reRes[1]
const repo = reRes[2]
const tag = `github-${owner}:${repo}`
let ps;
if (isDevelopment) { // Don't use queue in development
ps = startServerProcess(gameId)
if (!queue[tag] || queue[tag].length == 0) {
ps = startServerProcess(tag)
} else {
ps = queue[gameId].shift() // Pick the first Lean process; it's likely to be ready immediately
fillQueue(gameId)
ps = queue[tag].shift() // Pick the first Lean process; it's likely to be ready immediately
fillQueue(tag)
}
socketCounter += 1;

@ -68,6 +68,10 @@ module.exports = env => {
target: 'ws://localhost:8080',
ws: true
},
'/import': {
target: 'http://localhost:3000',
router: () => 'http://localhost:8080',
},
},
static: path.join(__dirname, 'client/public/'),
port: 3000,

Loading…
Cancel
Save