bunch of shit from claude code
This commit is contained in:
19
backend/.env.example
Normal file
19
backend/.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/vash_esports
|
||||
|
||||
# Discord Bot
|
||||
DISCORD_TOKEN=your_discord_bot_token
|
||||
DISCORD_GUILD_ID=your_guild_id
|
||||
DISCORD_ANNOUNCEMENT_CHANNEL_ID=your_channel_id
|
||||
DISCORD_MATCH_RESULTS_CHANNEL_ID=your_channel_id
|
||||
|
||||
# osu! API
|
||||
OSU_CLIENT_ID=your_client_id
|
||||
OSU_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# osu! IRC (for BanchoBot)
|
||||
OSU_IRC_USERNAME=your_osu_username
|
||||
OSU_IRC_PASSWORD=your_irc_password
|
||||
34
backend/.gitignore
vendored
Normal file
34
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
106
backend/CLAUDE.md
Normal file
106
backend/CLAUDE.md
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
||||
15
backend/README.md
Normal file
15
backend/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# backend
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
75
backend/bun.lock
Normal file
75
backend/bun.lock
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "backend",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.14.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
||||
|
||||
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||
|
||||
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
||||
|
||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||
|
||||
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
||||
|
||||
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.38", "", {}, "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q=="],
|
||||
|
||||
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||
|
||||
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
||||
|
||||
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
}
|
||||
}
|
||||
23
backend/package.json
Normal file
23
backend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "vash-esports-backend",
|
||||
"version": "1.0.0",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/index.ts",
|
||||
"start": "bun src/index.ts",
|
||||
"test": "bun test",
|
||||
"cli": "bun src/cli/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
56
backend/src/application/events/EventBus.ts
Normal file
56
backend/src/application/events/EventBus.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { DomainEvent } from "../../domain/events/DomainEvent";
|
||||
|
||||
type EventHandler<T extends DomainEvent = DomainEvent> = (event: T) => void | Promise<void>;
|
||||
|
||||
export class EventBus {
|
||||
private handlers: Map<string, Set<EventHandler>> = new Map();
|
||||
private allHandlers: Set<EventHandler> = new Set();
|
||||
|
||||
subscribe<T extends DomainEvent>(
|
||||
type: T["type"],
|
||||
handler: EventHandler<T>
|
||||
): () => void {
|
||||
if (!this.handlers.has(type)) {
|
||||
this.handlers.set(type, new Set());
|
||||
}
|
||||
this.handlers.get(type)!.add(handler as EventHandler);
|
||||
|
||||
return () => {
|
||||
this.handlers.get(type)?.delete(handler as EventHandler);
|
||||
};
|
||||
}
|
||||
|
||||
subscribeAll(handler: EventHandler): () => void {
|
||||
this.allHandlers.add(handler);
|
||||
return () => {
|
||||
this.allHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
async publish(event: DomainEvent): Promise<void> {
|
||||
const handlers = this.handlers.get(event.type) ?? new Set();
|
||||
|
||||
const allPromises: Promise<void>[] = [];
|
||||
|
||||
for (const handler of handlers) {
|
||||
const result = handler(event);
|
||||
if (result instanceof Promise) {
|
||||
allPromises.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
for (const handler of this.allHandlers) {
|
||||
const result = handler(event);
|
||||
if (result instanceof Promise) {
|
||||
allPromises.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(allPromises);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.handlers.clear();
|
||||
this.allHandlers.clear();
|
||||
}
|
||||
}
|
||||
1
backend/src/application/events/index.ts
Normal file
1
backend/src/application/events/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./EventBus";
|
||||
334
backend/src/application/services/MatchService.ts
Normal file
334
backend/src/application/services/MatchService.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import type { IMatchRepository } from "../../domain/interfaces/IMatchRepository";
|
||||
import type { IPlayerRepository } from "../../domain/interfaces/IPlayerRepository";
|
||||
import type { IGameService } from "../../domain/interfaces/IGameService";
|
||||
import type { INotificationService } from "../../domain/interfaces/INotificationService";
|
||||
import type { ProtocolRegistry } from "../../domain/protocols/ProtocolRegistry";
|
||||
import type { ProtocolContext, RollResult } from "../../domain/protocols/IMatchProtocol";
|
||||
import type { EventBus } from "../events/EventBus";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
import type { OsuMap } from "../../domain/models/OsuMap";
|
||||
import type { Score } from "../../domain/models/Score";
|
||||
import { type Match, createMatch, allPlayersJoined, getTeamWins } from "../../domain/models/Match";
|
||||
import { MatchState } from "../../domain/models/MatchState";
|
||||
import { createMatchScore } from "../../domain/models/Score";
|
||||
import { MatchEvents } from "../../domain/events/MatchEvents";
|
||||
|
||||
export class MatchService {
|
||||
constructor(
|
||||
private matchRepo: IMatchRepository,
|
||||
private playerRepo: IPlayerRepository,
|
||||
private protocolRegistry: ProtocolRegistry,
|
||||
private gameService: IGameService,
|
||||
private notificationService: INotificationService,
|
||||
private eventBus: EventBus
|
||||
) {
|
||||
this.setupGameServiceCallbacks();
|
||||
}
|
||||
|
||||
private setupGameServiceCallbacks(): void {
|
||||
this.gameService.onPlayerJoin(async (osuId, lobbyId) => {
|
||||
const match = await this.matchRepo.findByLobbyId(lobbyId);
|
||||
if (!match) return;
|
||||
|
||||
const player = await this.playerRepo.findByOsuId(osuId);
|
||||
if (!player) return;
|
||||
|
||||
await this.handlePlayerJoined(match.id, player);
|
||||
});
|
||||
|
||||
this.gameService.onRoll(async (osuId, lobbyId, roll) => {
|
||||
const match = await this.matchRepo.findByLobbyId(lobbyId);
|
||||
if (!match) return;
|
||||
|
||||
const player = await this.playerRepo.findByOsuId(osuId);
|
||||
if (!player) return;
|
||||
|
||||
await this.handleRoll(match.id, player, roll);
|
||||
});
|
||||
|
||||
this.gameService.onMatchFinish(async (lobbyId, scores) => {
|
||||
const match = await this.matchRepo.findByLobbyId(lobbyId);
|
||||
if (!match) return;
|
||||
|
||||
await this.handleMapComplete(match.id, scores);
|
||||
});
|
||||
}
|
||||
|
||||
async createMatch(teams: Team[], protocolName: string): Promise<Match> {
|
||||
const protocol = this.protocolRegistry.get(protocolName);
|
||||
|
||||
const match = createMatch({
|
||||
teams,
|
||||
protocol: protocolName,
|
||||
});
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
await this.eventBus.publish(MatchEvents.created(match));
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
async startMatch(matchId: string): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) {
|
||||
throw new Error(`Match ${matchId} not found`);
|
||||
}
|
||||
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
const context = this.createProtocolContext(match);
|
||||
|
||||
const lobbyId = await this.gameService.createLobby(match);
|
||||
match.lobbyId = lobbyId;
|
||||
match.startedAt = new Date();
|
||||
|
||||
const newState = await protocol.onMatchStart(match, context);
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
|
||||
for (const team of match.teams) {
|
||||
for (const player of team.players) {
|
||||
await this.gameService.invitePlayer(player, lobbyId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.notificationService.announceMatch(match);
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async handlePlayerJoined(matchId: string, player: Player): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
if (match.state !== MatchState.WAITING_PLAYERS) return;
|
||||
|
||||
const isMatchPlayer = match.teams.some((t) =>
|
||||
t.players.some((p) => p.id === player.id)
|
||||
);
|
||||
if (!isMatchPlayer) return;
|
||||
|
||||
match.joinedPlayers.add(player.id);
|
||||
await this.eventBus.publish(MatchEvents.playerJoined(match, player));
|
||||
|
||||
if (allPlayersJoined(match)) {
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
const context = this.createProtocolContext(match);
|
||||
|
||||
const newState = await protocol.onAllPlayersJoined(match, context);
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async handleRoll(matchId: string, player: Player, roll: number): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
if (match.state !== MatchState.ROLLING) return;
|
||||
|
||||
const team = match.teams.find((t) => t.players.some((p) => p.id === player.id));
|
||||
if (!team) return;
|
||||
|
||||
if (match.rolls.has(team.id)) return;
|
||||
|
||||
match.rolls.set(team.id, roll);
|
||||
await this.eventBus.publish(MatchEvents.rollCompleted(match, player, roll));
|
||||
|
||||
const rolls: RollResult[] = Array.from(match.rolls.entries()).map(
|
||||
([teamId, rollValue]) => ({
|
||||
teamId,
|
||||
playerId: player.id,
|
||||
roll: rollValue,
|
||||
})
|
||||
);
|
||||
|
||||
if (rolls.length === match.teams.length) {
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
const context = this.createProtocolContext(match);
|
||||
|
||||
const newState = await protocol.onRollComplete(match, rolls, context);
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async handlePick(matchId: string, team: Team, map: OsuMap): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
if (match.state !== MatchState.PICKING) return;
|
||||
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
if (!protocol.isValidPick(match, team, map)) return;
|
||||
|
||||
const context = this.createProtocolContext(match);
|
||||
await this.eventBus.publish(MatchEvents.mapPicked(match, team, map));
|
||||
|
||||
const newState = await protocol.onPick(match, team, map, context);
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async handleBan(matchId: string, team: Team, map: OsuMap): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
if (match.state !== MatchState.BANNING) return;
|
||||
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
if (!protocol.isValidBan(match, team, map)) return;
|
||||
|
||||
const context = this.createProtocolContext(match);
|
||||
await this.eventBus.publish(MatchEvents.mapBanned(match, team, map));
|
||||
|
||||
const newState = await protocol.onBan(match, team, map, context);
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async handleMapComplete(matchId: string, scores: Score[]): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
if (match.state !== MatchState.PLAYING) return;
|
||||
|
||||
const protocol = this.protocolRegistry.get(match.protocol);
|
||||
const winnerId = protocol.calculateMapWinner(match, scores);
|
||||
|
||||
const teamScores = new Map<string, number>();
|
||||
const playerScores = new Map<string, Score>();
|
||||
|
||||
for (const team of match.teams) {
|
||||
const teamPlayerIds = team.players.map((p) => p.id);
|
||||
const teamTotal = scores
|
||||
.filter((s) => teamPlayerIds.includes(s.playerId))
|
||||
.reduce((sum, s) => sum + s.score, 0);
|
||||
teamScores.set(team.id, teamTotal);
|
||||
}
|
||||
|
||||
for (const score of scores) {
|
||||
playerScores.set(score.playerId, score);
|
||||
}
|
||||
|
||||
const currentMapId = match.picks[match.picks.length - 1];
|
||||
const matchScore = createMatchScore({
|
||||
mapId: currentMapId,
|
||||
teamScores,
|
||||
playerScores,
|
||||
winner: winnerId,
|
||||
});
|
||||
|
||||
match.scores.push(matchScore);
|
||||
await this.eventBus.publish(MatchEvents.mapCompleted(match, matchScore));
|
||||
await this.notificationService.announceMapResult(match, matchScore);
|
||||
|
||||
const context = this.createProtocolContext(match);
|
||||
const newState = await protocol.onMapComplete(match, scores, context);
|
||||
|
||||
if (newState === MatchState.COMPLETED) {
|
||||
const winner = match.teams.find((t) => t.id === protocol.calculateMatchWinner(match));
|
||||
if (winner) {
|
||||
match.winner = winner;
|
||||
match.endedAt = new Date();
|
||||
await this.eventBus.publish(MatchEvents.completed(match, winner));
|
||||
await this.notificationService.announceMatchResult(match, winner);
|
||||
await this.updatePlayerRatings(match);
|
||||
}
|
||||
}
|
||||
|
||||
if (newState) {
|
||||
await this.transitionState(match, newState);
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
async cancelMatch(matchId: string, reason: string): Promise<void> {
|
||||
const match = await this.matchRepo.findById(matchId);
|
||||
if (!match) return;
|
||||
|
||||
match.endedAt = new Date();
|
||||
await this.transitionState(match, MatchState.CANCELLED);
|
||||
await this.eventBus.publish(MatchEvents.cancelled(match, reason));
|
||||
|
||||
if (match.lobbyId) {
|
||||
await this.gameService.closeLobby(match.lobbyId);
|
||||
}
|
||||
|
||||
await this.matchRepo.save(match);
|
||||
}
|
||||
|
||||
private async transitionState(match: Match, newState: MatchState): Promise<void> {
|
||||
const previousState = match.state;
|
||||
match.state = newState;
|
||||
await this.matchRepo.updateState(match.id, newState);
|
||||
await this.eventBus.publish(MatchEvents.stateChanged(match, previousState));
|
||||
}
|
||||
|
||||
private createProtocolContext(match: Match): ProtocolContext {
|
||||
return {
|
||||
sendMessage: async (message: string) => {
|
||||
if (match.lobbyId) {
|
||||
await this.gameService.sendMessage(match.lobbyId, message);
|
||||
}
|
||||
},
|
||||
setMap: async (mapId: number) => {
|
||||
if (match.lobbyId) {
|
||||
await this.gameService.setMap(match.lobbyId, mapId);
|
||||
}
|
||||
},
|
||||
startGame: async () => {
|
||||
if (match.lobbyId) {
|
||||
await this.gameService.startMatch(match.lobbyId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async updatePlayerRatings(match: Match): Promise<void> {
|
||||
if (!match.winner) return;
|
||||
|
||||
const K = 32;
|
||||
const winningTeam = match.winner;
|
||||
const losingTeam = match.teams.find((t) => t.id !== winningTeam.id);
|
||||
if (!losingTeam) return;
|
||||
|
||||
const winnerAvgRating =
|
||||
winningTeam.players.reduce((sum, p) => sum + p.rating, 0) /
|
||||
winningTeam.players.length;
|
||||
const loserAvgRating =
|
||||
losingTeam.players.reduce((sum, p) => sum + p.rating, 0) /
|
||||
losingTeam.players.length;
|
||||
|
||||
const expectedWinner =
|
||||
1 / (1 + Math.pow(10, (loserAvgRating - winnerAvgRating) / 400));
|
||||
const expectedLoser = 1 - expectedWinner;
|
||||
|
||||
const winnerDelta = Math.round(K * (1 - expectedWinner));
|
||||
const loserDelta = Math.round(K * (0 - expectedLoser));
|
||||
|
||||
for (const player of winningTeam.players) {
|
||||
await this.playerRepo.updateRating(player.id, player.rating + winnerDelta);
|
||||
}
|
||||
|
||||
for (const player of losingTeam.players) {
|
||||
await this.playerRepo.updateRating(
|
||||
player.id,
|
||||
Math.max(100, player.rating + loserDelta)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
183
backend/src/application/services/QueueService.ts
Normal file
183
backend/src/application/services/QueueService.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { IQueueRepository } from "../../domain/interfaces/IQueueRepository";
|
||||
import type { MatchService } from "./MatchService";
|
||||
import type { EventBus } from "../events/EventBus";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import { type QueueEntry, createQueueEntry } from "../../domain/models/QueueEntry";
|
||||
import { getAverageRating } from "../../domain/models/Team";
|
||||
import { QueueEvents } from "../../domain/events/QueueEvents";
|
||||
|
||||
export interface MatchmakingConfig {
|
||||
maxRatingDifference: number;
|
||||
ratingExpansionRate: number;
|
||||
minQueueTime: number;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: MatchmakingConfig = {
|
||||
maxRatingDifference: 200,
|
||||
ratingExpansionRate: 50,
|
||||
minQueueTime: 10000,
|
||||
protocol: "standard-osu",
|
||||
};
|
||||
|
||||
export class QueueService {
|
||||
private config: MatchmakingConfig;
|
||||
private matchmakingInterval?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private queueRepo: IQueueRepository,
|
||||
private matchService: MatchService,
|
||||
private eventBus: EventBus,
|
||||
config?: Partial<MatchmakingConfig>
|
||||
) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
async joinQueue(team: Team, queueType: string): Promise<QueueEntry> {
|
||||
const existing = await this.queueRepo.findByTeamId(team.id);
|
||||
if (existing) {
|
||||
throw new Error(`Team ${team.id} is already in queue`);
|
||||
}
|
||||
|
||||
const entry = createQueueEntry({
|
||||
team,
|
||||
queueType,
|
||||
});
|
||||
|
||||
await this.queueRepo.add(entry);
|
||||
await this.eventBus.publish(QueueEvents.teamJoined(entry));
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async leaveQueue(teamId: string): Promise<void> {
|
||||
const entry = await this.queueRepo.findByTeamId(teamId);
|
||||
if (!entry) {
|
||||
throw new Error(`Team ${teamId} is not in queue`);
|
||||
}
|
||||
|
||||
await this.queueRepo.remove(teamId);
|
||||
await this.eventBus.publish(QueueEvents.teamLeft(teamId, entry.queueType));
|
||||
}
|
||||
|
||||
async findMatch(queueType: string): Promise<Match | null> {
|
||||
const entries = await this.queueRepo.findByType(queueType);
|
||||
if (entries.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchPair = this.findBestMatch(entries);
|
||||
if (!matchPair) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [entry1, entry2] = matchPair;
|
||||
|
||||
await this.queueRepo.remove(entry1.team.id);
|
||||
await this.queueRepo.remove(entry2.team.id);
|
||||
|
||||
const teams = [entry1.team, entry2.team];
|
||||
await this.eventBus.publish(QueueEvents.matchFound(teams, queueType));
|
||||
|
||||
const match = await this.matchService.createMatch(teams, this.config.protocol);
|
||||
await this.matchService.startMatch(match.id);
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
async runMatchmaking(): Promise<void> {
|
||||
const queueTypes = new Set<string>();
|
||||
const allEntries = await this.queueRepo.findAll();
|
||||
|
||||
for (const entry of allEntries) {
|
||||
queueTypes.add(entry.queueType);
|
||||
}
|
||||
|
||||
for (const queueType of queueTypes) {
|
||||
await this.findMatch(queueType);
|
||||
}
|
||||
}
|
||||
|
||||
startAutoMatchmaking(intervalMs: number = 5000): void {
|
||||
if (this.matchmakingInterval) {
|
||||
clearInterval(this.matchmakingInterval);
|
||||
}
|
||||
this.matchmakingInterval = setInterval(() => {
|
||||
this.runMatchmaking().catch(console.error);
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopAutoMatchmaking(): void {
|
||||
if (this.matchmakingInterval) {
|
||||
clearInterval(this.matchmakingInterval);
|
||||
this.matchmakingInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private findBestMatch(entries: QueueEntry[]): [QueueEntry, QueueEntry] | null {
|
||||
const now = Date.now();
|
||||
let bestPair: [QueueEntry, QueueEntry] | null = null;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
for (let j = i + 1; j < entries.length; j++) {
|
||||
const entry1 = entries[i];
|
||||
const entry2 = entries[j];
|
||||
|
||||
if (!entry1 || !entry2) continue;
|
||||
|
||||
const rating1 = getAverageRating(entry1.team);
|
||||
const rating2 = getAverageRating(entry2.team);
|
||||
const ratingDiff = Math.abs(rating1 - rating2);
|
||||
|
||||
const waitTime1 = now - entry1.joinedAt.getTime();
|
||||
const waitTime2 = now - entry2.joinedAt.getTime();
|
||||
const minWaitTime = Math.min(waitTime1, waitTime2);
|
||||
|
||||
if (minWaitTime < this.config.minQueueTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const expansionMultiplier = Math.floor(minWaitTime / this.config.minQueueTime);
|
||||
const maxAllowedDiff =
|
||||
this.config.maxRatingDifference +
|
||||
expansionMultiplier * this.config.ratingExpansionRate;
|
||||
|
||||
if (ratingDiff > maxAllowedDiff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const score = ratingDiff - minWaitTime / 1000;
|
||||
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
bestPair = [entry1, entry2];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestPair;
|
||||
}
|
||||
|
||||
async getQueueStatus(queueType: string): Promise<{
|
||||
count: number;
|
||||
entries: Array<{
|
||||
teamName: string;
|
||||
rating: number;
|
||||
waitTime: number;
|
||||
}>;
|
||||
}> {
|
||||
const entries = await this.queueRepo.findByType(queueType);
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
entries: entries.map((e) => ({
|
||||
teamName: e.team.name,
|
||||
rating: getAverageRating(e.team),
|
||||
waitTime: now - e.joinedAt.getTime(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
2
backend/src/application/services/index.ts
Normal file
2
backend/src/application/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./MatchService";
|
||||
export * from "./QueueService";
|
||||
262
backend/src/cli/index.ts
Normal file
262
backend/src/cli/index.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { parseArgs } from "util";
|
||||
import { EventBus } from "../application/events/EventBus";
|
||||
import { MatchService } from "../application/services/MatchService";
|
||||
import { QueueService } from "../application/services/QueueService";
|
||||
import { ProtocolRegistry } from "../domain/protocols/ProtocolRegistry";
|
||||
import { StandardOsuProtocol } from "../domain/protocols/StandardOsuProtocol";
|
||||
import { HeadToHeadProtocol } from "../domain/protocols/HeadToHeadProtocol";
|
||||
import { InMemoryPlayerRepository } from "../infrastructure/mocks/InMemoryPlayerRepository";
|
||||
import { InMemoryTeamRepository } from "../infrastructure/mocks/InMemoryTeamRepository";
|
||||
import { InMemoryMatchRepository } from "../infrastructure/mocks/InMemoryMatchRepository";
|
||||
import { InMemoryQueueRepository } from "../infrastructure/mocks/InMemoryQueueRepository";
|
||||
import { MockGameService } from "../infrastructure/mocks/MockGameService";
|
||||
import { MockNotificationService } from "../infrastructure/mocks/MockNotificationService";
|
||||
import { createPlayer } from "../domain/models/Player";
|
||||
import { createTeam, getAverageRating } from "../domain/models/Team";
|
||||
import { MatchState } from "../domain/models/MatchState";
|
||||
|
||||
const eventBus = new EventBus();
|
||||
const mockGameService = new MockGameService();
|
||||
const mockNotificationService = new MockNotificationService();
|
||||
|
||||
const playerRepo = new InMemoryPlayerRepository();
|
||||
const teamRepo = new InMemoryTeamRepository();
|
||||
const matchRepo = new InMemoryMatchRepository();
|
||||
const queueRepo = new InMemoryQueueRepository();
|
||||
|
||||
const protocolRegistry = new ProtocolRegistry();
|
||||
protocolRegistry.register(new StandardOsuProtocol());
|
||||
protocolRegistry.register(new HeadToHeadProtocol());
|
||||
|
||||
const matchService = new MatchService(
|
||||
matchRepo,
|
||||
playerRepo,
|
||||
protocolRegistry,
|
||||
mockGameService,
|
||||
mockNotificationService,
|
||||
eventBus
|
||||
);
|
||||
|
||||
const queueService = new QueueService(queueRepo, matchService, eventBus, {
|
||||
minQueueTime: 0,
|
||||
});
|
||||
|
||||
eventBus.subscribeAll((event) => {
|
||||
console.log(`[Event] ${event.type}`);
|
||||
});
|
||||
|
||||
type CommandHandler = (...args: string[]) => Promise<void>;
|
||||
|
||||
const commands: Record<string, CommandHandler> = {
|
||||
"player:create": async (osuName?: string, rating?: string) => {
|
||||
const player = createPlayer({
|
||||
discordId: `discord-${Date.now()}`,
|
||||
osuId: Math.floor(Math.random() * 1000000),
|
||||
osuName: osuName ?? `Player${Date.now()}`,
|
||||
rating: rating ? parseInt(rating) : 1000,
|
||||
});
|
||||
await playerRepo.save(player);
|
||||
console.log(`Created player: ${player.id} (${player.osuName}, rating: ${player.rating})`);
|
||||
},
|
||||
|
||||
"player:list": async () => {
|
||||
const players = await playerRepo.findAll();
|
||||
if (players.length === 0) {
|
||||
console.log("No players found");
|
||||
return;
|
||||
}
|
||||
console.log("Players:");
|
||||
for (const p of players) {
|
||||
console.log(` ${p.id}: ${p.osuName} (rating: ${p.rating})`);
|
||||
}
|
||||
},
|
||||
|
||||
"team:create": async (name: string, ...playerIds: string[]) => {
|
||||
if (!name || playerIds.length === 0) {
|
||||
console.log("Usage: team:create <name> <playerId1> [playerId2] ...");
|
||||
return;
|
||||
}
|
||||
const players = await Promise.all(
|
||||
playerIds.map((id) => playerRepo.findById(id))
|
||||
);
|
||||
const validPlayers = players.filter((p) => p !== null);
|
||||
if (validPlayers.length === 0) {
|
||||
console.log("No valid players found");
|
||||
return;
|
||||
}
|
||||
const team = createTeam({ name, players: validPlayers });
|
||||
await teamRepo.save(team);
|
||||
console.log(`Created team: ${team.id} (${team.name}, avg rating: ${getAverageRating(team)})`);
|
||||
},
|
||||
|
||||
"team:list": async () => {
|
||||
const teams = await teamRepo.findAll();
|
||||
if (teams.length === 0) {
|
||||
console.log("No teams found");
|
||||
return;
|
||||
}
|
||||
console.log("Teams:");
|
||||
for (const t of teams) {
|
||||
console.log(` ${t.id}: ${t.name} (${t.players.length} players, avg: ${getAverageRating(t)})`);
|
||||
}
|
||||
},
|
||||
|
||||
"queue:join": async (teamId: string, queueType: string = "ranked") => {
|
||||
if (!teamId) {
|
||||
console.log("Usage: queue:join <teamId> [queueType]");
|
||||
return;
|
||||
}
|
||||
const team = await teamRepo.findById(teamId);
|
||||
if (!team) {
|
||||
console.log(`Team ${teamId} not found`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry = await queueService.joinQueue(team, queueType);
|
||||
console.log(`Team ${team.name} joined ${queueType} queue at ${entry.joinedAt.toISOString()}`);
|
||||
} catch (e) {
|
||||
console.log(`Error: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
"queue:leave": async (teamId: string) => {
|
||||
if (!teamId) {
|
||||
console.log("Usage: queue:leave <teamId>");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await queueService.leaveQueue(teamId);
|
||||
console.log(`Team ${teamId} left the queue`);
|
||||
} catch (e) {
|
||||
console.log(`Error: ${(e as Error).message}`);
|
||||
}
|
||||
},
|
||||
|
||||
"queue:list": async (queueType: string = "ranked") => {
|
||||
const status = await queueService.getQueueStatus(queueType);
|
||||
console.log(`Queue (${queueType}): ${status.count} teams`);
|
||||
for (const e of status.entries) {
|
||||
console.log(` ${e.teamName} (rating: ${e.rating}, wait: ${Math.round(e.waitTime / 1000)}s)`);
|
||||
}
|
||||
},
|
||||
|
||||
"matchmaking:run": async () => {
|
||||
const match = await queueService.findMatch("ranked");
|
||||
if (match) {
|
||||
console.log(`Created match ${match.id}: ${match.teams.map((t) => t.name).join(" vs ")}`);
|
||||
} else {
|
||||
console.log("No match found (need 2+ teams in queue)");
|
||||
}
|
||||
},
|
||||
|
||||
"match:status": async (matchId: string) => {
|
||||
if (!matchId) {
|
||||
console.log("Usage: match:status <matchId>");
|
||||
return;
|
||||
}
|
||||
const match = await matchRepo.findById(matchId);
|
||||
if (!match) {
|
||||
console.log(`Match ${matchId} not found`);
|
||||
return;
|
||||
}
|
||||
console.log(`Match: ${match.id}`);
|
||||
console.log(` State: ${match.state}`);
|
||||
console.log(` Protocol: ${match.protocol}`);
|
||||
console.log(` Teams: ${match.teams.map((t) => t.name).join(" vs ")}`);
|
||||
console.log(` Scores: ${match.teams.map((t) => `${t.name}: ${match.scores.filter((s) => s.winner === t.id).length}`).join(", ")}`);
|
||||
if (match.winner) {
|
||||
console.log(` Winner: ${match.winner.name}`);
|
||||
}
|
||||
},
|
||||
|
||||
"match:list": async () => {
|
||||
const matches = await matchRepo.findAll();
|
||||
if (matches.length === 0) {
|
||||
console.log("No matches found");
|
||||
return;
|
||||
}
|
||||
console.log("Matches:");
|
||||
for (const m of matches) {
|
||||
console.log(` ${m.id}: ${m.teams.map((t) => t.name).join(" vs ")} [${m.state}]`);
|
||||
}
|
||||
},
|
||||
|
||||
"match:simulate-join": async (matchId: string, osuIdStr: string) => {
|
||||
if (!matchId || !osuIdStr) {
|
||||
console.log("Usage: match:simulate-join <matchId> <osuId>");
|
||||
return;
|
||||
}
|
||||
const match = await matchRepo.findById(matchId);
|
||||
if (!match || !match.lobbyId) {
|
||||
console.log(`Match ${matchId} not found or has no lobby`);
|
||||
return;
|
||||
}
|
||||
const osuId = parseInt(osuIdStr);
|
||||
mockGameService.simulatePlayerJoin(match.lobbyId, osuId);
|
||||
console.log(`Simulated player ${osuId} joining lobby ${match.lobbyId}`);
|
||||
},
|
||||
|
||||
"match:simulate-roll": async (matchId: string, osuIdStr: string, rollStr: string) => {
|
||||
if (!matchId || !osuIdStr || !rollStr) {
|
||||
console.log("Usage: match:simulate-roll <matchId> <osuId> <roll>");
|
||||
return;
|
||||
}
|
||||
const match = await matchRepo.findById(matchId);
|
||||
if (!match || !match.lobbyId) {
|
||||
console.log(`Match ${matchId} not found or has no lobby`);
|
||||
return;
|
||||
}
|
||||
const osuId = parseInt(osuIdStr);
|
||||
const roll = parseInt(rollStr);
|
||||
mockGameService.simulateRoll(match.lobbyId, osuId, roll);
|
||||
console.log(`Simulated roll ${roll} from player ${osuId}`);
|
||||
},
|
||||
|
||||
"protocol:list": async () => {
|
||||
const protocols = protocolRegistry.list();
|
||||
console.log("Available protocols:");
|
||||
for (const p of protocols) {
|
||||
console.log(` ${p.name}: ${p.description} (${p.teamSize}v${p.teamSize}, best of ${p.bestOf})`);
|
||||
}
|
||||
},
|
||||
|
||||
help: async () => {
|
||||
console.log("Available commands:");
|
||||
console.log(" player:create [osuName] [rating]");
|
||||
console.log(" player:list");
|
||||
console.log(" team:create <name> <playerId1> [playerId2] ...");
|
||||
console.log(" team:list");
|
||||
console.log(" queue:join <teamId> [queueType]");
|
||||
console.log(" queue:leave <teamId>");
|
||||
console.log(" queue:list [queueType]");
|
||||
console.log(" matchmaking:run");
|
||||
console.log(" match:status <matchId>");
|
||||
console.log(" match:list");
|
||||
console.log(" match:simulate-join <matchId> <osuId>");
|
||||
console.log(" match:simulate-roll <matchId> <osuId> <roll>");
|
||||
console.log(" protocol:list");
|
||||
console.log(" help");
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const commandArgs = args.slice(1);
|
||||
|
||||
if (!command) {
|
||||
await commands.help();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const handler = commands[command];
|
||||
if (!handler) {
|
||||
console.log(`Unknown command: ${command}`);
|
||||
await commands.help();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await handler(...commandArgs);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
13
backend/src/domain/events/DomainEvent.ts
Normal file
13
backend/src/domain/events/DomainEvent.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface DomainEvent<T = unknown> {
|
||||
type: string;
|
||||
timestamp: Date;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export function createEvent<T>(type: string, payload: T): DomainEvent<T> {
|
||||
return {
|
||||
type,
|
||||
timestamp: new Date(),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
115
backend/src/domain/events/MatchEvents.ts
Normal file
115
backend/src/domain/events/MatchEvents.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Match } from "../models/Match";
|
||||
import type { MatchState } from "../models/MatchState";
|
||||
import type { Player } from "../models/Player";
|
||||
import type { Team } from "../models/Team";
|
||||
import type { MatchScore } from "../models/Score";
|
||||
import type { OsuMap } from "../models/OsuMap";
|
||||
import type { DomainEvent } from "./DomainEvent";
|
||||
import { createEvent } from "./DomainEvent";
|
||||
|
||||
export interface MatchCreatedPayload {
|
||||
match: Match;
|
||||
}
|
||||
|
||||
export interface MatchStateChangedPayload {
|
||||
match: Match;
|
||||
previousState: MatchState;
|
||||
}
|
||||
|
||||
export interface PlayerJoinedLobbyPayload {
|
||||
match: Match;
|
||||
player: Player;
|
||||
}
|
||||
|
||||
export interface RollCompletedPayload {
|
||||
match: Match;
|
||||
player: Player;
|
||||
roll: number;
|
||||
}
|
||||
|
||||
export interface MapPickedPayload {
|
||||
match: Match;
|
||||
team: Team;
|
||||
map: OsuMap;
|
||||
}
|
||||
|
||||
export interface MapBannedPayload {
|
||||
match: Match;
|
||||
team: Team;
|
||||
map: OsuMap;
|
||||
}
|
||||
|
||||
export interface MapStartedPayload {
|
||||
match: Match;
|
||||
map: OsuMap;
|
||||
}
|
||||
|
||||
export interface MapCompletedPayload {
|
||||
match: Match;
|
||||
scores: MatchScore;
|
||||
}
|
||||
|
||||
export interface MatchCompletedPayload {
|
||||
match: Match;
|
||||
winner: Team;
|
||||
}
|
||||
|
||||
export interface MatchCancelledPayload {
|
||||
match: Match;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type MatchCreatedEvent = DomainEvent<MatchCreatedPayload> & { type: "match.created" };
|
||||
export type MatchStateChangedEvent = DomainEvent<MatchStateChangedPayload> & { type: "match.state_changed" };
|
||||
export type PlayerJoinedLobbyEvent = DomainEvent<PlayerJoinedLobbyPayload> & { type: "match.player_joined" };
|
||||
export type RollCompletedEvent = DomainEvent<RollCompletedPayload> & { type: "match.roll_completed" };
|
||||
export type MapPickedEvent = DomainEvent<MapPickedPayload> & { type: "match.map_picked" };
|
||||
export type MapBannedEvent = DomainEvent<MapBannedPayload> & { type: "match.map_banned" };
|
||||
export type MapStartedEvent = DomainEvent<MapStartedPayload> & { type: "match.map_started" };
|
||||
export type MapCompletedEvent = DomainEvent<MapCompletedPayload> & { type: "match.map_completed" };
|
||||
export type MatchCompletedEvent = DomainEvent<MatchCompletedPayload> & { type: "match.completed" };
|
||||
export type MatchCancelledEvent = DomainEvent<MatchCancelledPayload> & { type: "match.cancelled" };
|
||||
|
||||
export type MatchEvent =
|
||||
| MatchCreatedEvent
|
||||
| MatchStateChangedEvent
|
||||
| PlayerJoinedLobbyEvent
|
||||
| RollCompletedEvent
|
||||
| MapPickedEvent
|
||||
| MapBannedEvent
|
||||
| MapStartedEvent
|
||||
| MapCompletedEvent
|
||||
| MatchCompletedEvent
|
||||
| MatchCancelledEvent;
|
||||
|
||||
export const MatchEvents = {
|
||||
created: (match: Match): MatchCreatedEvent =>
|
||||
createEvent("match.created", { match }) as MatchCreatedEvent,
|
||||
|
||||
stateChanged: (match: Match, previousState: MatchState): MatchStateChangedEvent =>
|
||||
createEvent("match.state_changed", { match, previousState }) as MatchStateChangedEvent,
|
||||
|
||||
playerJoined: (match: Match, player: Player): PlayerJoinedLobbyEvent =>
|
||||
createEvent("match.player_joined", { match, player }) as PlayerJoinedLobbyEvent,
|
||||
|
||||
rollCompleted: (match: Match, player: Player, roll: number): RollCompletedEvent =>
|
||||
createEvent("match.roll_completed", { match, player, roll }) as RollCompletedEvent,
|
||||
|
||||
mapPicked: (match: Match, team: Team, map: OsuMap): MapPickedEvent =>
|
||||
createEvent("match.map_picked", { match, team, map }) as MapPickedEvent,
|
||||
|
||||
mapBanned: (match: Match, team: Team, map: OsuMap): MapBannedEvent =>
|
||||
createEvent("match.map_banned", { match, team, map }) as MapBannedEvent,
|
||||
|
||||
mapStarted: (match: Match, map: OsuMap): MapStartedEvent =>
|
||||
createEvent("match.map_started", { match, map }) as MapStartedEvent,
|
||||
|
||||
mapCompleted: (match: Match, scores: MatchScore): MapCompletedEvent =>
|
||||
createEvent("match.map_completed", { match, scores }) as MapCompletedEvent,
|
||||
|
||||
completed: (match: Match, winner: Team): MatchCompletedEvent =>
|
||||
createEvent("match.completed", { match, winner }) as MatchCompletedEvent,
|
||||
|
||||
cancelled: (match: Match, reason: string): MatchCancelledEvent =>
|
||||
createEvent("match.cancelled", { match, reason }) as MatchCancelledEvent,
|
||||
};
|
||||
35
backend/src/domain/events/QueueEvents.ts
Normal file
35
backend/src/domain/events/QueueEvents.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Team } from "../models/Team";
|
||||
import type { QueueEntry } from "../models/QueueEntry";
|
||||
import type { DomainEvent } from "./DomainEvent";
|
||||
import { createEvent } from "./DomainEvent";
|
||||
|
||||
export interface TeamQueuedPayload {
|
||||
entry: QueueEntry;
|
||||
}
|
||||
|
||||
export interface TeamDequeuedPayload {
|
||||
teamId: string;
|
||||
queueType: string;
|
||||
}
|
||||
|
||||
export interface MatchFoundPayload {
|
||||
teams: Team[];
|
||||
queueType: string;
|
||||
}
|
||||
|
||||
export type TeamQueuedEvent = DomainEvent<TeamQueuedPayload> & { type: "queue.team_joined" };
|
||||
export type TeamDequeuedEvent = DomainEvent<TeamDequeuedPayload> & { type: "queue.team_left" };
|
||||
export type MatchFoundEvent = DomainEvent<MatchFoundPayload> & { type: "queue.match_found" };
|
||||
|
||||
export type QueueEvent = TeamQueuedEvent | TeamDequeuedEvent | MatchFoundEvent;
|
||||
|
||||
export const QueueEvents = {
|
||||
teamJoined: (entry: QueueEntry): TeamQueuedEvent =>
|
||||
createEvent("queue.team_joined", { entry }) as TeamQueuedEvent,
|
||||
|
||||
teamLeft: (teamId: string, queueType: string): TeamDequeuedEvent =>
|
||||
createEvent("queue.team_left", { teamId, queueType }) as TeamDequeuedEvent,
|
||||
|
||||
matchFound: (teams: Team[], queueType: string): MatchFoundEvent =>
|
||||
createEvent("queue.match_found", { teams, queueType }) as MatchFoundEvent,
|
||||
};
|
||||
3
backend/src/domain/events/index.ts
Normal file
3
backend/src/domain/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./DomainEvent";
|
||||
export * from "./MatchEvents";
|
||||
export * from "./QueueEvents";
|
||||
18
backend/src/domain/interfaces/IGameService.ts
Normal file
18
backend/src/domain/interfaces/IGameService.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Match } from "../models/Match";
|
||||
import type { Player } from "../models/Player";
|
||||
import type { Score } from "../models/Score";
|
||||
|
||||
export interface IGameService {
|
||||
createLobby(match: Match): Promise<string>;
|
||||
closeLobby(lobbyId: string): Promise<void>;
|
||||
invitePlayer(player: Player, lobbyId: string): Promise<void>;
|
||||
sendMessage(lobbyId: string, message: string): Promise<void>;
|
||||
setMap(lobbyId: string, mapId: number): Promise<void>;
|
||||
startMatch(lobbyId: string): Promise<void>;
|
||||
|
||||
onPlayerJoin(callback: (osuId: number, lobbyId: string) => void): void;
|
||||
onPlayerLeave(callback: (osuId: number, lobbyId: string) => void): void;
|
||||
onMatchStart(callback: (lobbyId: string) => void): void;
|
||||
onMatchFinish(callback: (lobbyId: string, scores: Score[]) => void): void;
|
||||
onRoll(callback: (osuId: number, lobbyId: string, roll: number) => void): void;
|
||||
}
|
||||
0
backend/src/domain/interfaces/IMatchProtocol.ts
Normal file
0
backend/src/domain/interfaces/IMatchProtocol.ts
Normal file
12
backend/src/domain/interfaces/IMatchRepository.ts
Normal file
12
backend/src/domain/interfaces/IMatchRepository.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Match } from "../models/Match";
|
||||
import type { MatchState } from "../models/MatchState";
|
||||
|
||||
export interface IMatchRepository {
|
||||
findById(id: string): Promise<Match | null>;
|
||||
findActive(): Promise<Match[]>;
|
||||
findByPlayer(playerId: string, limit?: number): Promise<Match[]>;
|
||||
findByLobbyId(lobbyId: string): Promise<Match | null>;
|
||||
save(match: Match): Promise<void>;
|
||||
updateState(matchId: string, state: MatchState): Promise<void>;
|
||||
findAll(): Promise<Match[]>;
|
||||
}
|
||||
11
backend/src/domain/interfaces/INotificationService.ts
Normal file
11
backend/src/domain/interfaces/INotificationService.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Match } from "../models/Match";
|
||||
import type { Team } from "../models/Team";
|
||||
import type { MatchScore } from "../models/Score";
|
||||
|
||||
export interface INotificationService {
|
||||
announceMatch(match: Match): Promise<void>;
|
||||
announceMatchResult(match: Match, winner: Team): Promise<void>;
|
||||
notifyPlayer(playerId: string, message: string): Promise<void>;
|
||||
notifyTeam(teamId: string, message: string): Promise<void>;
|
||||
announceMapResult(match: Match, scores: MatchScore): Promise<void>;
|
||||
}
|
||||
10
backend/src/domain/interfaces/IPlayerRepository.ts
Normal file
10
backend/src/domain/interfaces/IPlayerRepository.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Player } from "../models/Player";
|
||||
|
||||
export interface IPlayerRepository {
|
||||
findById(id: string): Promise<Player | null>;
|
||||
findByOsuId(osuId: number): Promise<Player | null>;
|
||||
findByDiscordId(discordId: string): Promise<Player | null>;
|
||||
save(player: Player): Promise<void>;
|
||||
updateRating(playerId: string, newRating: number): Promise<void>;
|
||||
findAll(): Promise<Player[]>;
|
||||
}
|
||||
15
backend/src/domain/interfaces/IQueueRepository.ts
Normal file
15
backend/src/domain/interfaces/IQueueRepository.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { QueueEntry } from "../models/QueueEntry";
|
||||
|
||||
export interface IQueueRepository {
|
||||
add(entry: QueueEntry): Promise<void>;
|
||||
remove(teamId: string): Promise<void>;
|
||||
findByTeamId(teamId: string): Promise<QueueEntry | null>;
|
||||
findByType(queueType: string): Promise<QueueEntry[]>;
|
||||
findClosestByRating(
|
||||
rating: number,
|
||||
queueType: string,
|
||||
exclude?: string[]
|
||||
): Promise<QueueEntry | null>;
|
||||
findAll(): Promise<QueueEntry[]>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
9
backend/src/domain/interfaces/ITeamRepository.ts
Normal file
9
backend/src/domain/interfaces/ITeamRepository.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Team } from "../models/Team";
|
||||
|
||||
export interface ITeamRepository {
|
||||
findById(id: string): Promise<Team | null>;
|
||||
findByPlayer(playerId: string): Promise<Team[]>;
|
||||
save(team: Team): Promise<void>;
|
||||
delete(teamId: string): Promise<void>;
|
||||
findAll(): Promise<Team[]>;
|
||||
}
|
||||
7
backend/src/domain/interfaces/index.ts
Normal file
7
backend/src/domain/interfaces/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./IPlayerRepository";
|
||||
export * from "./ITeamRepository";
|
||||
export * from "./IMatchRepository";
|
||||
export * from "./IQueueRepository";
|
||||
export * from "./IGameService";
|
||||
export * from "./INotificationService";
|
||||
export * from "./IMatchProtocol";
|
||||
71
backend/src/domain/models/Match.ts
Normal file
71
backend/src/domain/models/Match.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Team } from "./Team";
|
||||
import type { MatchScore } from "./Score";
|
||||
import { MatchState } from "./MatchState";
|
||||
|
||||
export interface Match {
|
||||
id: string;
|
||||
teams: Team[];
|
||||
protocol: string;
|
||||
state: MatchState;
|
||||
scores: MatchScore[];
|
||||
winner?: Team;
|
||||
lobbyId?: string;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
endedAt?: Date;
|
||||
|
||||
rolls: Map<string, number>;
|
||||
bans: Map<string, number[]>;
|
||||
picks: number[];
|
||||
currentPicker?: string;
|
||||
currentBanner?: string;
|
||||
joinedPlayers: Set<string>;
|
||||
}
|
||||
|
||||
export function createMatch(params: {
|
||||
id?: string;
|
||||
teams: Team[];
|
||||
protocol: string;
|
||||
state?: MatchState;
|
||||
lobbyId?: string;
|
||||
createdAt?: Date;
|
||||
}): Match {
|
||||
if (params.teams.length < 2) {
|
||||
throw new Error("Match must have at least two teams");
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id ?? crypto.randomUUID(),
|
||||
teams: params.teams,
|
||||
protocol: params.protocol,
|
||||
state: params.state ?? MatchState.CREATED,
|
||||
scores: [],
|
||||
lobbyId: params.lobbyId,
|
||||
createdAt: params.createdAt ?? new Date(),
|
||||
rolls: new Map(),
|
||||
bans: new Map(),
|
||||
picks: [],
|
||||
joinedPlayers: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTeamWins(match: Match, teamId: string): number {
|
||||
return match.scores.filter((s) => s.winner === teamId).length;
|
||||
}
|
||||
|
||||
export function getMatchWinner(match: Match, bestOf: number): Team | null {
|
||||
const winsNeeded = Math.ceil(bestOf / 2);
|
||||
|
||||
for (const team of match.teams) {
|
||||
if (getTeamWins(match, team.id) >= winsNeeded) {
|
||||
return team;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function allPlayersJoined(match: Match): boolean {
|
||||
const allPlayerIds = match.teams.flatMap((t) => t.players.map((p) => p.id));
|
||||
return allPlayerIds.every((id) => match.joinedPlayers.has(id));
|
||||
}
|
||||
20
backend/src/domain/models/MatchState.ts
Normal file
20
backend/src/domain/models/MatchState.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export enum MatchState {
|
||||
CREATED = "created",
|
||||
LOBBY_CREATED = "lobby_created",
|
||||
WAITING_PLAYERS = "waiting_players",
|
||||
ROLLING = "rolling",
|
||||
PICKING = "picking",
|
||||
BANNING = "banning",
|
||||
PLAYING = "playing",
|
||||
MAP_COMPLETE = "map_complete",
|
||||
COMPLETED = "completed",
|
||||
CANCELLED = "cancelled",
|
||||
}
|
||||
|
||||
export function isTerminalState(state: MatchState): boolean {
|
||||
return state === MatchState.COMPLETED || state === MatchState.CANCELLED;
|
||||
}
|
||||
|
||||
export function isActiveState(state: MatchState): boolean {
|
||||
return !isTerminalState(state);
|
||||
}
|
||||
45
backend/src/domain/models/OsuMap.ts
Normal file
45
backend/src/domain/models/OsuMap.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface OsuMap {
|
||||
id: number;
|
||||
beatmapSetId: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
difficulty: string;
|
||||
starRating: number;
|
||||
bpm: number;
|
||||
length: number;
|
||||
mod?: string;
|
||||
}
|
||||
|
||||
export interface Mappool {
|
||||
id: string;
|
||||
name: string;
|
||||
maps: OsuMap[];
|
||||
}
|
||||
|
||||
export function createOsuMap(params: {
|
||||
id: number;
|
||||
beatmapSetId?: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
difficulty: string;
|
||||
starRating?: number;
|
||||
bpm?: number;
|
||||
length?: number;
|
||||
mod?: string;
|
||||
}): OsuMap {
|
||||
return {
|
||||
id: params.id,
|
||||
beatmapSetId: params.beatmapSetId ?? 0,
|
||||
title: params.title,
|
||||
artist: params.artist,
|
||||
difficulty: params.difficulty,
|
||||
starRating: params.starRating ?? 0,
|
||||
bpm: params.bpm ?? 0,
|
||||
length: params.length ?? 0,
|
||||
mod: params.mod,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMapsByMod(mappool: Mappool, mod: string): OsuMap[] {
|
||||
return mappool.maps.filter((m) => m.mod === mod);
|
||||
}
|
||||
29
backend/src/domain/models/Player.ts
Normal file
29
backend/src/domain/models/Player.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface Player {
|
||||
id: string;
|
||||
discordId: string;
|
||||
osuId: number;
|
||||
osuName: string;
|
||||
displayName: string;
|
||||
rating: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function createPlayer(params: {
|
||||
id?: string;
|
||||
discordId: string;
|
||||
osuId: number;
|
||||
osuName: string;
|
||||
displayName?: string;
|
||||
rating?: number;
|
||||
createdAt?: Date;
|
||||
}): Player {
|
||||
return {
|
||||
id: params.id ?? crypto.randomUUID(),
|
||||
discordId: params.discordId,
|
||||
osuId: params.osuId,
|
||||
osuName: params.osuName,
|
||||
displayName: params.displayName ?? params.osuName,
|
||||
rating: params.rating ?? 1000,
|
||||
createdAt: params.createdAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
22
backend/src/domain/models/QueueEntry.ts
Normal file
22
backend/src/domain/models/QueueEntry.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Team } from "./Team";
|
||||
|
||||
export interface QueueEntry {
|
||||
id: string;
|
||||
team: Team;
|
||||
queueType: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export function createQueueEntry(params: {
|
||||
id?: string;
|
||||
team: Team;
|
||||
queueType: string;
|
||||
joinedAt?: Date;
|
||||
}): QueueEntry {
|
||||
return {
|
||||
id: params.id ?? crypto.randomUUID(),
|
||||
team: params.team,
|
||||
queueType: params.queueType,
|
||||
joinedAt: params.joinedAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
38
backend/src/domain/models/Score.ts
Normal file
38
backend/src/domain/models/Score.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Score {
|
||||
playerId: string;
|
||||
score: number;
|
||||
accuracy: number;
|
||||
maxCombo: number;
|
||||
misses: number;
|
||||
mods?: string[];
|
||||
}
|
||||
|
||||
export interface MatchScore {
|
||||
id: string;
|
||||
mapId: number;
|
||||
teamScores: Map<string, number>;
|
||||
playerScores: Map<string, Score>;
|
||||
winner: string;
|
||||
}
|
||||
|
||||
export function createMatchScore(params: {
|
||||
id?: string;
|
||||
mapId: number;
|
||||
teamScores: Map<string, number>;
|
||||
playerScores: Map<string, Score>;
|
||||
winner: string;
|
||||
}): MatchScore {
|
||||
return {
|
||||
id: params.id ?? crypto.randomUUID(),
|
||||
mapId: params.mapId,
|
||||
teamScores: params.teamScores,
|
||||
playerScores: params.playerScores,
|
||||
winner: params.winner,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateTeamScore(scores: Score[], teamPlayerIds: string[]): number {
|
||||
return scores
|
||||
.filter((s) => teamPlayerIds.includes(s.playerId))
|
||||
.reduce((sum, s) => sum + s.score, 0);
|
||||
}
|
||||
35
backend/src/domain/models/Team.ts
Normal file
35
backend/src/domain/models/Team.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Player } from "./Player";
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
players: Player[];
|
||||
captain: Player;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function createTeam(params: {
|
||||
id?: string;
|
||||
name: string;
|
||||
players: Player[];
|
||||
captain?: Player;
|
||||
createdAt?: Date;
|
||||
}): Team {
|
||||
if (params.players.length === 0) {
|
||||
throw new Error("Team must have at least one player");
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id ?? crypto.randomUUID(),
|
||||
name: params.name,
|
||||
players: params.players,
|
||||
captain: params.captain ?? params.players[0],
|
||||
createdAt: params.createdAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAverageRating(team: Team): number {
|
||||
if (team.players.length === 0) return 0;
|
||||
const total = team.players.reduce((sum, p) => sum + p.rating, 0);
|
||||
return Math.round(total / team.players.length);
|
||||
}
|
||||
7
backend/src/domain/models/index.ts
Normal file
7
backend/src/domain/models/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./Player";
|
||||
export * from "./Team";
|
||||
export * from "./Match";
|
||||
export * from "./MatchState";
|
||||
export * from "./Score";
|
||||
export * from "./QueueEntry";
|
||||
export * from "./OsuMap";
|
||||
160
backend/src/domain/protocols/HeadToHeadProtocol.ts
Normal file
160
backend/src/domain/protocols/HeadToHeadProtocol.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
IMatchProtocol,
|
||||
ProtocolContext,
|
||||
RollResult,
|
||||
} from "./IMatchProtocol";
|
||||
import type { Match } from "../models/Match";
|
||||
import { MatchState } from "../models/MatchState";
|
||||
import type { Team } from "../models/Team";
|
||||
import type { OsuMap } from "../models/OsuMap";
|
||||
import type { Score } from "../models/Score";
|
||||
import { getTeamWins } from "../models/Match";
|
||||
|
||||
export class HeadToHeadProtocol implements IMatchProtocol {
|
||||
readonly name = "head-to-head";
|
||||
readonly description = "1v1 head-to-head format";
|
||||
readonly teamSize = 1;
|
||||
readonly bestOf = 5;
|
||||
readonly hasPickBan = false;
|
||||
readonly hasFreemod = false;
|
||||
|
||||
async onMatchStart(
|
||||
match: Match,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const team1 = match.teams[0];
|
||||
const team2 = match.teams[1];
|
||||
await context.sendMessage(`1v1 Match: ${team1.name} vs ${team2.name}`);
|
||||
await context.sendMessage("Waiting for players to join...");
|
||||
return MatchState.WAITING_PLAYERS;
|
||||
}
|
||||
|
||||
async onAllPlayersJoined(
|
||||
_match: Match,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
await context.sendMessage("Both players have joined! Please roll (!roll)");
|
||||
return MatchState.ROLLING;
|
||||
}
|
||||
|
||||
async onRollComplete(
|
||||
match: Match,
|
||||
rolls: RollResult[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const team1Roll = rolls.find((r) => r.teamId === match.teams[0].id);
|
||||
const team2Roll = rolls.find((r) => r.teamId === match.teams[1].id);
|
||||
|
||||
if (!team1Roll || !team2Roll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const winningTeam =
|
||||
team1Roll.roll > team2Roll.roll ? match.teams[0] : match.teams[1];
|
||||
|
||||
match.currentPicker = winningTeam.id;
|
||||
|
||||
await context.sendMessage(
|
||||
`${winningTeam.name} wins the roll and picks first!`
|
||||
);
|
||||
|
||||
return MatchState.PICKING;
|
||||
}
|
||||
|
||||
async onBan(
|
||||
_match: Match,
|
||||
_team: Team,
|
||||
_map: OsuMap,
|
||||
_context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async onPick(
|
||||
match: Match,
|
||||
team: Team,
|
||||
map: OsuMap,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
match.picks.push(map.id);
|
||||
await context.sendMessage(`${team.name} picked ${map.title} [${map.difficulty}]`);
|
||||
await context.setMap(map.id);
|
||||
|
||||
return MatchState.PLAYING;
|
||||
}
|
||||
|
||||
async onMapComplete(
|
||||
match: Match,
|
||||
_scores: Score[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const winner = this.calculateMatchWinner(match);
|
||||
if (winner) {
|
||||
return MatchState.COMPLETED;
|
||||
}
|
||||
|
||||
const currentPicker = match.currentPicker;
|
||||
match.currentPicker =
|
||||
currentPicker === match.teams[0].id
|
||||
? match.teams[1].id
|
||||
: match.teams[0].id;
|
||||
|
||||
const team1 = match.teams[0];
|
||||
const team2 = match.teams[1];
|
||||
await context.sendMessage(
|
||||
`Score: ${team1.name} ${getTeamWins(match, team1.id)} - ${getTeamWins(match, team2.id)} ${team2.name}`
|
||||
);
|
||||
|
||||
return MatchState.PICKING;
|
||||
}
|
||||
|
||||
isValidPick(match: Match, team: Team, map: OsuMap): boolean {
|
||||
if (match.currentPicker !== team.id) return false;
|
||||
if (match.picks.includes(map.id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
isValidBan(_match: Match, _team: Team, _map: OsuMap): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
calculateMapWinner(match: Match, scores: Score[]): string {
|
||||
const team1 = match.teams[0];
|
||||
const team2 = match.teams[1];
|
||||
|
||||
const team1Score = scores.find((s) =>
|
||||
team1.players.some((p) => p.id === s.playerId)
|
||||
)?.score ?? 0;
|
||||
|
||||
const team2Score = scores.find((s) =>
|
||||
team2.players.some((p) => p.id === s.playerId)
|
||||
)?.score ?? 0;
|
||||
|
||||
return team1Score > team2Score ? team1.id : team2.id;
|
||||
}
|
||||
|
||||
calculateMatchWinner(match: Match): string | null {
|
||||
const winsNeeded = this.getWinsNeeded();
|
||||
|
||||
for (const team of match.teams) {
|
||||
if (getTeamWins(match, team.id) >= winsNeeded) {
|
||||
return team.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getPickingTeam(match: Match): Team {
|
||||
const team = match.teams.find((t) => t.id === match.currentPicker);
|
||||
return team ?? match.teams[0];
|
||||
}
|
||||
|
||||
getBanningTeam(match: Match): Team {
|
||||
return match.teams[0];
|
||||
}
|
||||
|
||||
getWinsNeeded(): number {
|
||||
return Math.ceil(this.bestOf / 2);
|
||||
}
|
||||
}
|
||||
63
backend/src/domain/protocols/IMatchProtocol.ts
Normal file
63
backend/src/domain/protocols/IMatchProtocol.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Match } from "../models/Match";
|
||||
import type { MatchState } from "../models/MatchState";
|
||||
import type { Team } from "../models/Team";
|
||||
import type { OsuMap } from "../models/OsuMap";
|
||||
import type { Score } from "../models/Score";
|
||||
|
||||
export interface RollResult {
|
||||
teamId: string;
|
||||
playerId: string;
|
||||
roll: number;
|
||||
}
|
||||
|
||||
export interface ProtocolContext {
|
||||
sendMessage: (message: string) => Promise<void>;
|
||||
setMap: (mapId: number) => Promise<void>;
|
||||
startGame: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMatchProtocol {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
|
||||
readonly teamSize: number;
|
||||
readonly bestOf: number;
|
||||
readonly hasPickBan: boolean;
|
||||
readonly hasFreemod: boolean;
|
||||
|
||||
onMatchStart(match: Match, context: ProtocolContext): Promise<MatchState | null>;
|
||||
onAllPlayersJoined(match: Match, context: ProtocolContext): Promise<MatchState | null>;
|
||||
onRollComplete(
|
||||
match: Match,
|
||||
rolls: RollResult[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null>;
|
||||
onPick(
|
||||
match: Match,
|
||||
team: Team,
|
||||
map: OsuMap,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null>;
|
||||
onBan(
|
||||
match: Match,
|
||||
team: Team,
|
||||
map: OsuMap,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null>;
|
||||
onMapComplete(
|
||||
match: Match,
|
||||
scores: Score[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null>;
|
||||
|
||||
isValidPick(match: Match, team: Team, map: OsuMap): boolean;
|
||||
isValidBan(match: Match, team: Team, map: OsuMap): boolean;
|
||||
|
||||
calculateMapWinner(match: Match, scores: Score[]): string;
|
||||
calculateMatchWinner(match: Match): string | null;
|
||||
|
||||
getPickingTeam(match: Match): Team;
|
||||
getBanningTeam(match: Match): Team;
|
||||
|
||||
getWinsNeeded(): number;
|
||||
}
|
||||
32
backend/src/domain/protocols/ProtocolRegistry.ts
Normal file
32
backend/src/domain/protocols/ProtocolRegistry.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IMatchProtocol } from "./IMatchProtocol";
|
||||
|
||||
export class ProtocolRegistry {
|
||||
private protocols: Map<string, IMatchProtocol> = new Map();
|
||||
|
||||
register(protocol: IMatchProtocol): void {
|
||||
if (this.protocols.has(protocol.name)) {
|
||||
throw new Error(`Protocol "${protocol.name}" is already registered`);
|
||||
}
|
||||
this.protocols.set(protocol.name, protocol);
|
||||
}
|
||||
|
||||
get(name: string): IMatchProtocol {
|
||||
const protocol = this.protocols.get(name);
|
||||
if (!protocol) {
|
||||
throw new Error(`Protocol "${name}" not found`);
|
||||
}
|
||||
return protocol;
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.protocols.has(name);
|
||||
}
|
||||
|
||||
list(): IMatchProtocol[] {
|
||||
return Array.from(this.protocols.values());
|
||||
}
|
||||
|
||||
names(): string[] {
|
||||
return Array.from(this.protocols.keys());
|
||||
}
|
||||
}
|
||||
192
backend/src/domain/protocols/StandardOsuProtocol.ts
Normal file
192
backend/src/domain/protocols/StandardOsuProtocol.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type {
|
||||
IMatchProtocol,
|
||||
ProtocolContext,
|
||||
RollResult,
|
||||
} from "./IMatchProtocol";
|
||||
import type { Match } from "../models/Match";
|
||||
import { MatchState } from "../models/MatchState";
|
||||
import type { Team } from "../models/Team";
|
||||
import type { OsuMap } from "../models/OsuMap";
|
||||
import type { Score } from "../models/Score";
|
||||
import { getTeamWins } from "../models/Match";
|
||||
|
||||
export class StandardOsuProtocol implements IMatchProtocol {
|
||||
readonly name = "standard-osu";
|
||||
readonly description = "Standard 4v4 tournament format with pick/ban";
|
||||
readonly teamSize = 4;
|
||||
readonly bestOf = 9;
|
||||
readonly hasPickBan = true;
|
||||
readonly hasFreemod = true;
|
||||
|
||||
async onMatchStart(
|
||||
_match: Match,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
await context.sendMessage("Welcome to the match!");
|
||||
await context.sendMessage("Waiting for all players to join...");
|
||||
return MatchState.WAITING_PLAYERS;
|
||||
}
|
||||
|
||||
async onAllPlayersJoined(
|
||||
_match: Match,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
await context.sendMessage("All players have joined! Please roll (!roll)");
|
||||
return MatchState.ROLLING;
|
||||
}
|
||||
|
||||
async onRollComplete(
|
||||
match: Match,
|
||||
rolls: RollResult[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const team1Roll = rolls.find((r) => r.teamId === match.teams[0].id);
|
||||
const team2Roll = rolls.find((r) => r.teamId === match.teams[1].id);
|
||||
|
||||
if (!team1Roll || !team2Roll) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const winningTeam =
|
||||
team1Roll.roll > team2Roll.roll ? match.teams[0] : match.teams[1];
|
||||
const losingTeam =
|
||||
team1Roll.roll > team2Roll.roll ? match.teams[1] : match.teams[0];
|
||||
|
||||
match.currentBanner = winningTeam.id;
|
||||
match.currentPicker = losingTeam.id;
|
||||
|
||||
await context.sendMessage(
|
||||
`${winningTeam.name} wins the roll! They will ban first, ${losingTeam.name} picks first.`
|
||||
);
|
||||
|
||||
return MatchState.BANNING;
|
||||
}
|
||||
|
||||
async onBan(
|
||||
match: Match,
|
||||
team: Team,
|
||||
map: OsuMap,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const existingBans = match.bans.get(team.id);
|
||||
const bans = existingBans ? [...existingBans] : [];
|
||||
bans.push(map.id);
|
||||
match.bans.set(team.id, bans);
|
||||
|
||||
await context.sendMessage(`${team.name} banned ${map.title} [${map.difficulty}]`);
|
||||
|
||||
const totalBans = Array.from(match.bans.values()).flat().length;
|
||||
if (totalBans >= 2) {
|
||||
return MatchState.PICKING;
|
||||
}
|
||||
|
||||
const currentBanner = match.currentBanner;
|
||||
match.currentBanner =
|
||||
currentBanner === match.teams[0].id
|
||||
? match.teams[1].id
|
||||
: match.teams[0].id;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async onPick(
|
||||
match: Match,
|
||||
team: Team,
|
||||
map: OsuMap,
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
match.picks.push(map.id);
|
||||
await context.sendMessage(`${team.name} picked ${map.title} [${map.difficulty}]`);
|
||||
await context.setMap(map.id);
|
||||
|
||||
return MatchState.PLAYING;
|
||||
}
|
||||
|
||||
async onMapComplete(
|
||||
match: Match,
|
||||
_scores: Score[],
|
||||
context: ProtocolContext
|
||||
): Promise<MatchState | null> {
|
||||
const winner = this.calculateMatchWinner(match);
|
||||
if (winner) {
|
||||
return MatchState.COMPLETED;
|
||||
}
|
||||
|
||||
const currentPicker = match.currentPicker;
|
||||
match.currentPicker =
|
||||
currentPicker === match.teams[0].id
|
||||
? match.teams[1].id
|
||||
: match.teams[0].id;
|
||||
|
||||
const team1 = match.teams[0];
|
||||
const team2 = match.teams[1];
|
||||
await context.sendMessage(
|
||||
`Score: ${team1.name} ${getTeamWins(match, team1.id)} - ${getTeamWins(match, team2.id)} ${team2.name}`
|
||||
);
|
||||
|
||||
return MatchState.PICKING;
|
||||
}
|
||||
|
||||
isValidPick(match: Match, team: Team, map: OsuMap): boolean {
|
||||
if (match.currentPicker !== team.id) return false;
|
||||
|
||||
const allBans = Array.from(match.bans.values()).flat();
|
||||
if (allBans.includes(map.id)) return false;
|
||||
|
||||
if (match.picks.includes(map.id)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isValidBan(match: Match, team: Team, map: OsuMap): boolean {
|
||||
if (match.currentBanner !== team.id) return false;
|
||||
|
||||
const allBans = Array.from(match.bans.values()).flat();
|
||||
if (allBans.includes(map.id)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
calculateMapWinner(match: Match, scores: Score[]): string {
|
||||
const team1 = match.teams[0];
|
||||
const team2 = match.teams[1];
|
||||
const team1PlayerIds = team1.players.map((p) => p.id);
|
||||
const team2PlayerIds = team2.players.map((p) => p.id);
|
||||
|
||||
const team1Score = scores
|
||||
.filter((s) => team1PlayerIds.includes(s.playerId))
|
||||
.reduce((sum, s) => sum + s.score, 0);
|
||||
|
||||
const team2Score = scores
|
||||
.filter((s) => team2PlayerIds.includes(s.playerId))
|
||||
.reduce((sum, s) => sum + s.score, 0);
|
||||
|
||||
return team1Score > team2Score ? team1.id : team2.id;
|
||||
}
|
||||
|
||||
calculateMatchWinner(match: Match): string | null {
|
||||
const winsNeeded = this.getWinsNeeded();
|
||||
|
||||
for (const team of match.teams) {
|
||||
if (getTeamWins(match, team.id) >= winsNeeded) {
|
||||
return team.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getPickingTeam(match: Match): Team {
|
||||
const team = match.teams.find((t) => t.id === match.currentPicker);
|
||||
return team ?? match.teams[0];
|
||||
}
|
||||
|
||||
getBanningTeam(match: Match): Team {
|
||||
const team = match.teams.find((t) => t.id === match.currentBanner);
|
||||
return team ?? match.teams[0];
|
||||
}
|
||||
|
||||
getWinsNeeded(): number {
|
||||
return Math.ceil(this.bestOf / 2);
|
||||
}
|
||||
}
|
||||
4
backend/src/domain/protocols/index.ts
Normal file
4
backend/src/domain/protocols/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./IMatchProtocol";
|
||||
export * from "./StandardOsuProtocol";
|
||||
export * from "./HeadToHeadProtocol";
|
||||
export * from "./ProtocolRegistry";
|
||||
197
backend/src/index.ts
Normal file
197
backend/src/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import { EventBus } from "./application/events/EventBus";
|
||||
import { MatchService } from "./application/services/MatchService";
|
||||
import { QueueService } from "./application/services/QueueService";
|
||||
import { ProtocolRegistry } from "./domain/protocols/ProtocolRegistry";
|
||||
import { StandardOsuProtocol } from "./domain/protocols/StandardOsuProtocol";
|
||||
import { HeadToHeadProtocol } from "./domain/protocols/HeadToHeadProtocol";
|
||||
import { InMemoryPlayerRepository } from "./infrastructure/mocks/InMemoryPlayerRepository";
|
||||
import { InMemoryTeamRepository } from "./infrastructure/mocks/InMemoryTeamRepository";
|
||||
import { InMemoryMatchRepository } from "./infrastructure/mocks/InMemoryMatchRepository";
|
||||
import { InMemoryQueueRepository } from "./infrastructure/mocks/InMemoryQueueRepository";
|
||||
import { MockGameService } from "./infrastructure/mocks/MockGameService";
|
||||
import { MockNotificationService } from "./infrastructure/mocks/MockNotificationService";
|
||||
import { WebSocketService } from "./infrastructure/websocket/WebSocketService";
|
||||
import { getTeamWins } from "./domain/models/Match";
|
||||
import { getAverageRating } from "./domain/models/Team";
|
||||
|
||||
interface WebSocketData {
|
||||
id: string;
|
||||
subscribedMatches: Set<string>;
|
||||
}
|
||||
|
||||
const eventBus = new EventBus();
|
||||
const mockGameService = new MockGameService();
|
||||
const mockNotificationService = new MockNotificationService();
|
||||
|
||||
const playerRepo = new InMemoryPlayerRepository();
|
||||
const teamRepo = new InMemoryTeamRepository();
|
||||
const matchRepo = new InMemoryMatchRepository();
|
||||
const queueRepo = new InMemoryQueueRepository();
|
||||
|
||||
const protocolRegistry = new ProtocolRegistry();
|
||||
protocolRegistry.register(new StandardOsuProtocol());
|
||||
protocolRegistry.register(new HeadToHeadProtocol());
|
||||
|
||||
const matchService = new MatchService(
|
||||
matchRepo,
|
||||
playerRepo,
|
||||
protocolRegistry,
|
||||
mockGameService,
|
||||
mockNotificationService,
|
||||
eventBus
|
||||
);
|
||||
|
||||
const queueService = new QueueService(queueRepo, matchService, eventBus, {
|
||||
minQueueTime: 0,
|
||||
protocol: "head-to-head",
|
||||
});
|
||||
|
||||
const wsService = new WebSocketService(eventBus);
|
||||
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
const upgraded = server.upgrade(req, {
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
subscribedMatches: new Set(),
|
||||
},
|
||||
});
|
||||
if (upgraded) return undefined;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/health") {
|
||||
return Response.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/queue/status" && req.method === "GET") {
|
||||
const queueType = url.searchParams.get("type") ?? "ranked";
|
||||
const status = await queueService.getQueueStatus(queueType);
|
||||
return Response.json(status);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/queue/join" && req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const team = await teamRepo.findById(body.teamId);
|
||||
if (!team) {
|
||||
return Response.json({ error: "Team not found" }, { status: 404 });
|
||||
}
|
||||
const entry = await queueService.joinQueue(team, body.queueType ?? "ranked");
|
||||
return Response.json({ success: true, entry: { id: entry.id, joinedAt: entry.joinedAt } });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/queue/leave" && req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
await queueService.leaveQueue(body.teamId);
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/matches/") && req.method === "GET") {
|
||||
const matchId = url.pathname.split("/").pop();
|
||||
if (!matchId) {
|
||||
return Response.json({ error: "Match ID required" }, { status: 400 });
|
||||
}
|
||||
const match = await matchRepo.findById(matchId);
|
||||
if (!match) {
|
||||
return Response.json({ error: "Match not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json({
|
||||
id: match.id,
|
||||
state: match.state,
|
||||
protocol: match.protocol,
|
||||
teams: match.teams.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
players: t.players.map((p) => ({
|
||||
id: p.id,
|
||||
displayName: p.displayName,
|
||||
rating: p.rating,
|
||||
})),
|
||||
averageRating: getAverageRating(t),
|
||||
wins: getTeamWins(match, t.id),
|
||||
})),
|
||||
winner: match.winner ? { id: match.winner.id, name: match.winner.name } : null,
|
||||
createdAt: match.createdAt,
|
||||
startedAt: match.startedAt,
|
||||
endedAt: match.endedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/matches" && req.method === "GET") {
|
||||
const matches = await matchRepo.findAll();
|
||||
return Response.json(
|
||||
matches.map((m) => ({
|
||||
id: m.id,
|
||||
state: m.state,
|
||||
teams: m.teams.map((t) => t.name),
|
||||
winner: m.winner?.name ?? null,
|
||||
createdAt: m.createdAt,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/players" && req.method === "GET") {
|
||||
const players = await playerRepo.findAll();
|
||||
return Response.json(players);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/teams" && req.method === "GET") {
|
||||
const teams = await teamRepo.findAll();
|
||||
return Response.json(
|
||||
teams.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
playerCount: t.players.length,
|
||||
averageRating: getAverageRating(t),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/protocols" && req.method === "GET") {
|
||||
const protocols = protocolRegistry.list();
|
||||
return Response.json(
|
||||
protocols.map((p) => ({
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
teamSize: p.teamSize,
|
||||
bestOf: p.bestOf,
|
||||
hasPickBan: p.hasPickBan,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws: ServerWebSocket<WebSocketData>) {
|
||||
wsService.handleOpen(ws);
|
||||
},
|
||||
message(ws: ServerWebSocket<WebSocketData>, message: string | Buffer) {
|
||||
wsService.handleMessage(ws, message);
|
||||
},
|
||||
close(ws: ServerWebSocket<WebSocketData>) {
|
||||
wsService.handleClose(ws);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Server running at http://localhost:${server.port}`);
|
||||
console.log(`WebSocket available at ws://localhost:${server.port}/ws`);
|
||||
|
||||
queueService.startAutoMatchmaking(5000);
|
||||
console.log("Auto-matchmaking started (5s interval)");
|
||||
293
backend/src/infrastructure/database/PostgresMatchRepository.ts
Normal file
293
backend/src/infrastructure/database/PostgresMatchRepository.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type { SQL } from "bun";
|
||||
import type { IMatchRepository } from "../../domain/interfaces/IMatchRepository";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import { MatchState, isActiveState } from "../../domain/models/MatchState";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
import type { MatchScore, Score } from "../../domain/models/Score";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
protocol: string;
|
||||
state: string;
|
||||
lobby_id: string | null;
|
||||
winner_team_id: string | null;
|
||||
created_at: Date;
|
||||
started_at: Date | null;
|
||||
ended_at: Date | null;
|
||||
}
|
||||
|
||||
interface PlayerRow {
|
||||
id: string;
|
||||
discord_id: string;
|
||||
osu_id: number;
|
||||
osu_name: string;
|
||||
display_name: string;
|
||||
rating: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface TeamRow {
|
||||
id: string;
|
||||
name: string;
|
||||
captain_id: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export class PostgresMatchRepository implements IMatchRepository {
|
||||
constructor(private db: SQL) {}
|
||||
|
||||
private rowToPlayer(row: PlayerRow): Player {
|
||||
return {
|
||||
id: row.id,
|
||||
discordId: row.discord_id,
|
||||
osuId: row.osu_id,
|
||||
osuName: row.osu_name,
|
||||
displayName: row.display_name,
|
||||
rating: row.rating,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadTeamPlayers(teamId: string): Promise<Player[]> {
|
||||
const rows = await this.db`
|
||||
SELECT p.* FROM players p
|
||||
JOIN team_players tp ON tp.player_id = p.id
|
||||
WHERE tp.team_id = ${teamId}
|
||||
`;
|
||||
return rows.map((row) => this.rowToPlayer(row as PlayerRow));
|
||||
}
|
||||
|
||||
private async loadMatchTeams(matchId: string): Promise<Team[]> {
|
||||
const teamRows = await this.db`
|
||||
SELECT t.*, mt.team_order FROM teams t
|
||||
JOIN match_teams mt ON mt.team_id = t.id
|
||||
WHERE mt.match_id = ${matchId}
|
||||
ORDER BY mt.team_order
|
||||
`;
|
||||
|
||||
const teams: Team[] = [];
|
||||
for (const row of teamRows as (TeamRow & { team_order: number })[]) {
|
||||
const players = await this.loadTeamPlayers(row.id);
|
||||
const captain = players.find((p) => p.id === row.captain_id) ?? players[0];
|
||||
teams.push({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
players,
|
||||
captain,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
private async loadMatchScores(matchId: string): Promise<MatchScore[]> {
|
||||
const scoreRows = await this.db`
|
||||
SELECT * FROM match_scores WHERE match_id = ${matchId} ORDER BY map_order
|
||||
`;
|
||||
|
||||
const scores: MatchScore[] = [];
|
||||
for (const row of scoreRows as any[]) {
|
||||
const playerScoreRows = await this.db`
|
||||
SELECT * FROM player_scores WHERE match_score_id = ${row.id}
|
||||
`;
|
||||
|
||||
const teamScores = new Map<string, number>();
|
||||
const playerScores = new Map<string, Score>();
|
||||
|
||||
for (const ps of playerScoreRows as any[]) {
|
||||
playerScores.set(ps.player_id, {
|
||||
playerId: ps.player_id,
|
||||
score: ps.score,
|
||||
accuracy: ps.accuracy,
|
||||
maxCombo: ps.max_combo,
|
||||
misses: ps.misses,
|
||||
mods: ps.mods,
|
||||
});
|
||||
}
|
||||
|
||||
scores.push({
|
||||
id: row.id,
|
||||
mapId: row.map_id,
|
||||
teamScores,
|
||||
playerScores,
|
||||
winner: row.winner_team_id,
|
||||
});
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
private async loadMatchRolls(matchId: string): Promise<Map<string, number>> {
|
||||
const rows = await this.db`SELECT * FROM match_rolls WHERE match_id = ${matchId}`;
|
||||
const rolls = new Map<string, number>();
|
||||
for (const row of rows as any[]) {
|
||||
rolls.set(row.team_id, row.roll_value);
|
||||
}
|
||||
return rolls;
|
||||
}
|
||||
|
||||
private async loadMatchBans(matchId: string): Promise<Map<string, number[]>> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM match_bans WHERE match_id = ${matchId} ORDER BY ban_order
|
||||
`;
|
||||
const bans = new Map<string, number[]>();
|
||||
for (const row of rows as any[]) {
|
||||
const teamBans = bans.get(row.team_id) ?? [];
|
||||
teamBans.push(row.map_id);
|
||||
bans.set(row.team_id, teamBans);
|
||||
}
|
||||
return bans;
|
||||
}
|
||||
|
||||
private async loadMatchPicks(matchId: string): Promise<number[]> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM match_picks WHERE match_id = ${matchId} ORDER BY pick_order
|
||||
`;
|
||||
return (rows as any[]).map((r) => r.map_id);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Match | null> {
|
||||
const rows = await this.db`SELECT * FROM matches WHERE id = ${id}`;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0] as MatchRow;
|
||||
const teams = await this.loadMatchTeams(id);
|
||||
const scores = await this.loadMatchScores(id);
|
||||
const rolls = await this.loadMatchRolls(id);
|
||||
const bans = await this.loadMatchBans(id);
|
||||
const picks = await this.loadMatchPicks(id);
|
||||
|
||||
const winner = row.winner_team_id
|
||||
? teams.find((t) => t.id === row.winner_team_id)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
teams,
|
||||
protocol: row.protocol,
|
||||
state: row.state as MatchState,
|
||||
scores,
|
||||
winner,
|
||||
lobbyId: row.lobby_id ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
startedAt: row.started_at ?? undefined,
|
||||
endedAt: row.ended_at ?? undefined,
|
||||
rolls,
|
||||
bans,
|
||||
picks,
|
||||
joinedPlayers: new Set(),
|
||||
currentPicker: undefined,
|
||||
currentBanner: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async findActive(): Promise<Match[]> {
|
||||
const rows = await this.db`
|
||||
SELECT id FROM matches WHERE state NOT IN ('completed', 'cancelled')
|
||||
`;
|
||||
const matches: Match[] = [];
|
||||
for (const row of rows as { id: string }[]) {
|
||||
const match = await this.findById(row.id);
|
||||
if (match) matches.push(match);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async findByPlayer(playerId: string, limit?: number): Promise<Match[]> {
|
||||
const query = limit
|
||||
? this.db`
|
||||
SELECT DISTINCT m.id FROM matches m
|
||||
JOIN match_teams mt ON mt.match_id = m.id
|
||||
JOIN team_players tp ON tp.team_id = mt.team_id
|
||||
WHERE tp.player_id = ${playerId}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`
|
||||
: this.db`
|
||||
SELECT DISTINCT m.id FROM matches m
|
||||
JOIN match_teams mt ON mt.match_id = m.id
|
||||
JOIN team_players tp ON tp.team_id = mt.team_id
|
||||
WHERE tp.player_id = ${playerId}
|
||||
ORDER BY m.created_at DESC
|
||||
`;
|
||||
|
||||
const rows = await query;
|
||||
const matches: Match[] = [];
|
||||
for (const row of rows as { id: string }[]) {
|
||||
const match = await this.findById(row.id);
|
||||
if (match) matches.push(match);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
async findByLobbyId(lobbyId: string): Promise<Match | null> {
|
||||
const rows = await this.db`SELECT id FROM matches WHERE lobby_id = ${lobbyId}`;
|
||||
if (rows.length === 0) return null;
|
||||
return this.findById((rows[0] as { id: string }).id);
|
||||
}
|
||||
|
||||
async save(match: Match): Promise<void> {
|
||||
await this.db`
|
||||
INSERT INTO matches (id, protocol, state, lobby_id, winner_team_id, created_at, started_at, ended_at)
|
||||
VALUES (${match.id}, ${match.protocol}, ${match.state}, ${match.lobbyId ?? null}, ${match.winner?.id ?? null}, ${match.createdAt}, ${match.startedAt ?? null}, ${match.endedAt ?? null})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
protocol = EXCLUDED.protocol,
|
||||
state = EXCLUDED.state,
|
||||
lobby_id = EXCLUDED.lobby_id,
|
||||
winner_team_id = EXCLUDED.winner_team_id,
|
||||
started_at = EXCLUDED.started_at,
|
||||
ended_at = EXCLUDED.ended_at
|
||||
`;
|
||||
|
||||
await this.db`DELETE FROM match_teams WHERE match_id = ${match.id}`;
|
||||
for (let i = 0; i < match.teams.length; i++) {
|
||||
await this.db`
|
||||
INSERT INTO match_teams (match_id, team_id, team_order)
|
||||
VALUES (${match.id}, ${match.teams[i].id}, ${i})
|
||||
`;
|
||||
}
|
||||
|
||||
await this.db`DELETE FROM match_rolls WHERE match_id = ${match.id}`;
|
||||
for (const [teamId, roll] of match.rolls) {
|
||||
await this.db`
|
||||
INSERT INTO match_rolls (match_id, team_id, roll_value)
|
||||
VALUES (${match.id}, ${teamId}, ${roll})
|
||||
`;
|
||||
}
|
||||
|
||||
await this.db`DELETE FROM match_bans WHERE match_id = ${match.id}`;
|
||||
let banOrder = 0;
|
||||
for (const [teamId, mapIds] of match.bans) {
|
||||
for (const mapId of mapIds) {
|
||||
await this.db`
|
||||
INSERT INTO match_bans (match_id, team_id, map_id, ban_order)
|
||||
VALUES (${match.id}, ${teamId}, ${mapId}, ${banOrder++})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await this.db`DELETE FROM match_picks WHERE match_id = ${match.id}`;
|
||||
for (let i = 0; i < match.picks.length; i++) {
|
||||
await this.db`
|
||||
INSERT INTO match_picks (match_id, map_id, pick_order)
|
||||
VALUES (${match.id}, ${match.picks[i]}, ${i})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async updateState(matchId: string, state: MatchState): Promise<void> {
|
||||
await this.db`UPDATE matches SET state = ${state} WHERE id = ${matchId}`;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Match[]> {
|
||||
const rows = await this.db`SELECT id FROM matches ORDER BY created_at DESC`;
|
||||
const matches: Match[] = [];
|
||||
for (const row of rows as { id: string }[]) {
|
||||
const match = await this.findById(row.id);
|
||||
if (match) matches.push(match);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { SQL } from "bun";
|
||||
import type { IPlayerRepository } from "../../domain/interfaces/IPlayerRepository";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
|
||||
interface PlayerRow {
|
||||
id: string;
|
||||
discord_id: string;
|
||||
osu_id: number;
|
||||
osu_name: string;
|
||||
display_name: string;
|
||||
rating: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export class PostgresPlayerRepository implements IPlayerRepository {
|
||||
constructor(private db: SQL) {}
|
||||
|
||||
private rowToPlayer(row: PlayerRow): Player {
|
||||
return {
|
||||
id: row.id,
|
||||
discordId: row.discord_id,
|
||||
osuId: row.osu_id,
|
||||
osuName: row.osu_name,
|
||||
displayName: row.display_name,
|
||||
rating: row.rating,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Player | null> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM players WHERE id = ${id}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return this.rowToPlayer(rows[0] as PlayerRow);
|
||||
}
|
||||
|
||||
async findByOsuId(osuId: number): Promise<Player | null> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM players WHERE osu_id = ${osuId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return this.rowToPlayer(rows[0] as PlayerRow);
|
||||
}
|
||||
|
||||
async findByDiscordId(discordId: string): Promise<Player | null> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM players WHERE discord_id = ${discordId}
|
||||
`;
|
||||
if (rows.length === 0) return null;
|
||||
return this.rowToPlayer(rows[0] as PlayerRow);
|
||||
}
|
||||
|
||||
async save(player: Player): Promise<void> {
|
||||
await this.db`
|
||||
INSERT INTO players (id, discord_id, osu_id, osu_name, display_name, rating, created_at)
|
||||
VALUES (${player.id}, ${player.discordId}, ${player.osuId}, ${player.osuName}, ${player.displayName}, ${player.rating}, ${player.createdAt})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
discord_id = EXCLUDED.discord_id,
|
||||
osu_id = EXCLUDED.osu_id,
|
||||
osu_name = EXCLUDED.osu_name,
|
||||
display_name = EXCLUDED.display_name,
|
||||
rating = EXCLUDED.rating
|
||||
`;
|
||||
}
|
||||
|
||||
async updateRating(playerId: string, newRating: number): Promise<void> {
|
||||
await this.db`
|
||||
UPDATE players SET rating = ${newRating} WHERE id = ${playerId}
|
||||
`;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Player[]> {
|
||||
const rows = await this.db`SELECT * FROM players ORDER BY created_at DESC`;
|
||||
return rows.map((row) => this.rowToPlayer(row as PlayerRow));
|
||||
}
|
||||
}
|
||||
173
backend/src/infrastructure/database/PostgresQueueRepository.ts
Normal file
173
backend/src/infrastructure/database/PostgresQueueRepository.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { SQL } from "bun";
|
||||
import type { IQueueRepository } from "../../domain/interfaces/IQueueRepository";
|
||||
import type { QueueEntry } from "../../domain/models/QueueEntry";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
import { getAverageRating } from "../../domain/models/Team";
|
||||
|
||||
interface PlayerRow {
|
||||
id: string;
|
||||
discord_id: string;
|
||||
osu_id: number;
|
||||
osu_name: string;
|
||||
display_name: string;
|
||||
rating: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface TeamRow {
|
||||
id: string;
|
||||
name: string;
|
||||
captain_id: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface QueueEntryRow {
|
||||
id: string;
|
||||
team_id: string;
|
||||
queue_type: string;
|
||||
joined_at: Date;
|
||||
}
|
||||
|
||||
export class PostgresQueueRepository implements IQueueRepository {
|
||||
constructor(private db: SQL) {}
|
||||
|
||||
private rowToPlayer(row: PlayerRow): Player {
|
||||
return {
|
||||
id: row.id,
|
||||
discordId: row.discord_id,
|
||||
osuId: row.osu_id,
|
||||
osuName: row.osu_name,
|
||||
displayName: row.display_name,
|
||||
rating: row.rating,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadTeamPlayers(teamId: string): Promise<Player[]> {
|
||||
const rows = await this.db`
|
||||
SELECT p.* FROM players p
|
||||
JOIN team_players tp ON tp.player_id = p.id
|
||||
WHERE tp.team_id = ${teamId}
|
||||
`;
|
||||
return rows.map((row) => this.rowToPlayer(row as PlayerRow));
|
||||
}
|
||||
|
||||
private async loadTeam(teamId: string): Promise<Team | null> {
|
||||
const rows = await this.db`SELECT * FROM teams WHERE id = ${teamId}`;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0] as TeamRow;
|
||||
const players = await this.loadTeamPlayers(teamId);
|
||||
const captain = players.find((p) => p.id === row.captain_id) ?? players[0];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
players,
|
||||
captain,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async add(entry: QueueEntry): Promise<void> {
|
||||
await this.db`
|
||||
INSERT INTO queue_entries (id, team_id, queue_type, joined_at)
|
||||
VALUES (${entry.id}, ${entry.team.id}, ${entry.queueType}, ${entry.joinedAt})
|
||||
ON CONFLICT (team_id) DO UPDATE SET
|
||||
queue_type = EXCLUDED.queue_type,
|
||||
joined_at = EXCLUDED.joined_at
|
||||
`;
|
||||
}
|
||||
|
||||
async remove(teamId: string): Promise<void> {
|
||||
await this.db`DELETE FROM queue_entries WHERE team_id = ${teamId}`;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<QueueEntry | null> {
|
||||
const rows = await this.db`SELECT * FROM queue_entries WHERE team_id = ${teamId}`;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0] as QueueEntryRow;
|
||||
const team = await this.loadTeam(row.team_id);
|
||||
if (!team) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
team,
|
||||
queueType: row.queue_type,
|
||||
joinedAt: row.joined_at,
|
||||
};
|
||||
}
|
||||
|
||||
async findByType(queueType: string): Promise<QueueEntry[]> {
|
||||
const rows = await this.db`
|
||||
SELECT * FROM queue_entries
|
||||
WHERE queue_type = ${queueType}
|
||||
ORDER BY joined_at ASC
|
||||
`;
|
||||
|
||||
const entries: QueueEntry[] = [];
|
||||
for (const row of rows as QueueEntryRow[]) {
|
||||
const team = await this.loadTeam(row.team_id);
|
||||
if (team) {
|
||||
entries.push({
|
||||
id: row.id,
|
||||
team,
|
||||
queueType: row.queue_type,
|
||||
joinedAt: row.joined_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async findClosestByRating(
|
||||
rating: number,
|
||||
queueType: string,
|
||||
exclude?: string[]
|
||||
): Promise<QueueEntry | null> {
|
||||
const entries = await this.findByType(queueType);
|
||||
|
||||
let closest: QueueEntry | null = null;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (exclude?.includes(entry.team.id)) continue;
|
||||
|
||||
const teamRating = getAverageRating(entry.team);
|
||||
const diff = Math.abs(teamRating - rating);
|
||||
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
async findAll(): Promise<QueueEntry[]> {
|
||||
const rows = await this.db`SELECT * FROM queue_entries ORDER BY joined_at ASC`;
|
||||
|
||||
const entries: QueueEntry[] = [];
|
||||
for (const row of rows as QueueEntryRow[]) {
|
||||
const team = await this.loadTeam(row.team_id);
|
||||
if (team) {
|
||||
entries.push({
|
||||
id: row.id,
|
||||
team,
|
||||
queueType: row.queue_type,
|
||||
joinedAt: row.joined_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.db`DELETE FROM queue_entries`;
|
||||
}
|
||||
}
|
||||
128
backend/src/infrastructure/database/PostgresTeamRepository.ts
Normal file
128
backend/src/infrastructure/database/PostgresTeamRepository.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { SQL } from "bun";
|
||||
import type { ITeamRepository } from "../../domain/interfaces/ITeamRepository";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
|
||||
interface TeamRow {
|
||||
id: string;
|
||||
name: string;
|
||||
captain_id: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
interface PlayerRow {
|
||||
id: string;
|
||||
discord_id: string;
|
||||
osu_id: number;
|
||||
osu_name: string;
|
||||
display_name: string;
|
||||
rating: number;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export class PostgresTeamRepository implements ITeamRepository {
|
||||
constructor(private db: SQL) {}
|
||||
|
||||
private rowToPlayer(row: PlayerRow): Player {
|
||||
return {
|
||||
id: row.id,
|
||||
discordId: row.discord_id,
|
||||
osuId: row.osu_id,
|
||||
osuName: row.osu_name,
|
||||
displayName: row.display_name,
|
||||
rating: row.rating,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadTeamPlayers(teamId: string): Promise<Player[]> {
|
||||
const rows = await this.db`
|
||||
SELECT p.* FROM players p
|
||||
JOIN team_players tp ON tp.player_id = p.id
|
||||
WHERE tp.team_id = ${teamId}
|
||||
`;
|
||||
return rows.map((row) => this.rowToPlayer(row as PlayerRow));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
const rows = await this.db`SELECT * FROM teams WHERE id = ${id}`;
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const row = rows[0] as TeamRow;
|
||||
const players = await this.loadTeamPlayers(id);
|
||||
const captain = players.find((p) => p.id === row.captain_id) ?? players[0];
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
players,
|
||||
captain,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async findByPlayer(playerId: string): Promise<Team[]> {
|
||||
const teamRows = await this.db`
|
||||
SELECT t.* FROM teams t
|
||||
JOIN team_players tp ON tp.team_id = t.id
|
||||
WHERE tp.player_id = ${playerId}
|
||||
`;
|
||||
|
||||
const teams: Team[] = [];
|
||||
for (const row of teamRows as TeamRow[]) {
|
||||
const players = await this.loadTeamPlayers(row.id);
|
||||
const captain = players.find((p) => p.id === row.captain_id) ?? players[0];
|
||||
teams.push({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
players,
|
||||
captain,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
|
||||
async save(team: Team): Promise<void> {
|
||||
await this.db`
|
||||
INSERT INTO teams (id, name, captain_id, created_at)
|
||||
VALUES (${team.id}, ${team.name}, ${team.captain.id}, ${team.createdAt})
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
captain_id = EXCLUDED.captain_id
|
||||
`;
|
||||
|
||||
await this.db`DELETE FROM team_players WHERE team_id = ${team.id}`;
|
||||
|
||||
for (const player of team.players) {
|
||||
await this.db`
|
||||
INSERT INTO team_players (team_id, player_id)
|
||||
VALUES (${team.id}, ${player.id})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(teamId: string): Promise<void> {
|
||||
await this.db`DELETE FROM teams WHERE id = ${teamId}`;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
const teamRows = await this.db`SELECT * FROM teams ORDER BY created_at DESC`;
|
||||
const teams: Team[] = [];
|
||||
|
||||
for (const row of teamRows as TeamRow[]) {
|
||||
const players = await this.loadTeamPlayers(row.id);
|
||||
const captain = players.find((p) => p.id === row.captain_id) ?? players[0];
|
||||
teams.push({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
players,
|
||||
captain,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return teams;
|
||||
}
|
||||
}
|
||||
4
backend/src/infrastructure/database/index.ts
Normal file
4
backend/src/infrastructure/database/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./PostgresPlayerRepository";
|
||||
export * from "./PostgresTeamRepository";
|
||||
export * from "./PostgresMatchRepository";
|
||||
export * from "./PostgresQueueRepository";
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Initial database schema for Vash Esports Backend
|
||||
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
discord_id VARCHAR(255) UNIQUE NOT NULL,
|
||||
osu_id INTEGER UNIQUE NOT NULL,
|
||||
osu_name VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
rating INTEGER DEFAULT 1000,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
captain_id UUID REFERENCES players(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_players (
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
player_id UUID REFERENCES players(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (team_id, player_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
protocol VARCHAR(100) NOT NULL,
|
||||
state VARCHAR(50) NOT NULL,
|
||||
lobby_id VARCHAR(255),
|
||||
winner_team_id UUID REFERENCES teams(id),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
started_at TIMESTAMP,
|
||||
ended_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_teams (
|
||||
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
team_order INTEGER NOT NULL,
|
||||
PRIMARY KEY (match_id, team_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_rolls (
|
||||
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
roll_value INTEGER NOT NULL,
|
||||
PRIMARY KEY (match_id, team_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_bans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE,
|
||||
map_id INTEGER NOT NULL,
|
||||
ban_order INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_picks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
|
||||
map_id INTEGER NOT NULL,
|
||||
pick_order INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_scores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
|
||||
map_id INTEGER NOT NULL,
|
||||
winner_team_id UUID REFERENCES teams(id),
|
||||
map_order INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_scores (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
match_score_id UUID REFERENCES match_scores(id) ON DELETE CASCADE,
|
||||
player_id UUID REFERENCES players(id),
|
||||
score INTEGER NOT NULL,
|
||||
accuracy DECIMAL(5,2),
|
||||
max_combo INTEGER,
|
||||
misses INTEGER,
|
||||
mods TEXT[]
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS queue_entries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
team_id UUID REFERENCES teams(id) ON DELETE CASCADE UNIQUE,
|
||||
queue_type VARCHAR(50) NOT NULL,
|
||||
joined_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_players_osu_id ON players(osu_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_players_discord_id ON players(discord_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_state ON matches(state) WHERE state NOT IN ('completed', 'cancelled');
|
||||
CREATE INDEX IF NOT EXISTS idx_matches_lobby_id ON matches(lobby_id) WHERE lobby_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_queue_entries_type ON queue_entries(queue_type, joined_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_team_players_player ON team_players(player_id);
|
||||
174
backend/src/infrastructure/discord/DiscordService.ts
Normal file
174
backend/src/infrastructure/discord/DiscordService.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Client, GatewayIntentBits, EmbedBuilder, type TextChannel } from "discord.js";
|
||||
import type { INotificationService } from "../../domain/interfaces/INotificationService";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { MatchScore } from "../../domain/models/Score";
|
||||
import type { EventBus } from "../../application/events/EventBus";
|
||||
import { getTeamWins } from "../../domain/models/Match";
|
||||
|
||||
export interface DiscordConfig {
|
||||
token: string;
|
||||
guildId: string;
|
||||
announcementChannelId: string;
|
||||
matchResultsChannelId: string;
|
||||
}
|
||||
|
||||
export class DiscordService implements INotificationService {
|
||||
private client: Client;
|
||||
private ready = false;
|
||||
|
||||
constructor(
|
||||
private config: DiscordConfig,
|
||||
private eventBus?: EventBus
|
||||
) {
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
});
|
||||
|
||||
this.setupClient();
|
||||
}
|
||||
|
||||
private async setupClient(): Promise<void> {
|
||||
this.client.on("ready", () => {
|
||||
console.log(`[DiscordService] Logged in as ${this.client.user?.tag}`);
|
||||
this.ready = true;
|
||||
});
|
||||
|
||||
this.client.on("error", (error) => {
|
||||
console.error("[DiscordService] Client error:", error);
|
||||
});
|
||||
|
||||
if (this.config.token) {
|
||||
try {
|
||||
await this.client.login(this.config.token);
|
||||
} catch (error) {
|
||||
console.error("[DiscordService] Failed to login:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async announceMatch(match: Match): Promise<void> {
|
||||
if (!this.ready) {
|
||||
console.log("[DiscordService] Not ready, skipping announcement");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.client.channels.fetch(
|
||||
this.config.announcementChannelId
|
||||
);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("New Match Starting!")
|
||||
.setDescription(
|
||||
`**${match.teams[0].name}** vs **${match.teams[1].name}**`
|
||||
)
|
||||
.addFields(
|
||||
{
|
||||
name: match.teams[0].name,
|
||||
value: match.teams[0].players.map((p) => p.displayName).join("\n"),
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: match.teams[1].name,
|
||||
value: match.teams[1].players.map((p) => p.displayName).join("\n"),
|
||||
inline: true,
|
||||
}
|
||||
)
|
||||
.setColor(0x00ae86)
|
||||
.setTimestamp();
|
||||
|
||||
await (channel as TextChannel).send({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async announceMatchResult(match: Match, winner: Team): Promise<void> {
|
||||
if (!this.ready) {
|
||||
console.log("[DiscordService] Not ready, skipping result announcement");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.client.channels.fetch(
|
||||
this.config.matchResultsChannelId
|
||||
);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
|
||||
const team1Wins = getTeamWins(match, match.teams[0].id);
|
||||
const team2Wins = getTeamWins(match, match.teams[1].id);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("Match Completed!")
|
||||
.setDescription(`**${winner.name}** wins!`)
|
||||
.addFields({
|
||||
name: "Final Score",
|
||||
value: `${match.teams[0].name} **${team1Wins}** - **${team2Wins}** ${match.teams[1].name}`,
|
||||
})
|
||||
.setColor(winner.id === match.teams[0].id ? 0x3498db : 0xe74c3c)
|
||||
.setTimestamp();
|
||||
|
||||
await (channel as TextChannel).send({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async notifyPlayer(playerId: string, message: string): Promise<void> {
|
||||
if (!this.ready) {
|
||||
console.log("[DiscordService] Not ready, skipping player notification");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.client.users.fetch(playerId);
|
||||
await user.send(message);
|
||||
} catch (error) {
|
||||
console.error(`[DiscordService] Failed to DM player ${playerId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyTeam(teamId: string, message: string): Promise<void> {
|
||||
// In a real implementation, you'd look up the team's players
|
||||
// and notify each one. For now, just log.
|
||||
console.log(`[DiscordService] Team ${teamId}: ${message}`);
|
||||
}
|
||||
|
||||
async announceMapResult(match: Match, scores: MatchScore): Promise<void> {
|
||||
if (!this.ready) {
|
||||
console.log("[DiscordService] Not ready, skipping map result");
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = await this.client.channels.fetch(
|
||||
this.config.announcementChannelId
|
||||
);
|
||||
if (!channel || !channel.isTextBased()) return;
|
||||
|
||||
const winner = match.teams.find((t) => t.id === scores.winner);
|
||||
const team1Score = scores.teamScores.get(match.teams[0].id) ?? 0;
|
||||
const team2Score = scores.teamScores.get(match.teams[1].id) ?? 0;
|
||||
const team1Wins = getTeamWins(match, match.teams[0].id);
|
||||
const team2Wins = getTeamWins(match, match.teams[1].id);
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("Map Complete")
|
||||
.setDescription(`**${winner?.name}** wins the map!`)
|
||||
.addFields(
|
||||
{
|
||||
name: "Map Score",
|
||||
value: `${match.teams[0].name}: ${team1Score.toLocaleString()}\n${match.teams[1].name}: ${team2Score.toLocaleString()}`,
|
||||
},
|
||||
{
|
||||
name: "Match Score",
|
||||
value: `${match.teams[0].name} **${team1Wins}** - **${team2Wins}** ${match.teams[1].name}`,
|
||||
}
|
||||
)
|
||||
.setColor(0x9b59b6)
|
||||
.setTimestamp();
|
||||
|
||||
await (channel as TextChannel).send({ embeds: [embed] });
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
1
backend/src/infrastructure/discord/index.ts
Normal file
1
backend/src/infrastructure/discord/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./DiscordService";
|
||||
63
backend/src/infrastructure/mocks/InMemoryMatchRepository.ts
Normal file
63
backend/src/infrastructure/mocks/InMemoryMatchRepository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IMatchRepository } from "../../domain/interfaces/IMatchRepository";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import { MatchState, isActiveState } from "../../domain/models/MatchState";
|
||||
|
||||
export class InMemoryMatchRepository implements IMatchRepository {
|
||||
private matches: Map<string, Match> = new Map();
|
||||
|
||||
async findById(id: string): Promise<Match | null> {
|
||||
return this.matches.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findActive(): Promise<Match[]> {
|
||||
const result: Match[] = [];
|
||||
for (const match of this.matches.values()) {
|
||||
if (isActiveState(match.state)) {
|
||||
result.push(match);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByPlayer(playerId: string, limit?: number): Promise<Match[]> {
|
||||
const result: Match[] = [];
|
||||
for (const match of this.matches.values()) {
|
||||
const hasPlayer = match.teams.some((t) =>
|
||||
t.players.some((p) => p.id === playerId)
|
||||
);
|
||||
if (hasPlayer) {
|
||||
result.push(match);
|
||||
if (limit && result.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async findByLobbyId(lobbyId: string): Promise<Match | null> {
|
||||
for (const match of this.matches.values()) {
|
||||
if (match.lobbyId === lobbyId) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async save(match: Match): Promise<void> {
|
||||
this.matches.set(match.id, match);
|
||||
}
|
||||
|
||||
async updateState(matchId: string, state: MatchState): Promise<void> {
|
||||
const match = this.matches.get(matchId);
|
||||
if (match) {
|
||||
match.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(): Promise<Match[]> {
|
||||
return Array.from(this.matches.values());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.matches.clear();
|
||||
}
|
||||
}
|
||||
47
backend/src/infrastructure/mocks/InMemoryPlayerRepository.ts
Normal file
47
backend/src/infrastructure/mocks/InMemoryPlayerRepository.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { IPlayerRepository } from "../../domain/interfaces/IPlayerRepository";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
|
||||
export class InMemoryPlayerRepository implements IPlayerRepository {
|
||||
private players: Map<string, Player> = new Map();
|
||||
|
||||
async findById(id: string): Promise<Player | null> {
|
||||
return this.players.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByOsuId(osuId: number): Promise<Player | null> {
|
||||
for (const player of this.players.values()) {
|
||||
if (player.osuId === osuId) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async findByDiscordId(discordId: string): Promise<Player | null> {
|
||||
for (const player of this.players.values()) {
|
||||
if (player.discordId === discordId) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async save(player: Player): Promise<void> {
|
||||
this.players.set(player.id, player);
|
||||
}
|
||||
|
||||
async updateRating(playerId: string, newRating: number): Promise<void> {
|
||||
const player = this.players.get(playerId);
|
||||
if (player) {
|
||||
player.rating = newRating;
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(): Promise<Player[]> {
|
||||
return Array.from(this.players.values());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.players.clear();
|
||||
}
|
||||
}
|
||||
61
backend/src/infrastructure/mocks/InMemoryQueueRepository.ts
Normal file
61
backend/src/infrastructure/mocks/InMemoryQueueRepository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { IQueueRepository } from "../../domain/interfaces/IQueueRepository";
|
||||
import type { QueueEntry } from "../../domain/models/QueueEntry";
|
||||
import { getAverageRating } from "../../domain/models/Team";
|
||||
|
||||
export class InMemoryQueueRepository implements IQueueRepository {
|
||||
private entries: Map<string, QueueEntry> = new Map();
|
||||
|
||||
async add(entry: QueueEntry): Promise<void> {
|
||||
this.entries.set(entry.team.id, entry);
|
||||
}
|
||||
|
||||
async remove(teamId: string): Promise<void> {
|
||||
this.entries.delete(teamId);
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<QueueEntry | null> {
|
||||
return this.entries.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async findByType(queueType: string): Promise<QueueEntry[]> {
|
||||
const result: QueueEntry[] = [];
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.queueType === queueType) {
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.joinedAt.getTime() - b.joinedAt.getTime());
|
||||
}
|
||||
|
||||
async findClosestByRating(
|
||||
rating: number,
|
||||
queueType: string,
|
||||
exclude?: string[]
|
||||
): Promise<QueueEntry | null> {
|
||||
let closest: QueueEntry | null = null;
|
||||
let closestDiff = Infinity;
|
||||
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.queueType !== queueType) continue;
|
||||
if (exclude?.includes(entry.team.id)) continue;
|
||||
|
||||
const teamRating = getAverageRating(entry.team);
|
||||
const diff = Math.abs(teamRating - rating);
|
||||
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closest = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
async findAll(): Promise<QueueEntry[]> {
|
||||
return Array.from(this.entries.values());
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
36
backend/src/infrastructure/mocks/InMemoryTeamRepository.ts
Normal file
36
backend/src/infrastructure/mocks/InMemoryTeamRepository.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ITeamRepository } from "../../domain/interfaces/ITeamRepository";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
|
||||
export class InMemoryTeamRepository implements ITeamRepository {
|
||||
private teams: Map<string, Team> = new Map();
|
||||
|
||||
async findById(id: string): Promise<Team | null> {
|
||||
return this.teams.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByPlayer(playerId: string): Promise<Team[]> {
|
||||
const result: Team[] = [];
|
||||
for (const team of this.teams.values()) {
|
||||
if (team.players.some((p) => p.id === playerId)) {
|
||||
result.push(team);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async save(team: Team): Promise<void> {
|
||||
this.teams.set(team.id, team);
|
||||
}
|
||||
|
||||
async delete(teamId: string): Promise<void> {
|
||||
this.teams.delete(teamId);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Team[]> {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.teams.clear();
|
||||
}
|
||||
}
|
||||
136
backend/src/infrastructure/mocks/MockGameService.ts
Normal file
136
backend/src/infrastructure/mocks/MockGameService.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { IGameService } from "../../domain/interfaces/IGameService";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
import type { Score } from "../../domain/models/Score";
|
||||
|
||||
interface MockLobby {
|
||||
id: string;
|
||||
matchId: string;
|
||||
players: Set<number>;
|
||||
messages: string[];
|
||||
currentMap?: number;
|
||||
}
|
||||
|
||||
export class MockGameService implements IGameService {
|
||||
public lobbies: Map<string, MockLobby> = new Map();
|
||||
public messageLog: Array<{ lobbyId: string; message: string }> = [];
|
||||
|
||||
private playerJoinCallbacks: Array<(osuId: number, lobbyId: string) => void> = [];
|
||||
private playerLeaveCallbacks: Array<(osuId: number, lobbyId: string) => void> = [];
|
||||
private matchStartCallbacks: Array<(lobbyId: string) => void> = [];
|
||||
private matchFinishCallbacks: Array<(lobbyId: string, scores: Score[]) => void> = [];
|
||||
private rollCallbacks: Array<(osuId: number, lobbyId: string, roll: number) => void> = [];
|
||||
|
||||
async createLobby(match: Match): Promise<string> {
|
||||
const lobbyId = `mock-lobby-${match.id}`;
|
||||
this.lobbies.set(lobbyId, {
|
||||
id: lobbyId,
|
||||
matchId: match.id,
|
||||
players: new Set(),
|
||||
messages: [],
|
||||
});
|
||||
return lobbyId;
|
||||
}
|
||||
|
||||
async closeLobby(lobbyId: string): Promise<void> {
|
||||
this.lobbies.delete(lobbyId);
|
||||
}
|
||||
|
||||
async invitePlayer(player: Player, lobbyId: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.messages.push(`Invited ${player.osuName}`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(lobbyId: string, message: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.messages.push(message);
|
||||
}
|
||||
this.messageLog.push({ lobbyId, message });
|
||||
}
|
||||
|
||||
async setMap(lobbyId: string, mapId: number): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.currentMap = mapId;
|
||||
lobby.messages.push(`Changed map to ${mapId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async startMatch(lobbyId: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.messages.push("Match started");
|
||||
}
|
||||
}
|
||||
|
||||
onPlayerJoin(callback: (osuId: number, lobbyId: string) => void): void {
|
||||
this.playerJoinCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onPlayerLeave(callback: (osuId: number, lobbyId: string) => void): void {
|
||||
this.playerLeaveCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onMatchStart(callback: (lobbyId: string) => void): void {
|
||||
this.matchStartCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onMatchFinish(callback: (lobbyId: string, scores: Score[]) => void): void {
|
||||
this.matchFinishCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onRoll(callback: (osuId: number, lobbyId: string, roll: number) => void): void {
|
||||
this.rollCallbacks.push(callback);
|
||||
}
|
||||
|
||||
simulatePlayerJoin(lobbyId: string, osuId: number): void {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.players.add(osuId);
|
||||
}
|
||||
for (const cb of this.playerJoinCallbacks) {
|
||||
cb(osuId, lobbyId);
|
||||
}
|
||||
}
|
||||
|
||||
simulatePlayerLeave(lobbyId: string, osuId: number): void {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (lobby) {
|
||||
lobby.players.delete(osuId);
|
||||
}
|
||||
for (const cb of this.playerLeaveCallbacks) {
|
||||
cb(osuId, lobbyId);
|
||||
}
|
||||
}
|
||||
|
||||
simulateRoll(lobbyId: string, osuId: number, roll: number): void {
|
||||
for (const cb of this.rollCallbacks) {
|
||||
cb(osuId, lobbyId, roll);
|
||||
}
|
||||
}
|
||||
|
||||
simulateMatchStart(lobbyId: string): void {
|
||||
for (const cb of this.matchStartCallbacks) {
|
||||
cb(lobbyId);
|
||||
}
|
||||
}
|
||||
|
||||
simulateMatchFinish(lobbyId: string, scores: Score[]): void {
|
||||
for (const cb of this.matchFinishCallbacks) {
|
||||
cb(lobbyId, scores);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.lobbies.clear();
|
||||
this.messageLog = [];
|
||||
this.playerJoinCallbacks = [];
|
||||
this.playerLeaveCallbacks = [];
|
||||
this.matchStartCallbacks = [];
|
||||
this.matchFinishCallbacks = [];
|
||||
this.rollCallbacks = [];
|
||||
}
|
||||
}
|
||||
50
backend/src/infrastructure/mocks/MockNotificationService.ts
Normal file
50
backend/src/infrastructure/mocks/MockNotificationService.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { INotificationService } from "../../domain/interfaces/INotificationService";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import type { Team } from "../../domain/models/Team";
|
||||
import type { MatchScore } from "../../domain/models/Score";
|
||||
|
||||
interface Notification {
|
||||
type: string;
|
||||
targetId?: string;
|
||||
message?: string;
|
||||
match?: Match;
|
||||
winner?: Team;
|
||||
scores?: MatchScore;
|
||||
}
|
||||
|
||||
export class MockNotificationService implements INotificationService {
|
||||
public notifications: Notification[] = [];
|
||||
public announcements: Match[] = [];
|
||||
public playerNotifications: Array<{ playerId: string; message: string }> = [];
|
||||
public teamNotifications: Array<{ teamId: string; message: string }> = [];
|
||||
|
||||
async announceMatch(match: Match): Promise<void> {
|
||||
this.announcements.push(match);
|
||||
this.notifications.push({ type: "match_announced", match });
|
||||
}
|
||||
|
||||
async announceMatchResult(match: Match, winner: Team): Promise<void> {
|
||||
this.notifications.push({ type: "match_result", match, winner });
|
||||
}
|
||||
|
||||
async notifyPlayer(playerId: string, message: string): Promise<void> {
|
||||
this.playerNotifications.push({ playerId, message });
|
||||
this.notifications.push({ type: "player_notification", targetId: playerId, message });
|
||||
}
|
||||
|
||||
async notifyTeam(teamId: string, message: string): Promise<void> {
|
||||
this.teamNotifications.push({ teamId, message });
|
||||
this.notifications.push({ type: "team_notification", targetId: teamId, message });
|
||||
}
|
||||
|
||||
async announceMapResult(match: Match, scores: MatchScore): Promise<void> {
|
||||
this.notifications.push({ type: "map_result", match, scores });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.notifications = [];
|
||||
this.announcements = [];
|
||||
this.playerNotifications = [];
|
||||
this.teamNotifications = [];
|
||||
}
|
||||
}
|
||||
6
backend/src/infrastructure/mocks/index.ts
Normal file
6
backend/src/infrastructure/mocks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./InMemoryPlayerRepository";
|
||||
export * from "./InMemoryTeamRepository";
|
||||
export * from "./InMemoryMatchRepository";
|
||||
export * from "./InMemoryQueueRepository";
|
||||
export * from "./MockGameService";
|
||||
export * from "./MockNotificationService";
|
||||
187
backend/src/infrastructure/osu/OsuService.ts
Normal file
187
backend/src/infrastructure/osu/OsuService.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { IGameService } from "../../domain/interfaces/IGameService";
|
||||
import type { Match } from "../../domain/models/Match";
|
||||
import type { Player } from "../../domain/models/Player";
|
||||
import type { Score } from "../../domain/models/Score";
|
||||
|
||||
type PlayerJoinCallback = (osuId: number, lobbyId: string) => void;
|
||||
type PlayerLeaveCallback = (osuId: number, lobbyId: string) => void;
|
||||
type MatchStartCallback = (lobbyId: string) => void;
|
||||
type MatchFinishCallback = (lobbyId: string, scores: Score[]) => void;
|
||||
type RollCallback = (osuId: number, lobbyId: string, roll: number) => void;
|
||||
|
||||
export interface OsuApiConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface OsuIrcConfig {
|
||||
username: string;
|
||||
password: string;
|
||||
server?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export class OsuService implements IGameService {
|
||||
private playerJoinCallbacks: PlayerJoinCallback[] = [];
|
||||
private playerLeaveCallbacks: PlayerLeaveCallback[] = [];
|
||||
private matchStartCallbacks: MatchStartCallback[] = [];
|
||||
private matchFinishCallbacks: MatchFinishCallback[] = [];
|
||||
private rollCallbacks: RollCallback[] = [];
|
||||
|
||||
private lobbies: Map<string, { matchId: string; channel: string }> = new Map();
|
||||
|
||||
constructor(
|
||||
private apiConfig: OsuApiConfig,
|
||||
private ircConfig: OsuIrcConfig
|
||||
) {
|
||||
this.connectIrc();
|
||||
}
|
||||
|
||||
private async connectIrc(): Promise<void> {
|
||||
// TODO: Implement IRC connection using Bun's WebSocket
|
||||
// Connect to irc.ppy.sh
|
||||
// Authenticate with BanchoBot
|
||||
console.log("[OsuService] IRC connection not yet implemented");
|
||||
}
|
||||
|
||||
async createLobby(match: Match): Promise<string> {
|
||||
const lobbyName = `VASH: ${match.teams.map((t) => t.name).join(" vs ")}`;
|
||||
// TODO: Send IRC command to create lobby
|
||||
// !mp make <name>
|
||||
// Parse response to get lobby ID
|
||||
|
||||
const lobbyId = `osu_${match.id.slice(0, 8)}`;
|
||||
this.lobbies.set(lobbyId, {
|
||||
matchId: match.id,
|
||||
channel: `#mp_${lobbyId}`,
|
||||
});
|
||||
|
||||
console.log(`[OsuService] Created lobby: ${lobbyId}`);
|
||||
return lobbyId;
|
||||
}
|
||||
|
||||
async closeLobby(lobbyId: string): Promise<void> {
|
||||
// TODO: Send IRC command to close lobby
|
||||
// !mp close
|
||||
this.lobbies.delete(lobbyId);
|
||||
console.log(`[OsuService] Closed lobby: ${lobbyId}`);
|
||||
}
|
||||
|
||||
async invitePlayer(player: Player, lobbyId: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (!lobby) {
|
||||
throw new Error(`Lobby ${lobbyId} not found`);
|
||||
}
|
||||
// TODO: Send IRC command
|
||||
// !mp invite <username>
|
||||
console.log(`[OsuService] Invited ${player.osuName} to ${lobbyId}`);
|
||||
}
|
||||
|
||||
async sendMessage(lobbyId: string, message: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (!lobby) {
|
||||
throw new Error(`Lobby ${lobbyId} not found`);
|
||||
}
|
||||
// TODO: Send IRC message to lobby channel
|
||||
console.log(`[OsuService] [${lobbyId}] ${message}`);
|
||||
}
|
||||
|
||||
async setMap(lobbyId: string, mapId: number): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (!lobby) {
|
||||
throw new Error(`Lobby ${lobbyId} not found`);
|
||||
}
|
||||
// TODO: Send IRC command
|
||||
// !mp map <id> <mode>
|
||||
console.log(`[OsuService] Set map ${mapId} in ${lobbyId}`);
|
||||
}
|
||||
|
||||
async startMatch(lobbyId: string): Promise<void> {
|
||||
const lobby = this.lobbies.get(lobbyId);
|
||||
if (!lobby) {
|
||||
throw new Error(`Lobby ${lobbyId} not found`);
|
||||
}
|
||||
// TODO: Send IRC command
|
||||
// !mp start
|
||||
console.log(`[OsuService] Started match in ${lobbyId}`);
|
||||
}
|
||||
|
||||
onPlayerJoin(callback: PlayerJoinCallback): void {
|
||||
this.playerJoinCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onPlayerLeave(callback: PlayerLeaveCallback): void {
|
||||
this.playerLeaveCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onMatchStart(callback: MatchStartCallback): void {
|
||||
this.matchStartCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onMatchFinish(callback: MatchFinishCallback): void {
|
||||
this.matchFinishCallbacks.push(callback);
|
||||
}
|
||||
|
||||
onRoll(callback: RollCallback): void {
|
||||
this.rollCallbacks.push(callback);
|
||||
}
|
||||
|
||||
// IRC message parsing - called when messages are received
|
||||
private handleIrcMessage(channel: string, user: string, message: string): void {
|
||||
// Find lobby by channel
|
||||
let lobbyId: string | null = null;
|
||||
for (const [id, lobby] of this.lobbies) {
|
||||
if (lobby.channel === channel) {
|
||||
lobbyId = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!lobbyId) return;
|
||||
|
||||
// Parse BanchoBot messages
|
||||
if (user === "BanchoBot") {
|
||||
// Player joined: "<username> joined in slot X"
|
||||
const joinMatch = message.match(/^(\S+) joined in slot \d+/);
|
||||
if (joinMatch) {
|
||||
// TODO: Look up osuId from username
|
||||
// For now, we'd need an API call or cache
|
||||
console.log(`[OsuService] Player joined: ${joinMatch[1]}`);
|
||||
}
|
||||
|
||||
// Match finished - parse scores
|
||||
// Format varies, typically shows all player scores
|
||||
if (message.includes("The match has finished")) {
|
||||
// TODO: Parse scores from recent messages or API
|
||||
console.log(`[OsuService] Match finished in ${lobbyId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse roll command: "<username> rolls X point(s)"
|
||||
const rollMatch = message.match(/^(\S+) rolls (\d+) point/);
|
||||
if (rollMatch) {
|
||||
const username = rollMatch[1];
|
||||
const roll = parseInt(rollMatch[2]);
|
||||
// TODO: Look up osuId from username
|
||||
console.log(`[OsuService] ${username} rolled ${roll}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for testing
|
||||
_triggerPlayerJoin(osuId: number, lobbyId: string): void {
|
||||
for (const cb of this.playerJoinCallbacks) {
|
||||
cb(osuId, lobbyId);
|
||||
}
|
||||
}
|
||||
|
||||
_triggerRoll(osuId: number, lobbyId: string, roll: number): void {
|
||||
for (const cb of this.rollCallbacks) {
|
||||
cb(osuId, lobbyId, roll);
|
||||
}
|
||||
}
|
||||
|
||||
_triggerMatchFinish(lobbyId: string, scores: Score[]): void {
|
||||
for (const cb of this.matchFinishCallbacks) {
|
||||
cb(lobbyId, scores);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
backend/src/infrastructure/osu/index.ts
Normal file
1
backend/src/infrastructure/osu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./OsuService";
|
||||
220
backend/src/infrastructure/websocket/WebSocketService.ts
Normal file
220
backend/src/infrastructure/websocket/WebSocketService.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { EventBus } from "../../application/events/EventBus";
|
||||
import type { DomainEvent } from "../../domain/events/DomainEvent";
|
||||
|
||||
interface WebSocketData {
|
||||
id: string;
|
||||
subscribedMatches: Set<string>;
|
||||
}
|
||||
|
||||
type WebSocketClient = ServerWebSocket<WebSocketData>;
|
||||
|
||||
export interface ClientMessage {
|
||||
type: string;
|
||||
matchId?: string;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export class WebSocketService {
|
||||
private clients: Map<string, WebSocketClient> = new Map();
|
||||
private matchSubscriptions: Map<string, Set<string>> = new Map();
|
||||
|
||||
constructor(private eventBus: EventBus) {
|
||||
this.setupEventSubscriptions();
|
||||
}
|
||||
|
||||
private setupEventSubscriptions(): void {
|
||||
const eventsToForward = [
|
||||
"match.created",
|
||||
"match.state_changed",
|
||||
"match.player_joined",
|
||||
"match.roll_completed",
|
||||
"match.map_picked",
|
||||
"match.map_banned",
|
||||
"match.map_started",
|
||||
"match.map_completed",
|
||||
"match.completed",
|
||||
"match.cancelled",
|
||||
"queue.team_joined",
|
||||
"queue.team_left",
|
||||
"queue.match_found",
|
||||
];
|
||||
|
||||
for (const eventType of eventsToForward) {
|
||||
this.eventBus.subscribe(eventType as any, (event: DomainEvent) => {
|
||||
this.broadcastEvent(event);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleOpen(ws: WebSocketClient): void {
|
||||
const clientId = crypto.randomUUID();
|
||||
ws.data = {
|
||||
id: clientId,
|
||||
subscribedMatches: new Set(),
|
||||
};
|
||||
this.clients.set(clientId, ws);
|
||||
console.log(`[WebSocket] Client connected: ${clientId}`);
|
||||
|
||||
ws.send(JSON.stringify({ type: "connected", clientId }));
|
||||
}
|
||||
|
||||
handleMessage(ws: WebSocketClient, message: string | Buffer): void {
|
||||
try {
|
||||
const msg: ClientMessage =
|
||||
typeof message === "string" ? JSON.parse(message) : JSON.parse(message.toString());
|
||||
|
||||
switch (msg.type) {
|
||||
case "subscribe":
|
||||
if (msg.matchId) {
|
||||
this.subscribeToMatch(ws, msg.matchId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "unsubscribe":
|
||||
if (msg.matchId) {
|
||||
this.unsubscribeFromMatch(ws, msg.matchId);
|
||||
}
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`[WebSocket] Unknown message type: ${msg.type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] Failed to parse message:", error);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose(ws: WebSocketClient): void {
|
||||
const clientId = ws.data?.id;
|
||||
if (clientId) {
|
||||
// Clean up subscriptions
|
||||
for (const matchId of ws.data.subscribedMatches) {
|
||||
const subs = this.matchSubscriptions.get(matchId);
|
||||
if (subs) {
|
||||
subs.delete(clientId);
|
||||
if (subs.size === 0) {
|
||||
this.matchSubscriptions.delete(matchId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.clients.delete(clientId);
|
||||
console.log(`[WebSocket] Client disconnected: ${clientId}`);
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToMatch(ws: WebSocketClient, matchId: string): void {
|
||||
const clientId = ws.data?.id;
|
||||
if (!clientId) return;
|
||||
|
||||
ws.data.subscribedMatches.add(matchId);
|
||||
|
||||
if (!this.matchSubscriptions.has(matchId)) {
|
||||
this.matchSubscriptions.set(matchId, new Set());
|
||||
}
|
||||
this.matchSubscriptions.get(matchId)!.add(clientId);
|
||||
|
||||
ws.send(JSON.stringify({ type: "subscribed", matchId }));
|
||||
console.log(`[WebSocket] Client ${clientId} subscribed to match ${matchId}`);
|
||||
}
|
||||
|
||||
unsubscribeFromMatch(ws: WebSocketClient, matchId: string): void {
|
||||
const clientId = ws.data?.id;
|
||||
if (!clientId) return;
|
||||
|
||||
ws.data.subscribedMatches.delete(matchId);
|
||||
|
||||
const subs = this.matchSubscriptions.get(matchId);
|
||||
if (subs) {
|
||||
subs.delete(clientId);
|
||||
if (subs.size === 0) {
|
||||
this.matchSubscriptions.delete(matchId);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({ type: "unsubscribed", matchId }));
|
||||
}
|
||||
|
||||
private broadcastEvent(event: DomainEvent): void {
|
||||
const payload = event.payload as any;
|
||||
const matchId = payload?.match?.id;
|
||||
|
||||
const message = JSON.stringify({
|
||||
type: "event",
|
||||
eventType: event.type,
|
||||
timestamp: event.timestamp.toISOString(),
|
||||
payload: this.serializePayload(payload),
|
||||
});
|
||||
|
||||
if (matchId) {
|
||||
// Send to match subscribers
|
||||
const subscribers = this.matchSubscriptions.get(matchId);
|
||||
if (subscribers) {
|
||||
for (const clientId of subscribers) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Broadcast to all clients
|
||||
for (const client of this.clients.values()) {
|
||||
client.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private serializePayload(payload: any): any {
|
||||
if (!payload) return payload;
|
||||
|
||||
// Handle Maps
|
||||
if (payload instanceof Map) {
|
||||
return Object.fromEntries(payload);
|
||||
}
|
||||
|
||||
// Handle Sets
|
||||
if (payload instanceof Set) {
|
||||
return Array.from(payload);
|
||||
}
|
||||
|
||||
// Handle objects with Maps/Sets
|
||||
if (typeof payload === "object") {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value instanceof Map) {
|
||||
result[key] = Object.fromEntries(value);
|
||||
} else if (value instanceof Set) {
|
||||
result[key] = Array.from(value);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
result[key] = this.serializePayload(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
broadcast(message: object): void {
|
||||
const msg = JSON.stringify(message);
|
||||
for (const client of this.clients.values()) {
|
||||
client.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
getConnectedCount(): number {
|
||||
return this.clients.size;
|
||||
}
|
||||
|
||||
getMatchSubscriberCount(matchId: string): number {
|
||||
return this.matchSubscriptions.get(matchId)?.size ?? 0;
|
||||
}
|
||||
}
|
||||
1
backend/src/infrastructure/websocket/index.ts
Normal file
1
backend/src/infrastructure/websocket/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./WebSocketService";
|
||||
0
backend/src/presentation/api/routes.ts
Normal file
0
backend/src/presentation/api/routes.ts
Normal file
217
backend/src/testing/TestHarness.ts
Normal file
217
backend/src/testing/TestHarness.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { EventBus } from "../application/events/EventBus";
|
||||
import { MatchService } from "../application/services/MatchService";
|
||||
import { QueueService } from "../application/services/QueueService";
|
||||
import { ProtocolRegistry } from "../domain/protocols/ProtocolRegistry";
|
||||
import { StandardOsuProtocol } from "../domain/protocols/StandardOsuProtocol";
|
||||
import { HeadToHeadProtocol } from "../domain/protocols/HeadToHeadProtocol";
|
||||
import { InMemoryPlayerRepository } from "../infrastructure/mocks/InMemoryPlayerRepository";
|
||||
import { InMemoryTeamRepository } from "../infrastructure/mocks/InMemoryTeamRepository";
|
||||
import { InMemoryMatchRepository } from "../infrastructure/mocks/InMemoryMatchRepository";
|
||||
import { InMemoryQueueRepository } from "../infrastructure/mocks/InMemoryQueueRepository";
|
||||
import { MockGameService } from "../infrastructure/mocks/MockGameService";
|
||||
import { MockNotificationService } from "../infrastructure/mocks/MockNotificationService";
|
||||
import { type Player, createPlayer } from "../domain/models/Player";
|
||||
import { type Team, createTeam } from "../domain/models/Team";
|
||||
import type { Match } from "../domain/models/Match";
|
||||
import type { Score } from "../domain/models/Score";
|
||||
import { MatchState } from "../domain/models/MatchState";
|
||||
import type { DomainEvent } from "../domain/events/DomainEvent";
|
||||
|
||||
export class TestHarness {
|
||||
public eventBus: EventBus;
|
||||
public mockGameService: MockGameService;
|
||||
public mockNotificationService: MockNotificationService;
|
||||
public matchService: MatchService;
|
||||
public queueService: QueueService;
|
||||
public protocolRegistry: ProtocolRegistry;
|
||||
|
||||
public playerRepo: InMemoryPlayerRepository;
|
||||
public teamRepo: InMemoryTeamRepository;
|
||||
public matchRepo: InMemoryMatchRepository;
|
||||
public queueRepo: InMemoryQueueRepository;
|
||||
|
||||
public events: DomainEvent[] = [];
|
||||
|
||||
private playerCounter = 1;
|
||||
private teamCounter = 1;
|
||||
|
||||
constructor() {
|
||||
this.eventBus = new EventBus();
|
||||
this.mockGameService = new MockGameService();
|
||||
this.mockNotificationService = new MockNotificationService();
|
||||
|
||||
this.playerRepo = new InMemoryPlayerRepository();
|
||||
this.teamRepo = new InMemoryTeamRepository();
|
||||
this.matchRepo = new InMemoryMatchRepository();
|
||||
this.queueRepo = new InMemoryQueueRepository();
|
||||
|
||||
this.protocolRegistry = new ProtocolRegistry();
|
||||
this.protocolRegistry.register(new StandardOsuProtocol());
|
||||
this.protocolRegistry.register(new HeadToHeadProtocol());
|
||||
|
||||
this.matchService = new MatchService(
|
||||
this.matchRepo,
|
||||
this.playerRepo,
|
||||
this.protocolRegistry,
|
||||
this.mockGameService,
|
||||
this.mockNotificationService,
|
||||
this.eventBus
|
||||
);
|
||||
|
||||
this.queueService = new QueueService(
|
||||
this.queueRepo,
|
||||
this.matchService,
|
||||
this.eventBus,
|
||||
{ minQueueTime: 0, protocol: "head-to-head" }
|
||||
);
|
||||
|
||||
this.eventBus.subscribeAll((event) => {
|
||||
this.events.push(event);
|
||||
});
|
||||
}
|
||||
|
||||
createPlayer(overrides?: Partial<Player>): Player {
|
||||
const id = this.playerCounter++;
|
||||
const player = createPlayer({
|
||||
discordId: `discord-${id}`,
|
||||
osuId: 100000 + id,
|
||||
osuName: `Player${id}`,
|
||||
displayName: `Player ${id}`,
|
||||
rating: 1000,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
this.playerRepo.save(player);
|
||||
return player;
|
||||
}
|
||||
|
||||
createPlayers(count: number, baseRating: number = 1000): Player[] {
|
||||
const players: Player[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
players.push(this.createPlayer({ rating: baseRating }));
|
||||
}
|
||||
return players;
|
||||
}
|
||||
|
||||
createTeam(players: Player[], overrides?: Partial<Team>): Team {
|
||||
const id = this.teamCounter++;
|
||||
const team = createTeam({
|
||||
name: overrides?.name ?? `Team ${id}`,
|
||||
players,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
this.teamRepo.save(team);
|
||||
return team;
|
||||
}
|
||||
|
||||
async simulateAllPlayersJoin(match: Match): Promise<void> {
|
||||
for (const team of match.teams) {
|
||||
for (const player of team.players) {
|
||||
if (match.lobbyId) {
|
||||
this.mockGameService.simulatePlayerJoin(match.lobbyId, player.osuId);
|
||||
}
|
||||
await this.matchService.handlePlayerJoined(match.id, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async simulateRolls(
|
||||
match: Match,
|
||||
rolls: Map<string, number>
|
||||
): Promise<void> {
|
||||
for (const team of match.teams) {
|
||||
const roll = rolls.get(team.id) ?? Math.floor(Math.random() * 100) + 1;
|
||||
const captain = team.captain;
|
||||
if (match.lobbyId) {
|
||||
this.mockGameService.simulateRoll(match.lobbyId, captain.osuId, roll);
|
||||
}
|
||||
await this.matchService.handleRoll(match.id, captain, roll);
|
||||
}
|
||||
}
|
||||
|
||||
async simulateMapComplete(match: Match, winningTeamId: string): Promise<void> {
|
||||
const scores: Score[] = [];
|
||||
const winningTeam = match.teams.find((t) => t.id === winningTeamId)!;
|
||||
const losingTeam = match.teams.find((t) => t.id !== winningTeamId)!;
|
||||
|
||||
for (const player of winningTeam.players) {
|
||||
scores.push({
|
||||
playerId: player.id,
|
||||
score: 500000 + Math.floor(Math.random() * 100000),
|
||||
accuracy: 95 + Math.random() * 5,
|
||||
maxCombo: 800 + Math.floor(Math.random() * 200),
|
||||
misses: Math.floor(Math.random() * 5),
|
||||
});
|
||||
}
|
||||
|
||||
for (const player of losingTeam.players) {
|
||||
scores.push({
|
||||
playerId: player.id,
|
||||
score: 400000 + Math.floor(Math.random() * 80000),
|
||||
accuracy: 90 + Math.random() * 8,
|
||||
maxCombo: 600 + Math.floor(Math.random() * 200),
|
||||
misses: Math.floor(Math.random() * 10),
|
||||
});
|
||||
}
|
||||
|
||||
if (match.lobbyId) {
|
||||
this.mockGameService.simulateMatchFinish(match.lobbyId, scores);
|
||||
}
|
||||
await this.matchService.handleMapComplete(match.id, scores);
|
||||
}
|
||||
|
||||
async runFullMatch(
|
||||
team1: Team,
|
||||
team2: Team,
|
||||
protocolName: string = "head-to-head"
|
||||
): Promise<Match> {
|
||||
await this.queueService.joinQueue(team1, "ranked");
|
||||
await this.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const match = await this.queueService.findMatch("ranked");
|
||||
if (!match) {
|
||||
throw new Error("Failed to create match");
|
||||
}
|
||||
|
||||
await this.simulateAllPlayersJoin(match);
|
||||
|
||||
const rolls = new Map<string, number>();
|
||||
rolls.set(team1.id, 75);
|
||||
rolls.set(team2.id, 50);
|
||||
await this.simulateRolls(match, rolls);
|
||||
|
||||
const protocol = this.protocolRegistry.get(protocolName);
|
||||
const winsNeeded = protocol.getWinsNeeded();
|
||||
|
||||
for (let i = 0; i < winsNeeded; i++) {
|
||||
const updatedMatch = await this.matchRepo.findById(match.id);
|
||||
if (!updatedMatch || updatedMatch.state === MatchState.COMPLETED) break;
|
||||
|
||||
await this.simulateMapComplete(match, team1.id);
|
||||
}
|
||||
|
||||
return (await this.matchRepo.findById(match.id))!;
|
||||
}
|
||||
|
||||
getEventsByType(type: string): DomainEvent[] {
|
||||
return this.events.filter((e) => e.type === type);
|
||||
}
|
||||
|
||||
clearEvents(): void {
|
||||
this.events = [];
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.playerRepo.clear();
|
||||
this.teamRepo.clear();
|
||||
this.matchRepo.clear();
|
||||
this.queueRepo.clear();
|
||||
this.mockGameService.clear();
|
||||
this.mockNotificationService.clear();
|
||||
this.eventBus.clear();
|
||||
this.events = [];
|
||||
this.playerCounter = 1;
|
||||
this.teamCounter = 1;
|
||||
}
|
||||
}
|
||||
1
backend/src/testing/index.ts
Normal file
1
backend/src/testing/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./TestHarness";
|
||||
97
backend/tests/application/QueueService.test.ts
Normal file
97
backend/tests/application/QueueService.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { TestHarness } from "../../src/testing/TestHarness";
|
||||
|
||||
let harness: TestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
harness = new TestHarness();
|
||||
});
|
||||
|
||||
test("team can join queue", async () => {
|
||||
const players = harness.createPlayers(1);
|
||||
const team = harness.createTeam(players, { name: "Test Team" });
|
||||
|
||||
const entry = await harness.queueService.joinQueue(team, "ranked");
|
||||
|
||||
expect(entry.team.id).toBe(team.id);
|
||||
expect(entry.queueType).toBe("ranked");
|
||||
});
|
||||
|
||||
test("team cannot join queue twice", async () => {
|
||||
const players = harness.createPlayers(1);
|
||||
const team = harness.createTeam(players, { name: "Test Team" });
|
||||
|
||||
await harness.queueService.joinQueue(team, "ranked");
|
||||
|
||||
expect(async () => {
|
||||
await harness.queueService.joinQueue(team, "ranked");
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("team can leave queue", async () => {
|
||||
const players = harness.createPlayers(1);
|
||||
const team = harness.createTeam(players, { name: "Test Team" });
|
||||
|
||||
await harness.queueService.joinQueue(team, "ranked");
|
||||
await harness.queueService.leaveQueue(team.id);
|
||||
|
||||
const status = await harness.queueService.getQueueStatus("ranked");
|
||||
expect(status.count).toBe(0);
|
||||
});
|
||||
|
||||
test("findMatch returns null when less than 2 teams in queue", async () => {
|
||||
const players = harness.createPlayers(1);
|
||||
const team = harness.createTeam(players, { name: "Test Team" });
|
||||
|
||||
await harness.queueService.joinQueue(team, "ranked");
|
||||
|
||||
const match = await harness.queueService.findMatch("ranked");
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
test("findMatch creates match when 2 teams in queue", async () => {
|
||||
const players1 = harness.createPlayers(1, 1000);
|
||||
const players2 = harness.createPlayers(1, 1010);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const match = await harness.queueService.findMatch("ranked");
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.teams.length).toBe(2);
|
||||
});
|
||||
|
||||
test("getQueueStatus returns correct count", async () => {
|
||||
const players1 = harness.createPlayers(1);
|
||||
const players2 = harness.createPlayers(1);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const status = await harness.queueService.getQueueStatus("ranked");
|
||||
|
||||
expect(status.count).toBe(2);
|
||||
expect(status.entries.length).toBe(2);
|
||||
});
|
||||
|
||||
test("queue events are published", async () => {
|
||||
const players = harness.createPlayers(1);
|
||||
const team = harness.createTeam(players, { name: "Test Team" });
|
||||
|
||||
await harness.queueService.joinQueue(team, "ranked");
|
||||
|
||||
const joinEvents = harness.getEventsByType("queue.team_joined");
|
||||
expect(joinEvents.length).toBe(1);
|
||||
|
||||
await harness.queueService.leaveQueue(team.id);
|
||||
|
||||
const leaveEvents = harness.getEventsByType("queue.team_left");
|
||||
expect(leaveEvents.length).toBe(1);
|
||||
});
|
||||
155
backend/tests/domain/protocols/StandardOsuProtocol.test.ts
Normal file
155
backend/tests/domain/protocols/StandardOsuProtocol.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { StandardOsuProtocol } from "../../../src/domain/protocols/StandardOsuProtocol";
|
||||
import { createMatch } from "../../../src/domain/models/Match";
|
||||
import { createTeam } from "../../../src/domain/models/Team";
|
||||
import { createPlayer } from "../../../src/domain/models/Player";
|
||||
import type { Score } from "../../../src/domain/models/Score";
|
||||
|
||||
let protocol: StandardOsuProtocol;
|
||||
|
||||
beforeEach(() => {
|
||||
protocol = new StandardOsuProtocol();
|
||||
});
|
||||
|
||||
test("has correct configuration", () => {
|
||||
expect(protocol.name).toBe("standard-osu");
|
||||
expect(protocol.teamSize).toBe(4);
|
||||
expect(protocol.bestOf).toBe(9);
|
||||
expect(protocol.hasPickBan).toBe(true);
|
||||
expect(protocol.hasFreemod).toBe(true);
|
||||
});
|
||||
|
||||
test("getWinsNeeded returns correct value for best of 9", () => {
|
||||
expect(protocol.getWinsNeeded()).toBe(5);
|
||||
});
|
||||
|
||||
test("calculateMapWinner returns winning team based on scores", () => {
|
||||
const players1 = [
|
||||
createPlayer({ id: "p1", discordId: "d1", osuId: 1, osuName: "Player1" }),
|
||||
createPlayer({ id: "p2", discordId: "d2", osuId: 2, osuName: "Player2" }),
|
||||
];
|
||||
const players2 = [
|
||||
createPlayer({ id: "p3", discordId: "d3", osuId: 3, osuName: "Player3" }),
|
||||
createPlayer({ id: "p4", discordId: "d4", osuId: 4, osuName: "Player4" }),
|
||||
];
|
||||
|
||||
const team1 = createTeam({ id: "team1", name: "Team 1", players: players1 });
|
||||
const team2 = createTeam({ id: "team2", name: "Team 2", players: players2 });
|
||||
|
||||
const match = createMatch({
|
||||
teams: [team1, team2],
|
||||
protocol: "standard-osu",
|
||||
});
|
||||
|
||||
const scores: Score[] = [
|
||||
{ playerId: "p1", score: 500000, accuracy: 95, maxCombo: 800, misses: 2 },
|
||||
{ playerId: "p2", score: 450000, accuracy: 94, maxCombo: 750, misses: 3 },
|
||||
{ playerId: "p3", score: 400000, accuracy: 92, maxCombo: 700, misses: 5 },
|
||||
{ playerId: "p4", score: 380000, accuracy: 91, maxCombo: 680, misses: 6 },
|
||||
];
|
||||
|
||||
const winner = protocol.calculateMapWinner(match, scores);
|
||||
expect(winner).toBe("team1");
|
||||
});
|
||||
|
||||
test("calculateMatchWinner returns null when no team has enough wins", () => {
|
||||
const players1 = [createPlayer({ id: "p1", discordId: "d1", osuId: 1, osuName: "P1" })];
|
||||
const players2 = [createPlayer({ id: "p2", discordId: "d2", osuId: 2, osuName: "P2" })];
|
||||
|
||||
const team1 = createTeam({ id: "team1", name: "Team 1", players: players1 });
|
||||
const team2 = createTeam({ id: "team2", name: "Team 2", players: players2 });
|
||||
|
||||
const match = createMatch({
|
||||
teams: [team1, team2],
|
||||
protocol: "standard-osu",
|
||||
});
|
||||
|
||||
expect(protocol.calculateMatchWinner(match)).toBeNull();
|
||||
});
|
||||
|
||||
test("isValidPick returns false when not the picking team", () => {
|
||||
const players1 = [createPlayer({ id: "p1", discordId: "d1", osuId: 1, osuName: "P1" })];
|
||||
const players2 = [createPlayer({ id: "p2", discordId: "d2", osuId: 2, osuName: "P2" })];
|
||||
|
||||
const team1 = createTeam({ id: "team1", name: "Team 1", players: players1 });
|
||||
const team2 = createTeam({ id: "team2", name: "Team 2", players: players2 });
|
||||
|
||||
const match = createMatch({
|
||||
teams: [team1, team2],
|
||||
protocol: "standard-osu",
|
||||
});
|
||||
|
||||
match.currentPicker = "team1";
|
||||
|
||||
const map = {
|
||||
id: 123,
|
||||
beatmapSetId: 456,
|
||||
title: "Test Map",
|
||||
artist: "Artist",
|
||||
difficulty: "Hard",
|
||||
starRating: 5.5,
|
||||
bpm: 180,
|
||||
length: 120,
|
||||
};
|
||||
|
||||
expect(protocol.isValidPick(match, team1, map)).toBe(true);
|
||||
expect(protocol.isValidPick(match, team2, map)).toBe(false);
|
||||
});
|
||||
|
||||
test("isValidPick returns false for banned maps", () => {
|
||||
const players1 = [createPlayer({ id: "p1", discordId: "d1", osuId: 1, osuName: "P1" })];
|
||||
const players2 = [createPlayer({ id: "p2", discordId: "d2", osuId: 2, osuName: "P2" })];
|
||||
|
||||
const team1 = createTeam({ id: "team1", name: "Team 1", players: players1 });
|
||||
const team2 = createTeam({ id: "team2", name: "Team 2", players: players2 });
|
||||
|
||||
const match = createMatch({
|
||||
teams: [team1, team2],
|
||||
protocol: "standard-osu",
|
||||
});
|
||||
|
||||
match.currentPicker = "team1";
|
||||
match.bans.set("team2", [123]);
|
||||
|
||||
const map = {
|
||||
id: 123,
|
||||
beatmapSetId: 456,
|
||||
title: "Test Map",
|
||||
artist: "Artist",
|
||||
difficulty: "Hard",
|
||||
starRating: 5.5,
|
||||
bpm: 180,
|
||||
length: 120,
|
||||
};
|
||||
|
||||
expect(protocol.isValidPick(match, team1, map)).toBe(false);
|
||||
});
|
||||
|
||||
test("isValidPick returns false for already picked maps", () => {
|
||||
const players1 = [createPlayer({ id: "p1", discordId: "d1", osuId: 1, osuName: "P1" })];
|
||||
const players2 = [createPlayer({ id: "p2", discordId: "d2", osuId: 2, osuName: "P2" })];
|
||||
|
||||
const team1 = createTeam({ id: "team1", name: "Team 1", players: players1 });
|
||||
const team2 = createTeam({ id: "team2", name: "Team 2", players: players2 });
|
||||
|
||||
const match = createMatch({
|
||||
teams: [team1, team2],
|
||||
protocol: "standard-osu",
|
||||
});
|
||||
|
||||
match.currentPicker = "team1";
|
||||
match.picks.push(123);
|
||||
|
||||
const map = {
|
||||
id: 123,
|
||||
beatmapSetId: 456,
|
||||
title: "Test Map",
|
||||
artist: "Artist",
|
||||
difficulty: "Hard",
|
||||
starRating: 5.5,
|
||||
bpm: 180,
|
||||
length: 120,
|
||||
};
|
||||
|
||||
expect(protocol.isValidPick(match, team1, map)).toBe(false);
|
||||
});
|
||||
121
backend/tests/integration/full-match-flow.test.ts
Normal file
121
backend/tests/integration/full-match-flow.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { test, expect, beforeEach } from "bun:test";
|
||||
import { TestHarness } from "../../src/testing/TestHarness";
|
||||
import { MatchState } from "../../src/domain/models/MatchState";
|
||||
|
||||
let harness: TestHarness;
|
||||
|
||||
beforeEach(() => {
|
||||
harness = new TestHarness();
|
||||
});
|
||||
|
||||
test("complete match flow from queue to completion", async () => {
|
||||
const players1 = harness.createPlayers(1, 1000);
|
||||
const players2 = harness.createPlayers(1, 1020);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team Alpha" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team Beta" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const match = await harness.queueService.findMatch("ranked");
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match!.teams.length).toBe(2);
|
||||
expect(match!.lobbyId).toBeDefined();
|
||||
|
||||
await harness.simulateAllPlayersJoin(match!);
|
||||
|
||||
let updatedMatch = await harness.matchRepo.findById(match!.id);
|
||||
expect(updatedMatch!.state).toBe(MatchState.ROLLING);
|
||||
|
||||
const rolls = new Map<string, number>();
|
||||
rolls.set(team1.id, 75);
|
||||
rolls.set(team2.id, 50);
|
||||
await harness.simulateRolls(match!, rolls);
|
||||
|
||||
updatedMatch = await harness.matchRepo.findById(match!.id);
|
||||
expect(updatedMatch!.state).toBe(MatchState.PICKING);
|
||||
|
||||
const events = harness.getEventsByType("match.created");
|
||||
expect(events.length).toBe(1);
|
||||
|
||||
const stateChanges = harness.getEventsByType("match.state_changed");
|
||||
expect(stateChanges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("match creates lobby on start", async () => {
|
||||
const players1 = harness.createPlayers(1);
|
||||
const players2 = harness.createPlayers(1);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const match = await harness.queueService.findMatch("ranked");
|
||||
|
||||
expect(match!.lobbyId).toBeDefined();
|
||||
expect(harness.mockGameService.lobbies.has(match!.lobbyId!)).toBe(true);
|
||||
});
|
||||
|
||||
test("notifications are sent when match is created", async () => {
|
||||
const players1 = harness.createPlayers(1);
|
||||
const players2 = harness.createPlayers(1);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
await harness.queueService.findMatch("ranked");
|
||||
|
||||
expect(harness.mockNotificationService.announcements.length).toBe(1);
|
||||
});
|
||||
|
||||
test("match state transitions correctly through rolling phase", async () => {
|
||||
const players1 = harness.createPlayers(1);
|
||||
const players2 = harness.createPlayers(1);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
const match = await harness.queueService.findMatch("ranked");
|
||||
expect(match!.state).toBe(MatchState.WAITING_PLAYERS);
|
||||
|
||||
await harness.simulateAllPlayersJoin(match!);
|
||||
|
||||
let updatedMatch = await harness.matchRepo.findById(match!.id);
|
||||
expect(updatedMatch!.state).toBe(MatchState.ROLLING);
|
||||
|
||||
const rolls = new Map<string, number>();
|
||||
rolls.set(team1.id, 75);
|
||||
rolls.set(team2.id, 50);
|
||||
await harness.simulateRolls(match!, rolls);
|
||||
|
||||
updatedMatch = await harness.matchRepo.findById(match!.id);
|
||||
expect(updatedMatch!.state).toBe(MatchState.PICKING);
|
||||
// In head-to-head protocol, roll winner picks first
|
||||
expect(updatedMatch!.currentPicker).toBe(team1.id);
|
||||
});
|
||||
|
||||
test("queue status is empty after match found", async () => {
|
||||
const players1 = harness.createPlayers(1);
|
||||
const players2 = harness.createPlayers(1);
|
||||
|
||||
const team1 = harness.createTeam(players1, { name: "Team 1" });
|
||||
const team2 = harness.createTeam(players2, { name: "Team 2" });
|
||||
|
||||
await harness.queueService.joinQueue(team1, "ranked");
|
||||
await harness.queueService.joinQueue(team2, "ranked");
|
||||
|
||||
await harness.queueService.findMatch("ranked");
|
||||
|
||||
const status = await harness.queueService.getQueueStatus("ranked");
|
||||
expect(status.count).toBe(0);
|
||||
});
|
||||
29
backend/tsconfig.json
Normal file
29
backend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user