frontend with build and publish ci
ci/woodpecker/push/tools-wasm-pack-plugin Pipeline was successful Details
ci/woodpecker/push/wasm-o-x-rust Pipeline was successful Details
ci/woodpecker/push/frontend Pipeline was successful Details

This commit is contained in:
Gleb Koval 2022-08-09 00:38:49 +00:00
parent e5e066bdcc
commit 3afc775f62
Signed by: cyclane
GPG Key ID: 15E168A8B332382C
22 changed files with 5473 additions and 0 deletions

View File

@ -10,3 +10,4 @@ insert_final_newline = true
[*.yml]
indent_style=space
indent_size = 2

49
.woodpecker/frontend.yml Normal file
View File

@ -0,0 +1,49 @@
pipeline:
deps:
image: node:alpine
commands:
- cd frontend
- npm install --save-dev
eslint:
image: node:alpine
commands:
- cd frontend
- npm run lint
group: build
svelte:
image: node:alpine
commands:
- cd frontend
- npm run build
docker build:
image: plugins/docker
settings:
dry_run: true
repo: git.koval.net/cyclane/game-algorithms/frontend
tags: latest
context: frontend
dockerfile: frontend/Dockerfile
when:
branch:
exclude: [main]
path:
include:
- frontend/*
- .woodpecker/frontend.yml
docker build and publish:
image: plugins/docker
settings:
registry: git.koval.net
username: cyclane
password:
from_secret: DEPLOY_TOKEN
repo: git.koval.net/cyclane/game-algorithms/frontend
tags: latest
context: frontend
dockerfile: frontend/Dockerfile
when:
branch: main
path:
include:
- frontend/*
- .woodpecker/frontend.yml

13
frontend/.eslintignore Normal file
View File

@ -0,0 +1,13 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

44
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,44 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:jsdoc/recommended"],
plugins: ["svelte3", "@typescript-eslint", "jsdoc"],
ignorePatterns: ["*.cjs"],
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
settings: {
"svelte3/typescript": () => require("typescript")
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
},
rules: {
semi: 2,
"semi-spacing": ["error", {before: false, after: false}],
"object-curly-spacing": ["error", "always"],
"sort-imports": 2,
indent: ["error", "tab"],
"eol-last": 2,
"max-len": ["error", {code: 120}],
"prefer-const": 2,
quotes: ["error", "double"],
"@typescript-eslint/no-explicit-any": 2,
"@typescript-eslint/no-unused-vars": 2,
"jsdoc/require-description": 2,
"jsdoc/require-jsdoc": 2,
"jsdoc/require-param-description": 2,
"jsdoc/require-param-name": 2,
"jsdoc/require-param-type": 0,
"jsdoc/require-returns": ["error", {forceReturnsWithAsync: true}],
"jsdoc/require-returns-check": 2,
"jsdoc/require-returns-description": 2,
"jsdoc/require-returns-type": 0
}
};

8
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:alpine
COPY build /app/build
COPY package.json /app/package.json
HEALTHCHECK --interval=30s --retries=3 --start-period=10s --timeout=1s \
CMD wget -q --tries=1 --spider http://localhost:3000/ || exit 1
EXPOSE 3000
WORKDIR /app
ENTRYPOINT [ "node", "build" ]

38
frontend/README.md Normal file
View File

@ -0,0 +1,38 @@
# create-svelte
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
# create a new project in my-app
npm create svelte@latest my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.

4820
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
frontend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "frontend",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"lint-fix": "eslint . --fix"
},
"devDependencies": {
"@sveltejs/adapter-node": "next",
"@sveltejs/kit": "next",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
"carbon-components-svelte": "^0.67.7",
"carbon-icons-svelte": "^11.2.0",
"carbon-preprocess-svelte": "^0.9.1",
"eslint": "^8.16.0",
"eslint-plugin-jsdoc": "^39.3.4",
"eslint-plugin-svelte3": "^4.0.0",
"svelte": "^3.44.0",
"svelte-check": "^2.7.1",
"svelte-preprocess": "^4.10.6",
"tslib": "^2.3.1",
"typescript": "^4.7.4",
"vite": "^3.0.0",
"vite-plugin-wasm-pack": "0.1.11"
},
"type": "module",
"dependencies": {
"@game-algorithms/o-x-rust": "^0.0.9"
}
}

11
frontend/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface Platform {}
// interface PrivateEnv {}
// interface PublicEnv {}
// interface Session {}
// interface Stuff {}
}

12
frontend/src/app.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html theme="g100" lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body>
%sveltekit.body%
</body>
</html>

View File

@ -0,0 +1,8 @@
<div></div>
<style>
div {
width: 5rem;
height: 5rem;
}
</style>

View File

@ -0,0 +1,10 @@
<h1></h1>
<style>
h1 {
width: 5rem;
height: 5rem;
text-align: center;
font-size: 4rem;
}
</style>

View File

@ -0,0 +1,10 @@
<h1></h1>
<style>
h1 {
width: 5rem;
height: 5rem;
text-align: center;
font-size: 4rem;
}
</style>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import "carbon-components-svelte/css/all.css";
import {
Content,
Header,
HeaderGlobalAction,
HeaderNav,
HeaderNavItem,
HeaderUtilities,
SkipToContent,
Theme
} from "carbon-components-svelte";
import Moon from "carbon-icons-svelte/lib/Moon.svelte";
import Sun from "carbon-icons-svelte/lib/Sun.svelte";
let dark = true;
</script>
<Theme
theme={dark ? "g100" : "white"}
on:update={update => dark = update.detail.theme === "g100"}
persist persistKey="__carbon-theme"
/>
<Header company="Cyclane" platformName="Game Algorithms">
<svelte:fragment slot="skip-to-content">
<SkipToContent />
</svelte:fragment>
<HeaderNav>
<HeaderNavItem href="/" text="About"/>
<HeaderNavItem href="/o-x" text="Noughts & Crosses"/>
</HeaderNav>
<HeaderUtilities>
<HeaderGlobalAction icon={dark ? Moon : Sun} on:click={() => dark = !dark}/>
</HeaderUtilities>
</Header>
<Content><slot/></Content>

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Column, Grid, Link, Row } from "carbon-components-svelte";
</script>
<Grid>
<Row>
<Column>
<h1>Game Algorithms</h1>
<img
class="link"
alt="CI badge"
src="https://woodpecker.koval.net/api/badges/cyclane/game-algorithms/status.svg"
on:click={() => window.location.href = "https://woodpecker.koval.net/cyclane/game-algorithms"}
/>
<p>
This is a learning project for various board-game alogrithms.
</p>
<p>
Visit the <Link inline href="https://git.koval.net/cyclane/game-algorithms">Git repo</Link> for source code.
</p>
</Column>
</Row>
</Grid>
<style>
h1 {
margin-bottom: var(--cds-layout-01);
}
p {
margin-top: var(--cds-layout-01);
margin-bottom: var(--cds-layout-01);
}
.link {
cursor: pointer;
}
</style>

View File

@ -0,0 +1,280 @@
<script lang="ts">
import {
Button,
Column,
DataTable,
Grid,
ListItem,
RadioButton,
RadioButtonGroup,
Row,
Toggle,
UnorderedList
} from "carbon-components-svelte";
import wasmInit, { count_empty, find_winner, get_score, predict } from "@game-algorithms/o-x-rust";
import OXNoneIcon from "$lib/components/OXNoneIcon.svelte";
import OXOIcon from "$lib/components/OXOIcon.svelte";
import OXXIcon from "$lib/components/OXXIcon.svelte";
import { onMount } from "svelte";
let board: number[][] = [];
for (let i = 0;i < 3;i++) {
board.push([0, 0, 0]);
}
let turn = -1;
let playAs = "X";
let algorithm = "mm+d";
let human = 0;
let bot = 0;
let loaded = false;
let showPreviousScores = false;
let showCurrentScores = false;
let showScoresFor = "human";
let status = "Press 'Start' to play";
// step: cell: [human, bot]
let scores: [number | null, number | null][][] = [];
onMount(async () => {
await wasmInit();
loaded = true;
});
/**
* Complete a turn logic
*/
function completeTurn() {
const convertedBoard = Uint8Array.from(board.flat());
scores = [...scores, getBoardScore(board, human, bot, algorithm)];
const winner = find_winner(convertedBoard);
if (winner !== 0 || count_empty(convertedBoard) === 0) {
turn = -1;
switch (winner) {
case 0:
status = "You drew!";
break;
case human:
status = "You won!";
break;
case bot:
status = `The ${algorithm} algorithm defeated you!`;
break;
default:
status = "How did we get here?";
break;
}
alert(status);
return;
}
turn %= 2;
turn += 1;
if (turn === human) {
status = "Your turn!";
} else {
status = "Algorithm's turn!";
const p = predict(bot, human, bot === 1, convertedBoard, algorithm);
board[Math.floor(p / 3)][p % 3] = bot;
completeTurn();
}
}
/**
* Get the scores for all possible moves on the board for each player
*
* @param board The board
* @param human The human player ID
* @param bot The bot player ID
* @param algorithm The algorithm to use for scoring
* @returns The scores for the moves for each cell and player
*/
function getBoardScore(
board: number[][],
human: number,
bot: number,
algorithm: string
): [number | null, number | null][] {
return board.flat().map((v, i) => {
if (v === 0) {
const humanCopy = board.flat();
humanCopy[i] = human;
const hUint8Array = Uint8Array.from(humanCopy);
const botCopy = board.flat();
botCopy[i] = bot;
const bUint8Array = Uint8Array.from(humanCopy);
return [
get_score(human, bot, human === 1, hUint8Array, algorithm),
get_score(bot, human, bot === 1, bUint8Array, algorithm)
];
} else {
return [null, null];
}
});
}
/**
* Helper function to add up to <dp> decimal places to a number
*
* @param n Number
* @param dp Maximum decimal places
* @returns String formated number
*/
function toFixedOrLess(n: number, dp: number) {
const f = n.toFixed(dp);
const [u, d] = f.split(".");
const minD = d.slice(0, d.indexOf("0"));
if (minD.length === 0) {
return u;
}
return u + "." + minD;
}
/**
* Generate a table row from an array of scores
*
* @param player Player to get scores for
* @param moveOverride Override the move key value
* @returns The generated table row
*/
function scoreToRows(player: string, moveOverride?: string) {
return (score: [number | null, number | null][], i: number) => {
const rowScores: Record<string, string> = {};
for (let cell = 0;cell < 9;cell++) {
const s = score[cell][player === "human" ? 0 : 1];
rowScores[cell.toString()] = s === null ? "-" : toFixedOrLess(s, 3);
}
return {
id: i,
move: moveOverride || i + 1,
...rowScores
};
};
}
</script>
<Grid>
<Row>
<Column>
<h1>Noughts and Crosses</h1>
<p>
This is a noughts and crosses bot using various algorithms.
</p>
<UnorderedList>
<ListItem>sa: score adding without optimizations</ListItem>
<ListItem>sa+rd: score adding with ratio optimization including draw (score between win and loose)</ListItem>
<ListItem>sa+r-d: score adding with ratio optimization not including draw (draw = loss)</ListItem>
<ListItem>mm: minimax</ListItem>
<ListItem>mm+d: minimax with depth</ListItem>
</UnorderedList>
<div class="selection">
<RadioButtonGroup disabled={turn !== -1} legendText="Algorithms" bind:selected={algorithm}>
<RadioButton labelText="sa" value="sa" />
<RadioButton labelText="sa+rd" value="sa+rd" />
<RadioButton labelText="sa+r-d" value="sa+r-d" />
<RadioButton labelText="mm" value="mm" />
<RadioButton labelText="mm+d" value="mm+d" />
</RadioButtonGroup>
</div>
<div class="selection">
<RadioButtonGroup disabled={turn !== -1} legendText="Play as" bind:selected={playAs}>
<RadioButton labelText="Noughts" value="O" />
<RadioButton labelText="Crosses" value="X" />
</RadioButtonGroup>
</div>
<Button disabled={!loaded || turn !== -1} on:click={() => {
board = [];
scores = [];
for (let i = 0;i < 3;i++) {
board.push([0, 0, 0]);
}
turn = 2;
human = Number(playAs === "O") + 1;
bot = human % 2 + 1;
completeTurn();
}}>Start</Button>
</Column>
</Row>
<hr/>
<Row>
<Column>
<h2>Board</h2>
<p>{status}</p>
{#if loaded}
<Grid>
{#each board as row, r}
<Row>
{#each row as col, c}
<Column sm={.1} style="border: 1px solid white;">
<Button
kind="tertiary"
iconDescription={col === 0 ? "Available" : col === 1 ? "Cross" : "Nought"}
icon={col === 0 ? OXNoneIcon : col === 1 ? OXXIcon : OXOIcon}
disabled={board[r][c] !== 0 || turn !== human}
on:click={() => {
board[r][c] = human;
completeTurn();
console.log(scores);
}}
/>
</Column>
{/each}
</Row>
{/each}
</Grid>
{:else}
<h3>Loading...</h3>
{/if}
</Column>
</Row>
<hr/>
<Row>
<Column>
<h2>Scores</h2>
<p>
View scores for each potential move here.
Cells are numbered from 1 to 9 going left-to-right, top-to-bottom.
The table headers show the cell numbers.
</p>
<Toggle labelText="Show previous scores" bind:toggled={showPreviousScores}/>
<Toggle labelText="Show current scores" bind:toggled={showCurrentScores}/>
<div class="selection">
<RadioButtonGroup legendText="Shows scores for" bind:selected={showScoresFor}>
<RadioButton labelText="You" value="human" />
<RadioButton labelText="The algorithm" value="bot" />
</RadioButtonGroup>
</div>
{#if showPreviousScores || showCurrentScores}
<DataTable headers={[
{
key: "move",
value: "Move"
},
...board.flat().map((_, c) => {
return {
key: c.toString(),
value: (c + 1).toString()
};
})
]}
rows={[
...(showPreviousScores ?
scores.slice(0, -1).map(scoreToRows(showScoresFor)) : []
),
...(showCurrentScores && scores.length !== 0 ?
[scoreToRows(showScoresFor, "Now")(scores.slice(-1)[0], scores.length)] : []
)
]}
/>
{/if}
</Column>
</Row>
</Grid>
<style>
h1, h2, h3 {
margin-bottom: var(--cds-layout-01);
}
p, .selection {
margin-top: var(--cds-layout-01);
margin-bottom: var(--cds-layout-01);
}
</style>

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

19
frontend/svelte.config.js Normal file
View File

@ -0,0 +1,19 @@
import adapter from "@sveltejs/adapter-node";
import { optimizeImports } from "carbon-preprocess-svelte";
import preprocess from "svelte-preprocess";
/** @type {import("@sveltejs/kit").Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [
preprocess(),
optimizeImports(),
],
kit: {
adapter: adapter()
}
};
export default config;

14
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "Node"
}
}

11
frontend/vite.config.js Normal file
View File

@ -0,0 +1,11 @@
import { sveltekit } from "@sveltejs/kit/vite";
import wasmPack from "vite-plugin-wasm-pack";
const config = {
plugins: [
sveltekit(),
wasmPack([], ["@game-algorithms/o-x-rust"])
]
};
export default config;