270 lines
6.8 KiB
Svelte
270 lines
6.8 KiB
Svelte
<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 OXBoard from "$lib/components/o-x/OXBoard.svelte";
|
|
import OXOIcon from "$lib/components/o-x/OXOIcon.svelte";
|
|
import OXXIcon from "$lib/components/o-x/OXXIcon.svelte";
|
|
import { onMount } from "svelte";
|
|
|
|
let board = Array(9).fill(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);
|
|
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[p] = 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.map((v, i) => {
|
|
if (v === 0) {
|
|
const humanCopy = board.slice();
|
|
humanCopy[i] = human;
|
|
const hUint8Array = Uint8Array.from(humanCopy);
|
|
const botCopy = board.slice();
|
|
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 < score.length;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>
|
|
|
|
<svelte:head>
|
|
<title>Noughts & Crosses</title>
|
|
</svelte:head>
|
|
|
|
<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="Algorithm" 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 = Array(9).fill(0);
|
|
scores = [];
|
|
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}
|
|
<OXBoard
|
|
disabled={turn !== human}
|
|
board={board}
|
|
iconMap={{
|
|
1: OXXIcon,
|
|
2: OXOIcon
|
|
}}
|
|
on:click={event => {
|
|
board[event.detail.idx] = human;
|
|
completeTurn();
|
|
}}
|
|
/>
|
|
{: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.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>
|