Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,092 changes: 1,085 additions & 7 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
"scripts": {
"test": "echo \"Error: no test specified, sorry :/2\" && exit 1",
"dev": "vite --host 0.0.0.0",
"build": "vite build"
"build": "vite build",
"simulate": "tsx src/simulate/main.ts"
},
"keywords": [],
"author": "Andy Bond",
"license": "MIT",
"devDependencies": {
"tsx": "^4.20.6",
"typescript": "^5.8.3",
"vite": "^6.3.5"
},
"dependencies": {
"@tensorflow/tfjs": "^4.22.0"
"@tensorflow/tfjs": "^4.22.0",
"@tensorflow/tfjs-node": "^4.22.0"
}
}
11 changes: 11 additions & 0 deletions src/game/agent/agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { GameState } from "../gamestate";
import { modelName } from "../models";
import { nnAgent } from "./nn";

export interface ComputerAgent {
chooseMove: (gameState: GameState, legalMoveIndices: number[]) => Promise<number>
}


export type Agent = ComputerAgent | 'human';
export type AgentName = 'human' | modelName;

export function agentLookup(name: AgentName): Agent {
if (name === 'human') {
return name;
}
return nnAgent(name);
}
28 changes: 27 additions & 1 deletion src/game/agent/nn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@ import * as tf from '@tensorflow/tfjs';
import { ComputerAgent } from "./agent"
import { GameState } from "../gamestate"
import { modelName, modelCatalogue } from '../models';
import { getBaseUrl } from '../../utils/base_url';
import { isNode } from '../../utils/is_node';

