Merge pull request #14 from evanpelle/dev

Dev
This commit is contained in:
evanpelle
2024-12-10 20:52:03 -08:00
committed by GitHub
30 changed files with 484 additions and 181 deletions
+37 -14
View File
@@ -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 (<anonymous>)
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)
+105
View File
@@ -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",
+1
View File
@@ -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",
+2 -2
View File
@@ -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()
}
+2
View File
@@ -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'
+4 -4
View File
@@ -84,7 +84,7 @@ export class SinglePlayerModal extends LitElement {
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedMap === value}>
${key}
${value}
</option>
`)}
</select>
@@ -96,7 +96,7 @@ export class SinglePlayerModal extends LitElement {
.filter(([key]) => isNaN(Number(key)))
.map(([key, value]) => html`
<option value=${value} ?selected=${this.selectedDifficulty === value}>
${key}
${value}
</option>
`)}
</select>
@@ -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]}`);
+1
View File
@@ -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;
+66 -5
View File
@@ -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
}
}
+5 -4
View File
@@ -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) {
+32 -13
View File
@@ -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 {
</thead>
<tbody>
${this.players
.map((player, index) => html`
<tr class="${player.isMyPlayer ? 'myPlayer' : 'otherPlayer'}">
.map((player) => html`
<tr
class="${player.isMyPlayer ? 'myPlayer' : 'otherPlayer'}"
@click=${() => this.handleRowClick(player.player)}
>
<td>${player.position}</td>
<td>${unsafeHTML(player.name)}</td>
<td>${player.score}</td>
+22 -15
View File
@@ -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 {
+1
View File
@@ -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)
})
+9
View File
@@ -146,6 +146,14 @@ export function calculateBoundingBox(borderTiles: ReadonlySet<Tile>): { min: Cel
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) }
}
export function calculateBoundingBoxCenter(borderTiles: ReadonlySet<Tile>): 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;
}
+2 -2
View File
@@ -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 {
+82 -68
View File
@@ -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)
+4 -7
View File
@@ -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
// }
}
+13 -3
View File
@@ -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)) {
+2 -1
View File
@@ -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
+3 -2
View File
@@ -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<Player, number>()
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) {
@@ -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 {
+12 -6
View File
@@ -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<MutablePlayer>()
const attacked = new Map<MutablePlayer, number>()
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)
}
}
}
+7 -5
View File
@@ -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`)
}
}
}
}
+4
View File
@@ -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
+3 -3
View File
@@ -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
}
+13 -12
View File
@@ -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 {
+3
View File
@@ -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
+4 -1
View File
@@ -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)
+13
View File
@@ -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;
-1
View File
@@ -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,
+32 -7
View File
@@ -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}`)
}
}