diff --git a/TODO.txt b/TODO.txt index 1aefc1132..fcbb1be99 100644 --- a/TODO.txt +++ b/TODO.txt @@ -216,23 +216,37 @@ * bufix: mini map doesn't load in time DONE 12/7/2023 * bugfix: private game host game doesn't start DONE 12/8/2023 * add NA map DONE 12/8/2023 -* add Oceania map 12/8/2023 -* store in BigQuery -* clicking on a player's name in the rank UI should teleport you to him (pretty useful to know who's who and to locate small nations) -* record commit hash of game +* add Oceania map DONE 12/8/2023 +* max price for units DONE 12/9/2024 +* better unit scaling DONE 12/9/2024 +* make hard & impossible harder DONE 12/9/2024 +* clicking on a player's name in the rank UI should teleport you to him DONE 12/9/2024 +* emojis should be displayed on top of your name not under it DONE 12/9/2024 +* the notification for a successful trade should be shorter, example: " 70k Gold from trade with "X" " DONE 12/9/2024 +* countries don't actually spawn with some randomness, it's always the same exact spawn DONE 12/9/2024 +* you should get a notification and a reward (some money) for eliminating an enemy DONE 12/9/2024 +* alert on attack DONE 12/20/2024 +* alert on unit captured or destroyed 12/20/2024 +* only check islands/clusters when being attacked DONE 12/10/2024 +* only calculate name if tile changes DONE 12/10/2024 +* store in BigQuery DONE 12/10/2024 +* allow longer names and allow them to be displayed in the Rank UI not be cut +* make boats work on lakes (& oceania) * record game winner +* add panama canal NA +* repaint canvas after tab away to prevent blank screen +* on mobile can't click away from build menu * replay stored games -* max price for units -* when player dies, don't remove atom bombs +* record commit hash of game * remove alliance when player dies -* alert on attack -* alert on unit captured or destroyed -* nuking an enemy and accidentally destroying a trade ship shouldn't break the alliance and make you a traitor -* you should get a notification and a reward (some money) for eliminating an enemy (perhaps take wtvr gold the enemy had) -* emojis should be displayed on top of your name not under it -* the notification for a successful trade should be shorter, example: " 70k Gold from trade with "X" " -* countries don't actually spawn with some randomness, it's always the same exact spawn -* allow longer names and allow them to be displayed in the Rank UI not be cut (many are cut for now, even for countries) +* put delay on adjust troop ratio to reduce number of messages +* make clientID & playerID smaller +* remove dash from game id +* pause button in single player +* when hovering over another player have the name not appear ontop of the exit cross +* have a visual thing of who your attacking and how many boats you sent +* make the sliders bigger. Like vertically. +* add way to hide leaderboard n such * create behavior tests * create perf test @@ -265,3 +279,12 @@ FEB 1st * REFACTOR: give terranullius an ID, game.player() returns terranullius * REFACTOR: ocean is considered TerraNullius ? +Error: cannot delete [object Object] not active +Stack: Error: cannot delete [object Object] not active + at UnitImpl.delete (webpack://openfront-client/./src/core/game/UnitImpl.ts?:54:19) + at ShellExecution.tick (webpack://openfront-client/./src/core/execution/ShellExecution.ts?:36:38) + at eval (webpack://openfront-client/./src/core/game/GameImpl.ts?:133:19) + at Array.forEach () + at GameImpl.executeNextTick (webpack://openfront-client/./src/core/game/GameImpl.ts?:131:20) + at GameRunner.tick (webpack://openfront-client/./src/client/GameRunner.ts?:153:21) + at eval (webpack://openfront-client/./src/client/GameRunner.ts?:110:50) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 06a1a2f17..a32e4a502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "openfront-client", "dependencies": { "@datastructures-js/priority-queue": "^6.3.1", + "@google-cloud/bigquery": "^7.9.1", "@google-cloud/storage": "^7.14.0", "@types/dompurify": "^3.0.5", "@types/express": "^4.17.21", @@ -2351,6 +2352,92 @@ "node": ">=18" } }, + "node_modules/@google-cloud/bigquery": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-7.9.1.tgz", + "integrity": "sha512-ZkcRMpBoFLxIh6TiQBywA22yT3c2j0f07AHWEMjtYqMQzZQbFrpxuJU2COp3tyjZ91ZIGHe4gY7/dGZL88cltg==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/common": "^5.0.0", + "@google-cloud/paginator": "^5.0.2", + "@google-cloud/precise-date": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.1", + "big.js": "^6.0.0", + "duplexify": "^4.0.0", + "extend": "^3.0.2", + "is": "^3.3.0", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/bigquery/node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@google-cloud/bigquery/node_modules/big.js": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.2.2.tgz", + "integrity": "sha512-y/ie+Faknx7sZA5MfGA2xKlu0GDv8RWrXGsmlteyJQ2lvoKv9GBK/fpRMc2qlSoBAgNxrixICFCBefIq8WCQpQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bigjs" + } + }, + "node_modules/@google-cloud/bigquery/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@google-cloud/common": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", + "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.1", + "duplexify": "^4.1.1", + "extend": "^3.0.2", + "google-auth-library": "^9.0.0", + "html-entities": "^2.5.2", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/common/node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", @@ -2373,6 +2460,15 @@ "node": ">=8" } }, + "node_modules/@google-cloud/precise-date": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/precise-date/-/precise-date-4.0.0.tgz", + "integrity": "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@google-cloud/projectify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", @@ -8767,6 +8863,15 @@ "node": ">= 0.10" } }, + "node_modules/is": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/is/-/is-3.3.0.tgz", + "integrity": "sha512-nW24QBoPcFGGHJGUwnfpI7Yc5CdqWNdsyHQszVE/z2pKHXzh7FZ5GWhJqSyaQ9wMkQnsTx+kAI8bHlCX4tKdbg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", diff --git a/package.json b/package.json index 6dc47bfcf..e03563acd 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@datastructures-js/priority-queue": "^6.3.1", + "@google-cloud/bigquery": "^7.9.1", "@google-cloud/storage": "^7.14.0", "@types/dompurify": "^3.0.5", "@types/express": "^4.17.21", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index ee52675a5..f7a55cc4f 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -167,13 +167,13 @@ export class HostLobbyModal extends LitElement { } private async handleMapChange(e: Event) { - this.selectedMap = Number((e.target as HTMLSelectElement).value) as GameMap; + this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMap; console.log(`updating map to ${this.selectedMap}`) this.putGameConfig() } private async handleDifficultyChange(e: Event) { - this.selectedDiffculty = Number((e.target as HTMLSelectElement).value) as Difficulty; + this.selectedDiffculty = String((e.target as HTMLSelectElement).value) as Difficulty; console.log(`updating difficulty to ${this.selectedDiffculty}`) this.putGameConfig() } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 28562adfb..5c4f4a902 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -53,6 +53,8 @@ export class LocalServer { console.log('local server ending game') clearInterval(this.endTurnIntervalID) const record = CreateGameRecord(this.gameID, this.gameConfig, this.turns, this.startedAt, Date.now()) + // Clear turns because beacon only supports up to 64kb + record.turns = [] // For unload events, sendBeacon is the only reliable method const blob = new Blob([JSON.stringify(GameRecordSchema.parse(record))], { type: 'application/json' diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index b2810bd39..a4fbfb759 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -84,7 +84,7 @@ export class SinglePlayerModal extends LitElement { .filter(([key]) => isNaN(Number(key))) .map(([key, value]) => html` `)} @@ -96,7 +96,7 @@ export class SinglePlayerModal extends LitElement { .filter(([key]) => isNaN(Number(key))) .map(([key, value]) => html` `)} @@ -117,10 +117,10 @@ export class SinglePlayerModal extends LitElement { } private handleMapChange(e: Event) { - this.selectedMap = Number((e.target as HTMLSelectElement).value) as GameMap; + this.selectedMap = String((e.target as HTMLSelectElement).value) as GameMap; } private handleDifficultyChange(e: Event) { - this.selectedDifficulty = Number((e.target as HTMLSelectElement).value) as Difficulty; + this.selectedDifficulty = String((e.target as HTMLSelectElement).value) as Difficulty; } private startGame() { console.log(`Starting single player game with map: ${GameMap[this.selectedMap]}`); diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 5412ac305..a2c5cff97 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -41,6 +41,7 @@ export function createRenderer(canvas: HTMLCanvasElement, game: Game, eventBus: console.error('EmojiTable element not found in the DOM'); } leaderboard.clientID = clientID + leaderboard.eventBus = eventBus const controlPanel = document.querySelector('control-panel') as ControlPanel; diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 299423d5e..5b59be6d1 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -1,15 +1,23 @@ -import {EventBus} from "../../core/EventBus" -import {Cell, Game} from "../../core/game/Game"; -import {ZoomEvent, DragEvent} from "../InputHandler"; +import { colord } from "colord"; +import { EventBus } from "../../core/EventBus" +import { Cell, Game, Player } from "../../core/game/Game"; +import { calculateBoundingBox, calculateBoundingBoxCenter, manhattanDist } from "../../core/Util"; +import { ZoomEvent, DragEvent } from "../InputHandler"; +import { GoToPlayerEvent } from "./layers/Leaderboard"; +import { placeName } from "./NameBoxCalculator"; export class TransformHandler { public scale: number = 1.8 private offsetX: number = -350 private offsetY: number = -200 + private target: Cell + private intervalID = null + constructor(private game: Game, private eventBus: EventBus, private canvas: HTMLCanvasElement) { this.eventBus.on(ZoomEvent, (e) => this.onZoom(e)) this.eventBus.on(DragEvent, (e) => this.onMove(e)) + this.eventBus.on(GoToPlayerEvent, (e) => this.onGoToPlayer(e)) } boundingRect(): DOMRect { @@ -53,7 +61,6 @@ export class TransformHandler { screenBoundingRect(): [Cell, Cell] { - // Calculate the world point we want to zoom towards const LeftX = (- this.game.width() / 2) / this.scale + this.offsetX; const TopY = (- this.game.height() / 2) / this.scale + this.offsetY; @@ -61,7 +68,6 @@ export class TransformHandler { const gameTopY = TopY + this.game.height() / 2 - // Calculate the world point we want to zoom towards const rightX = (screen.width - this.game.width() / 2) / this.scale + this.offsetX; const rightY = (screen.height - this.game.height() / 2) / this.scale + this.offsetY; @@ -71,7 +77,53 @@ export class TransformHandler { return [new Cell(Math.floor(gameLeftX), Math.floor(gameTopY)), new Cell(Math.floor(gameRightX), Math.floor(gameBottomY))] } + screenCenter(): { screenX: number, screenY: number } { + const [upperLeft, bottomRight] = this.screenBoundingRect() + return { + screenX: upperLeft.x + Math.floor((bottomRight.x - upperLeft.x) / 2), + screenY: upperLeft.y + Math.floor((bottomRight.y - upperLeft.y) / 2) + } + } + + onGoToPlayer(event: GoToPlayerEvent) { + let unused = null; + this.clearTarget(); + [this.target, unused] = placeName(this.game, event.player); + this.intervalID = setInterval(() => this.goTo(), 1) + } + + private goTo() { + const { screenX, screenY } = this.screenCenter() + const screenMapCenter = new Cell(screenX, screenY) + + if (manhattanDist(screenMapCenter, this.target) < 2) { + this.clearTarget() + return + } + + + const dX = Math.abs(screenMapCenter.x - this.target.x) + if (dX > 2) { + const offsetDx = Math.max(1, Math.floor(dX / 25)) + if (screenMapCenter.x > this.target.x) { + this.offsetX -= offsetDx + } else { + this.offsetX += offsetDx + } + } + const dY = Math.abs(screenMapCenter.y - this.target.y) + if (dY > 2) { + const offsetDy = Math.max(1, Math.floor(dY / 25)) + if (screenMapCenter.y > this.target.y) { + this.offsetY -= offsetDy + } else { + this.offsetY += offsetDy + } + } + } + onZoom(event: ZoomEvent) { + this.clearTarget() const oldScale = this.scale; const zoomFactor = 1 + event.delta / 600; this.scale /= zoomFactor; @@ -93,7 +145,16 @@ export class TransformHandler { } onMove(event: DragEvent) { + this.clearTarget() this.offsetX -= event.deltaX / this.scale; this.offsetY -= event.deltaY / this.scale; } + + private clearTarget() { + if (this.intervalID != null) { + clearInterval(this.intervalID) + this.intervalID = null + } + this.target = null + } } \ No newline at end of file diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index 6ead87bf5..14a5afe5a 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -11,7 +11,8 @@ import { Game, Player, PlayerID, - TargetPlayerEvent + TargetPlayerEvent, + UnitEvent } from "../../../core/game/Game"; import { ClientID } from "../../../core/Schemas"; import { Layer } from "./Layer"; @@ -172,15 +173,15 @@ export class EventsDisplay extends LitElement implements Layer { tick() { let remainingEvents = this.events.filter(event => { - const shouldKeep = this.game.ticks() - event.createdAt < 50; + const shouldKeep = this.game.ticks() - event.createdAt < 80; if (!shouldKeep && event.onDelete) { event.onDelete(); } return shouldKeep; }); - if (remainingEvents.length > 5) { - remainingEvents = remainingEvents.slice(-5); + if (remainingEvents.length > 10) { + remainingEvents = remainingEvents.slice(-10); } if (this.events.length !== remainingEvents.length) { diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 8372b661c..5b30a020f 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -4,12 +4,18 @@ import { Layer } from './Layer'; import { Game, Player } from '../../../core/game/Game'; import { ClientID } from '../../../core/Schemas'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { EventBus, GameEvent } from '../../../core/EventBus'; interface Entry { name: string position: number score: string isMyPlayer: boolean + player: Player +} + +export class GoToPlayerEvent implements GameEvent { + constructor(public player: Player) { } } @customElement('leader-board') @@ -17,6 +23,7 @@ export class Leaderboard extends LitElement implements Layer { private game: Game public clientID: ClientID + public eventBus: EventBus init(game: Game) { this.game = game @@ -51,7 +58,8 @@ export class Leaderboard extends LitElement implements Layer { name: player.displayName(), position: index + 1, score: formatPercentage(player.numTilesOwned() / this.game.numLandTiles()), - isMyPlayer: player == myPlayer + isMyPlayer: player == myPlayer, + player: player })); if (myPlayer != null && this.players.find(p => p.isMyPlayer) == null) { @@ -69,13 +77,17 @@ export class Leaderboard extends LitElement implements Layer { position: place, score: formatPercentage(myPlayer.numTilesOwned() / this.game.numLandTiles()), isMyPlayer: true, + player: myPlayer }) } - this.requestUpdate() } + private handleRowClick(player: Player) { + this.eventBus.emit(new GoToPlayerEvent(player)) + } + renderLayer(context: CanvasRenderingContext2D) { } shouldTransform(): boolean { @@ -87,15 +99,15 @@ export class Leaderboard extends LitElement implements Layer { display: block; } img.emoji { - height: 1em; // Match text height - width: auto; // Maintain aspect ratio + height: 1em; + width: auto; } .leaderboard { position: fixed; top: 10px; left: 10px; z-index: 9999; - background-color: rgba(30, 30, 30, 0.7); /* Added transparency */ + background-color: rgba(30, 30, 30, 0.7); padding: 10px; box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); border-radius: 10px; @@ -103,7 +115,7 @@ export class Leaderboard extends LitElement implements Layer { max-height: 80vh; overflow-y: auto; width: 300px; - backdrop-filter: blur(5px); /* Optional: adds a blur effect to content behind the leaderboard */ + backdrop-filter: blur(5px); } table { width: 100%; @@ -112,11 +124,11 @@ export class Leaderboard extends LitElement implements Layer { th, td { padding: 8px; text-align: left; - border-bottom: 1px solid rgba(51, 51, 51, 0.2); /* Made border slightly transparent */ + border-bottom: 1px solid rgba(51, 51, 51, 0.2); color: white; } th { - background-color: rgba(44, 44, 44, 0.5); /* Made header slightly transparent */ + background-color: rgba(44, 44, 44, 0.5); color: white; } .myPlayer { @@ -127,10 +139,14 @@ export class Leaderboard extends LitElement implements Layer { font-size: 1.3em; } tr:nth-child(even) { - background-color: rgba(44, 44, 44, 0.5); /* Made alternating rows slightly transparent */ + background-color: rgba(44, 44, 44, 0.5); } - tr:hover { - background-color: rgba(58, 58, 58, 0.6); /* Made hover effect slightly transparent */ + tbody tr { + cursor: pointer; + transition: background-color 0.2s; + } + tbody tr:hover { + background-color: rgba(78, 78, 78, 0.8); } .hidden { display: none !important; @@ -160,8 +176,11 @@ export class Leaderboard extends LitElement implements Layer { ${this.players - .map((player, index) => html` - + .map((player) => html` + this.handleRowClick(player.player)} + > ${player.position} ${unsafeHTML(player.name)} ${player.score} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index a71928e31..f9ec68216 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -87,15 +87,21 @@ export class NameLayer implements Layer { } } } + const tickRefreshRate = Math.floor(this.refreshRate / 100) // 10 ticks for (const render of this.renders) { + const shouldRecalc = render.boundingBox == null || this.game.ticks() - render.player.lastTileChange() < tickRefreshRate const now = Date.now() if (now - render.lastBoundingCalculated > this.refreshRate) { - render.boundingBox = calculateBoundingBox(render.player.borderTiles()); render.lastBoundingCalculated = now + if (shouldRecalc) { + render.boundingBox = calculateBoundingBox(render.player.borderTiles()); + } } if (render.isVisible && now - render.lastRenderCalc > this.refreshRate) { - this.calculateRenderInfo(render) - render.lastRenderCalc = now + this.rand.nextInt(-50, 50) + render.lastRenderCalc = Date.now() + this.rand.nextInt(0, 100) + if (shouldRecalc) { + this.calculateRenderInfo(render) + } } } } @@ -132,7 +138,6 @@ export class NameLayer implements Layer { render.fontSize = 0 return } - render.lastRenderCalc = Date.now() + this.rand.nextInt(0, 100) const [cell, size] = placeName(this.game, render.player) render.location = cell render.fontSize = Math.max(1, Math.floor(size)) @@ -188,17 +193,6 @@ export class NameLayer implements Layer { ); } - if (myPlayer != null) { - const emojis = render.player.outgoingEmojis().filter(e => e.recipient == AllPlayers || e.recipient == myPlayer) - if (emojis.length > 0) { - context.font = `${render.fontSize * 4}px ${this.theme.font()}`; - context.fillStyle = this.theme.playerInfoColor(render.player.id()).toHex(); - context.textAlign = 'center'; - context.textBaseline = 'middle'; - - context.fillText(emojis[0].emoji, nameCenterX, nameCenterY + render.fontSize / 2); - } - } context.textRendering = "optimizeSpeed"; @@ -211,6 +205,19 @@ export class NameLayer implements Layer { context.font = `bold ${render.fontSize}px ${this.theme.font()}`; context.fillText(renderTroops(render.player.troops()), nameCenterX, nameCenterY + render.fontSize); + + + if (myPlayer != null) { + const emojis = render.player.outgoingEmojis().filter(e => e.recipient == AllPlayers || e.recipient == myPlayer) + if (emojis.length > 0) { + context.font = `${render.fontSize * 4}px ${this.theme.font()}`; + context.fillStyle = this.theme.playerInfoColor(render.player.id()).toHex(); + context.textAlign = 'center'; + context.textBaseline = 'middle'; + + context.fillText(emojis[0].emoji, nameCenterX, nameCenterY + render.fontSize / 2); + } + } } private getPlayer(): Player | null { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 8b22023f5..262539116 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -242,5 +242,6 @@ export const GameRecordSchema = z.object({ durationSeconds: z.number(), date: z.string(), usernames: z.array(z.string()), + num_turns: z.number(), turns: z.array(TurnSchema) }) \ No newline at end of file diff --git a/src/core/Util.ts b/src/core/Util.ts index 9873d9693..813f0b143 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -146,6 +146,14 @@ export function calculateBoundingBox(borderTiles: ReadonlySet): { min: Cel return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) } } +export function calculateBoundingBoxCenter(borderTiles: ReadonlySet): Cell { + const { min, max } = calculateBoundingBox(borderTiles) + return new Cell( + min.x + Math.floor((max.x - min.x) / 2), + min.y + Math.floor((max.y - min.y) / 2) + ) +} + export function inscribed(outer: { min: Cell; max: Cell }, inner: { min: Cell; max: Cell }): boolean { return ( outer.min.x <= inner.min.x && @@ -246,6 +254,7 @@ export function CreateGameRecord(id: GameID, gameConfig: GameConfig, turns: Turn } record.usernames = Array.from(usernames) record.durationSeconds = Math.floor((record.endTimestampMS - record.startTimestampMS) / 1000) + record.num_turns = turns.length return record; } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 41ebc70e2..d08524820 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -1,4 +1,4 @@ -import { GameType, Gold, Player, PlayerID, PlayerInfo, TerraNullius, Tick, Tile, Unit, UnitInfo, UnitType } from "../game/Game"; +import { Difficulty, GameType, Gold, Player, PlayerID, PlayerInfo, TerraNullius, Tick, Tile, Unit, UnitInfo, UnitType } from "../game/Game"; import { Colord, colord } from "colord"; import { devConfig } from "./DevConfig"; import { defaultConfig } from "./DefaultConfig"; @@ -64,7 +64,7 @@ export interface Config { defensePostRange(): number defensePostDefenseBonus(): number falloutDefenseModifier(): number - maxUnitCost(): number + difficultyModifier(difficulty: Difficulty): number } export interface Theme { diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index a1c712391..211b12bf4 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -1,4 +1,4 @@ -import { GameType, Gold, Player, PlayerInfo, PlayerType, TerrainType, TerraNullius, Tick, Tile, Unit, UnitInfo, UnitType } from "../game/Game"; +import { Difficulty, GameType, Gold, Player, PlayerInfo, PlayerType, TerrainType, TerraNullius, Tick, Tile, Unit, UnitInfo, UnitType } from "../game/Game"; import { GameID } from "../Schemas"; import { assertNever, distSort, manhattanDist, simpleHash, within } from "../Util"; import { Config, Theme } from "./Config"; @@ -7,11 +7,20 @@ import { pastelTheme } from "./PastelTheme"; export class DefaultConfig implements Config { - - maxUnitCost(): number { - return 99_999_999 + difficultyModifier(difficulty: Difficulty): number { + switch (difficulty) { + case Difficulty.Easy: + return 1 + case Difficulty.Medium: + return 3 + case Difficulty.Hard: + return 9 + case Difficulty.Impossible: + return 18 + } } + cityPopulationIncrease(): number { return 250_000 } @@ -38,71 +47,76 @@ export class DefaultConfig implements Config { } unitInfo(type: UnitType): UnitInfo { - const fn = () => { - switch (type) { - case UnitType.TransportShip: - return { - cost: () => 0, - territoryBound: false - } - case UnitType.Destroyer: - return { - cost: (p: Player) => (p.units(UnitType.Destroyer).length + 1) * 250_000, - territoryBound: false - } - case UnitType.Battleship: - return { - cost: (p: Player) => (p.units(UnitType.Battleship).length + 1) * 500_000, - territoryBound: false - } - case UnitType.Shell: - return { - cost: (p: Player) => 0, - territoryBound: false - } - case UnitType.Port: - return { - cost: (p: Player) => Math.pow(2, p.units(UnitType.Port).length) * 250_000, - territoryBound: true - } - case UnitType.AtomBomb: - return { - cost: () => 1_000_000, - territoryBound: false - } - case UnitType.HydrogenBomb: - return { - cost: () => 5_000_000, - territoryBound: false - } - case UnitType.TradeShip: - return { - cost: () => 0, - territoryBound: false - } - case UnitType.MissileSilo: - return { - cost: () => 1_000_000, - territoryBound: true - } - case UnitType.DefensePost: - return { - cost: (p: Player) => Math.pow(2, p.units(UnitType.DefensePost).length) * 100_000, - territoryBound: true - } - case UnitType.City: - return { - cost: (p: Player) => Math.pow(2, p.units(UnitType.City).length) * 250_000, - territoryBound: true - } - default: - assertNever(type) - } + switch (type) { + case UnitType.TransportShip: + return { + cost: () => 0, + territoryBound: false + } + case UnitType.Destroyer: + return { + cost: (p: Player) => (p.units(UnitType.Destroyer).length + 1) * 250_000, + territoryBound: false + } + case UnitType.Battleship: + return { + cost: (p: Player) => (p.units(UnitType.Battleship).length + 1) * 500_000, + territoryBound: false + } + case UnitType.Shell: + return { + cost: () => 0, + territoryBound: false + } + case UnitType.Port: + return { + cost: (p: Player) => + Math.min( + 1_000_000, + Math.pow(2, p.units(UnitType.Port).length) * 250_000 + ), + territoryBound: true + } + case UnitType.AtomBomb: + return { + cost: () => 1_000_000, + territoryBound: false + } + case UnitType.HydrogenBomb: + return { + cost: () => 5_000_000, + territoryBound: false + } + case UnitType.TradeShip: + return { + cost: () => 0, + territoryBound: false + } + case UnitType.MissileSilo: + return { + cost: () => 1_000_000, + territoryBound: true + } + case UnitType.DefensePost: + return { + cost: (p: Player) => + Math.min( + 500_000, + (p.units(UnitType.DefensePost).length + 1) * 100_000 + ), + territoryBound: true + } + case UnitType.City: + return { + cost: (p: Player) => Math.min( + 1_000_000, + Math.pow(2, p.units(UnitType.City).length) * 125_000, + ), + territoryBound: true + } + default: + assertNever(type) } - const ui = fn() - const oldCost = ui.cost - ui.cost = (p: Player) => Math.min(this.maxUnitCost(), oldCost(p)) - return ui } defaultDonationAmount(sender: Player): number { return Math.floor(sender.troops() / 3) diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 29b58a46a..dc15f27d3 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -5,7 +5,7 @@ export const devConfig = new class extends DefaultConfig { unitInfo(type: UnitType): UnitInfo { const info = super.unitInfo(type) const oldCost = info.cost - info.cost = (p: Player) => oldCost(p) / 1000 + info.cost = (p: Player) => oldCost(p) / 1000000 return info } maxUnitCost(): number { @@ -31,9 +31,9 @@ export const devConfig = new class extends DefaultConfig { tradeShipSpawnRate(): number { return 10 } - // boatMaxDistance(): number { - // return 5000 - // } + boatMaxDistance(): number { + return 5000 + } // numBots(): number { // return 0 @@ -42,7 +42,4 @@ export const devConfig = new class extends DefaultConfig { // return false // } - // boatMaxDistance(): number { - // return 2000 - // } } \ No newline at end of file diff --git a/src/core/execution/AttackExecution.ts b/src/core/execution/AttackExecution.ts index 99ee62188..4c8937fd7 100644 --- a/src/core/execution/AttackExecution.ts +++ b/src/core/execution/AttackExecution.ts @@ -1,7 +1,9 @@ import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { Cell, Execution, MutableGame, MutablePlayer, Player, PlayerID, TerrainType, TerraNullius, Tile } from "../game/Game"; +import { Cell, Execution, MutableGame, MutablePlayer, Player, PlayerID, PlayerType, TerrainType, TerraNullius, Tile } from "../game/Game"; import { PseudoRandom } from "../PseudoRandom"; import { manhattanDist } from "../Util"; +import { MessageType } from "../../client/graphics/layers/EventsDisplay"; +import { renderNumber } from "../../client/graphics/Utils"; export class AttackExecution implements Execution { private breakAlliance = false @@ -85,6 +87,9 @@ export class AttackExecution implements Execution { } } } + if (this._owner.type() != PlayerType.Bot && this.target.isPlayer() && this.target.type() == PlayerType.Human) { + mg.displayMessage(`You are being attacked by ${this._owner.displayName()}`, MessageType.ERROR, this._targetID) + } if (this.sourceCell != null) { this.addNeighbors(mg.tile(this.sourceCell)) } else { @@ -157,7 +162,7 @@ export class AttackExecution implements Execution { this.target.removeTroops(defenderTroopLoss) } this._owner.conquer(tileToConquer) - this.checkDefenderDead() + this.handleDeadDefender() } } @@ -198,8 +203,13 @@ export class AttackExecution implements Execution { } } - private checkDefenderDead() { + private handleDeadDefender() { if (this.target.isPlayer() && this.target.numTilesOwned() < 100) { + const gold = this.target.gold() + this.mg.displayMessage(`Conquered ${this.target.displayName()} received ${renderNumber(gold)} gold`, MessageType.SUCCESS, this._owner.id()) + this.target.removeGold(gold) + this._owner.addGold(gold) + for (let i = 0; i < 10; i++) { for (const tile of this.target.tiles()) { if (tile.borders(this._owner)) { diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 4bfbdfdee..8795cc603 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -117,6 +117,7 @@ export class Executor { const execs = [] for (const nation of this.gs.nations()) { execs.push(new FakeHumanExecution( + this.gameID, this.workerClient, new PlayerInfo( nation.name, @@ -125,7 +126,7 @@ export class Executor { this.random.nextID() ), nation.cell, - nation.strength * this.gs.gameConfig().difficulty + nation.strength * this.gs.config().difficultyModifier(this.gs.gameConfig().difficulty) )) } return execs diff --git a/src/core/execution/FakeHumanExecution.ts b/src/core/execution/FakeHumanExecution.ts index d9b70d432..06346e74d 100644 --- a/src/core/execution/FakeHumanExecution.ts +++ b/src/core/execution/FakeHumanExecution.ts @@ -9,6 +9,7 @@ import { ParallelAStar, WorkerClient } from "../worker/WorkerClient"; import { PathFinder } from "../pathfinding/PathFinding"; import { DestroyerExecution } from "./DestroyerExecution"; import { BattleshipExecution } from "./BattleshipExecution"; +import { GameID } from "../Schemas"; export class FakeHumanExecution implements Execution { @@ -25,8 +26,8 @@ export class FakeHumanExecution implements Execution { private relations = new Map() - constructor(private worker: WorkerClient, private playerInfo: PlayerInfo, private cell: Cell, private strength: number) { - this.random = new PseudoRandom(simpleHash(playerInfo.id)) + constructor(gameID: GameID, private worker: WorkerClient, private playerInfo: PlayerInfo, private cell: Cell, private strength: number) { + this.random = new PseudoRandom(simpleHash(playerInfo.id) + simpleHash(gameID)) } init(mg: MutableGame, ticks: number) { diff --git a/src/core/execution/MissileSiloExecution.ts b/src/core/execution/MissileSiloExecution.ts index b341c75ff..5142eb21e 100644 --- a/src/core/execution/MissileSiloExecution.ts +++ b/src/core/execution/MissileSiloExecution.ts @@ -28,12 +28,6 @@ export class MissileSiloExecution implements Execution { } this.silo = this.player.buildUnit(UnitType.MissileSilo, 0, tile) } - - if (!this.silo.tile().hasOwner()) { - this.silo.delete() - this.active = false - return - } } owner(): MutablePlayer { diff --git a/src/core/execution/NukeExecution.ts b/src/core/execution/NukeExecution.ts index ce24c64ac..4b7083a08 100644 --- a/src/core/execution/NukeExecution.ts +++ b/src/core/execution/NukeExecution.ts @@ -74,23 +74,29 @@ export class NukeExecution implements Execution { const ratio = Object.fromEntries( this.mg.players().map(p => [p.id(), (p.troops() + p.workers()) / p.numTilesOwned()]) ) - const others = new Set() + const attacked = new Map() for (const tile of toDestroy) { const owner = tile.owner() if (owner.isPlayer()) { const mp = this.mg.player(owner.id()) mp.relinquish(tile) mp.removeTroops(2 * ratio[mp.id()]) - others.add(mp) + if (!attacked.has(mp)) { + attacked.set(mp, 0) + } + const prev = attacked.get(mp) + attacked.set(mp, prev + 1) } if (tile.isLand()) { this.mg.addFallout(tile) } } - for (const other of others) { - const alliance = this.player.allianceWith(other) - if (alliance != null) { - this.player.breakAlliance(alliance) + for (const [other, tilesDestroyed] of attacked) { + if (tilesDestroyed > 100) { + const alliance = this.player.allianceWith(other) + if (alliance != null) { + this.player.breakAlliance(alliance) + } } } diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 29118918b..4f091a8ba 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -66,11 +66,13 @@ export class PlayerExecution implements Execution { if (ticks - this.lastCalc > this.ticksPerClusterCalc) { this.lastCalc = ticks - const start = performance.now() - this.removeClusters() - const end = performance.now() - if (end - start > 1000) { - console.log(`player ${this.player.name()}, took ${end - start}ms`) + if (ticks - this.player.lastTileChange() < this.ticksPerClusterCalc) { + const start = performance.now() + this.removeClusters() + const end = performance.now() + if (end - start > 1000) { + console.log(`player ${this.player.name()}, took ${end - start}ms`) + } } } } diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index a22923f9a..e47f6ef67 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -20,6 +20,10 @@ export class ShellExecution implements Execution { if (this.shell == null) { this.shell = this._owner.buildUnit(UnitType.Shell, 0, this.spawn) } + if (!this.shell.isActive()) { + this.active = false + return + } if (!this.target.isActive()) { this.shell.delete() this.active = false diff --git a/src/core/execution/TradeShipExecution.ts b/src/core/execution/TradeShipExecution.ts index 018327b8c..ab30075cf 100644 --- a/src/core/execution/TradeShipExecution.ts +++ b/src/core/execution/TradeShipExecution.ts @@ -72,7 +72,7 @@ export class TradeShipExecution implements Execution { const gold = this.mg.config().tradeShipGold(this.srcPort, dstPort) this.tradeShip.owner().addGold(gold) this.mg.displayMessage( - `Your trade ship captured from ${this.origOwner.displayName()}, giving you ${renderNumber(gold)} gold`, + `Received ${renderNumber(gold)} gold from ship captured from ${this.origOwner.displayName()}`, MessageType.SUCCESS, this.tradeShip.owner().id() ) @@ -99,8 +99,8 @@ export class TradeShipExecution implements Execution { const gold = this.mg.config().tradeShipGold(this.srcPort, this.dstPort) this.srcPort.owner().addGold(gold) this.dstPort.owner().addGold(gold) - this.mg.displayMessage(`Trade ship from ${this.tradeShip.owner().displayName()} has reached your port, giving you ${renderNumber(gold)} gold`, MessageType.SUCCESS, this.dstPort.owner().id()) - this.mg.displayMessage(`Your trade ship reached ${this.dstPort.owner().displayName()}, giving you ${renderNumber(gold)} gold`, MessageType.SUCCESS, this._owner) + this.mg.displayMessage(`Received ${renderNumber(gold)} gold from trade with ${this.tradeShip.owner().displayName()}`, MessageType.SUCCESS, this.dstPort.owner().id()) + this.mg.displayMessage(`Received ${renderNumber(gold)} gold from trade with ${this.tradeShip.owner().displayName()}`, MessageType.SUCCESS, this._owner) this.tradeShip.delete() return } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index bcf06c7e8..5d85cbe90 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -11,24 +11,24 @@ export type Gold = number export const AllPlayers = "AllPlayers" as const; export enum Difficulty { - Easy = 1, - Medium = 3, - Hard = 6, - Impossible = 12, + Easy = "Easy", + Medium = "Medium", + Hard = "Hard", + Impossible = "Impossible", } export enum GameMap { - World, - Europe, - Mena, - NorthAmerica, - Oceania + World = "World", + Europe = "Europe", + Mena = "Mena", + NorthAmerica = "North America", + Oceania = "Oceania" } export enum GameType { - Singleplayer, - Public, - Private, + Singleplayer = "Singleplayer", + Public = "Public", + Private = "Private", } export interface UnitInfo { @@ -253,6 +253,7 @@ export interface Player { // If can build returns the spawn tile, false otherwise canBuild(type: UnitType, targetTile: Tile): Tile | false + lastTileChange(): Tick } export interface MutablePlayer extends Player { diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index cd7e325ed..00eca92c5 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -329,12 +329,14 @@ export class GameImpl implements MutableGame { const tileImpl = tile as TileImpl let previousOwner = tileImpl._owner if (previousOwner.isPlayer()) { + previousOwner._lastTileChange = this._ticks previousOwner._tiles.delete(tile.cell().toString()) previousOwner._borderTiles.delete(tile) tileImpl._isBorder = false } tileImpl._owner = owner owner._tiles.set(tile.cell().toString(), tile) + owner._lastTileChange = this._ticks this.updateBorders(tile) tileImpl._hasFallout = false this.eventBus.emit(new TileEvent(tile)) @@ -350,6 +352,7 @@ export class GameImpl implements MutableGame { const tileImpl = tile as TileImpl let previousOwner = tileImpl._owner as PlayerImpl + previousOwner._lastTileChange = this._ticks previousOwner._tiles.delete(tile.cell().toString()) previousOwner._borderTiles.delete(tile) tileImpl._isBorder = false diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index c8507e386..38d2b9602 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -4,7 +4,6 @@ import { assertNever, bfs, closestOceanShoreFromPlayer, dist, distSortUnit, manh import { CellString, GameImpl } from "./GameImpl"; import { UnitImpl } from "./UnitImpl"; import { TileImpl } from "./TileImpl"; -import { TerraNulliusImpl } from "./TerraNulliusImpl"; import { MessageType } from "../../client/graphics/layers/EventsDisplay"; import { renderTroops } from "../../client/graphics/Utils"; @@ -19,6 +18,7 @@ class Donation { export class PlayerImpl implements MutablePlayer { + public _lastTileChange: number = 0 private _gold: Gold private _troops: number @@ -435,6 +435,9 @@ export class PlayerImpl implements MutablePlayer { } return spawns[0].tile() } + lastTileChange(): Tick { + return this._lastTileChange + } hash(): number { return simpleHash(this.id()) * (this.population() + this.numTilesOwned()) + this._units.reduce((acc, unit) => acc + unit.hash(), 0) diff --git a/src/core/game/UnitImpl.ts b/src/core/game/UnitImpl.ts index 804685957..85b34f548 100644 --- a/src/core/game/UnitImpl.ts +++ b/src/core/game/UnitImpl.ts @@ -1,3 +1,4 @@ +import { MessageType } from "../../client/graphics/layers/EventsDisplay"; import { simpleHash } from "../Util"; import { MutableUnit, Tile, TerraNullius, UnitType, Player, UnitInfo } from "./Game"; import { GameImpl } from "./GameImpl"; @@ -46,14 +47,26 @@ export class UnitImpl implements MutableUnit { } setOwner(newOwner: Player): void { + const oldOwner = this._owner this._owner = newOwner as PlayerImpl this.g.fireUnitUpdateEvent(this, this.tile()) + this.g.displayMessage( + `Your ${this.type()} was captured by ${newOwner.displayName()}`, + MessageType.ERROR, + oldOwner.id() + ) } delete(): void { + if (!this.isActive()) { + throw new Error(`cannot delete ${this} not active`) + } this._owner._units = this._owner._units.filter(b => b != this); this._active = false; this.g.fireUnitUpdateEvent(this, this._tile); + if (this.type() != UnitType.AtomBomb && this.type() != UnitType.HydrogenBomb) { + this.g.displayMessage(`Your ${this.type()} was destroyed`, MessageType.ERROR, this.owner().id()) + } } isActive(): boolean { return this._active; diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index adbc3edc1..b868c118b 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -55,7 +55,6 @@ function initializeMap(data: { gameMap: GameMap }) { } function findPath(terrainMap: TerrainMap, miniTerrainMap: TerrainMap, req: SearchRequest) { - console.log(`terrain map height: ${terrainMap.height()}`) const aStar = new MiniAStar( terrainMap, miniTerrainMap, diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 9d5c78eea..f30a52d80 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,17 +1,42 @@ import { GameConfig, GameID, GameRecord, GameRecordSchema, Turn } from "../core/Schemas"; import { Storage } from '@google-cloud/storage'; +import { BigQuery } from '@google-cloud/bigquery'; const storage = new Storage(); +const bigquery = new BigQuery(); + export async function archive(gameRecord: GameRecord) { try { - console.log(`writing game ${gameRecord.id} to gcs`) - const bucket = storage.bucket("openfront-games"); - const file = bucket.file(gameRecord.id); - await file.save(JSON.stringify(GameRecordSchema.parse(gameRecord)), { - contentType: 'application/json' - }); + // Save metadata to BigQuery + const row = { + id: gameRecord.id, + start: new Date(gameRecord.startTimestampMS), + end: new Date(gameRecord.endTimestampMS), + duration_seconds: gameRecord.durationSeconds, + number_turns: gameRecord.num_turns, + usernames: gameRecord.usernames, + game_mode: gameRecord.gameConfig.gameType, + winner: null, + difficulty: gameRecord.gameConfig.difficulty, + map: gameRecord.gameConfig.gameMap, + }; + + await bigquery + .dataset('game_archive') + .table('game_results') + .insert([row]); + + console.log(`wrote game metadata to BigQuery: ${gameRecord.id}`); + if (gameRecord.turns.length > 0) { + console.log(`writing game ${gameRecord.id} to gcs`) + const bucket = storage.bucket("openfront-games"); + const file = bucket.file(gameRecord.id); + await file.save(JSON.stringify(GameRecordSchema.parse(gameRecord)), { + contentType: 'application/json' + }); + } } catch (error) { - console.log(`error writing to gcs: ${error}`) + console.log(`error archiving game record: ${error}`) } } \ No newline at end of file