export async function loadModel(name: modelName) {
const modelUrl = `${import.meta.env.BASE_URL}models/${name}/model.json`;

let tf: typeof import("@tensorflow/tfjs");
if (isNode) {
const tfNode = await import("@tensorflow/tfjs-node");
tf = tfNode;
} else {
tf = await import("@tensorflow/tfjs");
}

// Build model URL/path
const base = getBaseUrl();

let modelUrl: string;
if (isNode) {
// for simulating in Node
const path = await import("path");
modelUrl = `file://${path.resolve(base, "models", name, "model.json")}`;
} else {
// running in browser
modelUrl = `${base}models/${name}/model.json`;
}

// console.log("Loading model from:", modelUrl);

const model = await tf.loadLayersModel(modelUrl);
const inputShape = model.inputs[0].shape;
const inputLength = inputShape[1]!;
Expand All @@ -15,6 +40,7 @@ export async function loadModel(name: modelName) {
return model;
}


export const nnAgent = (name: modelName): ComputerAgent => ({
chooseMove: async (gameState: GameState, legalMoveIndices: number[]) => {
const model = await loadModel(name);
Expand Down
16 changes: 11 additions & 5 deletions src/game/game.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Pack } from './pack';
import { GameState, GameStateForUI, GameConfig } from './gamestate';
import { AgentName } from './agent/agent';
import { GameLog, sendGameLog } from './log';

function randomID(): string {
Expand All @@ -26,14 +27,17 @@ export class Game {
public logs: GameLog[] = [];
private currentLog: GameLog;
private gameID: string;
private playerNames: AgentName[];

constructor(
playerNames: string[],
playerNames: AgentName[],
config: GameConfig = defaultConfig,
private simulation: boolean = false,
) {
this.gameID = randomID();
this.state = new GameState(playerNames, config);
this.currentLog = new GameLog(this.gameID, config);
this.currentLog = new GameLog(this.gameID, config, playerNames, this.simulation);
this.playerNames = playerNames;
this.incrementState();
}

Expand All @@ -51,11 +55,13 @@ export class Game {

async incrementState() {
await this.state.increment(this.currentLog);
console.log(this.currentLog);
// console.log(this.currentLog);
if (this.currentLog.complete) {
this.logs.push(this.currentLog);
sendGameLog(this.currentLog);
this.currentLog = new GameLog(this.gameID, this.state.config);
if (!this.simulation) {
sendGameLog(this.currentLog);
}
this.currentLog = new GameLog(this.gameID, this.state.config, this.playerNames, this.simulation);
}
// console.log(this.jsonLogs);
}
Expand Down
7 changes: 3 additions & 4 deletions src/game/gamestate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import { Pack } from './pack';
import { LadderPosition, Player, PlayerName, playerNameArr } from './player';
import { ScoreBreakdown } from './scores';
import { GameLog } from './log';
import { Agent } from './agent/agent';
import { Agent, AgentName, agentLookup } from './agent/agent';
// import { randomAgent } from './agent/random';
import { nnAgent } from './agent/nn';

export type GameMode = 'static' | 'mobile' | 'retromobile';
export type BonusCapping = 'nobonus' | 2 | 3 | 'uncapped';
Expand Down Expand Up @@ -74,10 +73,10 @@ export class GameState {
public advanceSuit: Suit | null = null;
public handNumber: number = 0;

constructor(public playerNames: string[], public config: GameConfig) {
constructor(public playerNames: AgentName[], public config: GameConfig) {
// TODO: more / flexi ??
const playerConfig: PlayerName[] = ['player', 'comp1', 'comp2'];
const agents: Agent[] = ['human', nnAgent("camber"), nnAgent("camber")]
const agents: Agent[] = playerNames.map((name) => agentLookup(name));
this.players = playerNames.map(
(name, i) => new Player(
name,
Expand Down
24 changes: 19 additions & 5 deletions src/game/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Card, Suit } from "./card";
import { Player } from "./player";
import { GameConfig } from "./gamestate";
import { ScoreBreakdown } from "./scores";
import { AgentName } from "./agent/agent";
import { getCommitHash } from "../utils/commit";

declare const __COMMIT_HASH__: string;

Expand All @@ -17,8 +19,6 @@ export class GameLog {
private holdingMultipliers: [Suit, number][][] = [];
// TODO: generalise this if we ever generalise count in app
private playerCount: number = 3;
// TODO: dynamic, better
private bot: string = "camber";
// this allows us to translate player index to position in hand
public dealerIndex: number = -1;
public handNumber: number = -1;
Expand All @@ -30,14 +30,21 @@ export class GameLog {
// each trick is array of [card, playerIndex], along with trump suit + winner index
private tricks: [Suit, [Card, number][], number][] = [];

// i realise this is a typo, but not worth the effort to deal with downstream
public staringScores: number[] = [];
public handScores: [number, ScoreBreakdown][] = [];

public complete: boolean = false;
private version: string = __COMMIT_HASH__;
private logVersion: number = 4;
private version: string = getCommitHash();
private logVersion: number = 5;

constructor(private gameID: string, private config: GameConfig) {}
constructor(
private gameID: string,
private config: GameConfig,
private players: AgentName[],
// mainly to help filter if we accidentally send off simulated data
private simulated: boolean = false,
) {}

captureLadders(ladders: [Card, Player | null][]) {
const sortedLadders: [Card, number | null][] = ladders.map(
Expand Down Expand Up @@ -93,6 +100,13 @@ export class GameLog {
this.holdingMultipliers = playerMultipliers;
}

get finalScores(): number[] {
return Array.from(
this.staringScores,
(_, i) => this.staringScores[i] + this.handScores[i][0]
);
}

get json(): string {
return JSON.stringify(this);
}
Expand Down
2 changes: 1 addition & 1 deletion src/game/scores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class ScoreBreakdown {
);
if (suitScores.length > 1) {
// TODO: error
console.log(`ladderSuit logic issue: ${suitScores}`);
console.log(`Error: ladderSuit logic issue: ${suitScores}`);
}
if (suitScores.length === 0) {
return null;
Expand Down
3 changes: 1 addition & 2 deletions src/interface/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { GameConfig } from "../game/gamestate";
let game: Game;

export function newGame(config: GameConfig): void {
// TODO: use names for display, overriding comp1 comp2 etc which should be internal
game = new Game(['Andy', 'Randy1', 'Randy2'], config);
game = new Game(['human', 'camber', 'camber'], config);
}

export function getGame(): Game {
Expand Down
14 changes: 14 additions & 0 deletions src/simulate/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { roundRobin } from "./simulate";

async function main() {
console.log("Simulating games");

await roundRobin(['arundel', 'bodiam', 'camber'], 5);

console.log("Complete");
}

main().catch((err) => {
console.error("❌ Error:", err);
process.exit(1);
});
11 changes: 11 additions & 0 deletions src/simulate/results.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Simulation results

Approximate:

```json
{
arundel: { played: 135, leaguePoints: 140, totalScore: 53061 },
bodiam: { played: 135, leaguePoints: -270, totalScore: 44814 },
camber: { played: 135, leaguePoints: 160, totalScore: 53060 }
}
```
94 changes: 94 additions & 0 deletions src/simulate/simulate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@

import { Game } from '../game/game';
import { GameConfig } from '../game/gamestate';
import { AgentName } from '../game/agent/agent';

export async function simulate(agents: AgentName[]): Promise<Game> {
const config: GameConfig = {
trumpRule: 'mobile',
capping: 'uncapped',
escalations: 4,
};
let game = new Game(agents, config, true);
let current = game.getGameStateForUI();

// getout for infinite loop
let counter = 0;
const maxCounter = 15000; // should be enough, i think?

// console.log(current);

while ((current.gameState !== 'gameComplete') && counter < maxCounter) {
// console.log("state...")
await game.incrementState();
current = game.getGameStateForUI();
counter++;
}

// console.log(game.logs);
// console.log(counter);
return game;
}

export async function simulateN(agents: AgentName[], n: number): Promise<Partial<Record<AgentName, number>>[]> {
let game: Game;
let scores: Partial<Record<string, any>>[] = [];
let gameRecord: Partial<Record<string, any>>[];
for (let index = 0; index < n; index++) {
console.log(`Simulating: ${index}`)
game = await simulate(agents);
let finalScores = game.logs[game.logs.length - 1].finalScores;
let winningScore = Math.max(...finalScores);
let losingScore = Math.min(...finalScores);
gameRecord = [];
agents.forEach(
(agent, i) => gameRecord.push(
{
agent: agent,
score: finalScores[i],
position: i,
// TODO: some bonus points based on score differences
// TODO: deal with ties properly
leaguePoints: finalScores[i] === winningScore ? 10 : (finalScores[i] === losingScore ? -10 : 0)
}
)
);
scores.push(...gameRecord);
}
return scores;
}

function product<T>(...arrays: T[][]): T[][] {
return arrays.reduce<T[][]>(
(acc, curr) =>
acc.flatMap(a => curr.map(b => [...a, b])),
[[]]
);
}

export async function roundRobin(agents: AgentName[], n: number) {
let allScores: Partial<Record<string, any>>[] = [];
let results: Partial<Record<AgentName, Record<string, number>>> = {};
for (const [a1, a2, a3] of product(agents, agents, agents)) {
console.log([a1, a2, a3]);
let scores = await simulateN([a1, a2, a3], n);
allScores.push(...scores);
}
console.log(allScores);
agents.forEach(
(agent) => {
let onlyAgent = allScores.filter(
scoreInfo => scoreInfo.agent === agent
);
let totalPoints = onlyAgent.map(
scoreInfo => scoreInfo.leaguePoints
).reduce((a, b) => a + b);
results[agent] = {
played: onlyAgent.length,
leaguePoints: totalPoints,
totalScore: onlyAgent.map(scoreInfo => scoreInfo.score).reduce((a, b) => a + b),
};
}
);
console.log(results);
}
19 changes: 19 additions & 0 deletions src/utils/base_url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import path from "path";

import { isNode } from "./is_node";

function getFilePath() {
return path.resolve(process.cwd(), "static/");
}


export function getBaseUrl(): string {

if (isNode) {
// simulation
return path.resolve(process.cwd(), "static");
} else {
// in-browser
return import.meta.env.BASE_URL || "/scalade/";
}
}
16 changes: 16 additions & 0 deletions src/utils/commit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
declare const __COMMIT_HASH__: string | undefined;

export function getCommitHash(): string {
if (typeof __COMMIT_HASH__ !== "undefined") {
return __COMMIT_HASH__;
}

try {
return require("child_process")
.execSync("git rev-parse --short HEAD")
.toString()
.trim();
} catch {
return "unknown";
}
}
2 changes: 2 additions & 0 deletions src/utils/is_node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const isNode =
typeof process !== "undefined" && process.release?.name === "node";
Loading