diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 20f81ca7..1ae39b56 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x, 24.x] # Define a matrix of Node.js versions to test + node-version: [22.x, 24.x, 26.x] # Define a matrix of Node.js versions to test steps: - name: Checkout code diff --git a/api/.eslintrc b/api/.eslintrc index 1f8f0d4b..c2cd7e68 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -34,7 +34,15 @@ "import/extensions": ["off"], "no-shadow": ["off"], "@typescript-eslint/no-shadow": ["error"], - "no-console": "warn" + "no-console": "warn", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ] } } ] diff --git a/api/docker-compose.yml b/api/docker-compose.yml index a2089299..d83a89c3 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:13 + image: postgres:14 ports: - '5432:5432' environment: diff --git a/api/docs/breaking-changes.md b/api/docs/breaking-changes.md new file mode 100644 index 00000000..e9722671 --- /dev/null +++ b/api/docs/breaking-changes.md @@ -0,0 +1,78 @@ +# Teams Refactor Breaking Changes + +This release changes the room WebSocket protocol from player-owned state to +team-owned state. Third-party clients must update their room state models before +connecting to rooms running this API version. + +## Player Lists + +The `players` field on `connected`, `chat`, `cellUpdate`, `syncBoard`, and other +room messages is no longer an array of players. It is now an object with two +arrays: + +```ts +{ + teams: Team[]; + spectators: Player[]; +} +``` + +Players in `teams` are nested under their team. Spectators are listed only in +`spectators`. Each `Player` now has `teamId`, which is an empty string for a +spectator. + +## Teams And Colors + +`Team` is a new protocol type with `id`, `name`, `color`, `goalCount`, and +`players`. `Team.color` is the authoritative mark color. + +`Player.color` has been removed from the API payload. Clients must read colors +from the containing team and must not render a color for spectators. + +The existing `changeColor` action now changes the authenticated player's team +color. Spectators cannot use it. + +## Board Cells + +`completedPlayers` has been renamed to `completedTeams`. The array contains +team IDs rather than player IDs. Use each ID to resolve a team from +`players.teams`, then use that team's `color` when rendering a completed cell. + +## Teams Setting + +`RoomData` now includes required boolean `teamsEnabled`. It is included in the +initial room response and `updateRoomData` messages. + +When `teamsEnabled` is `false`, the API still creates one internal team per +player to own marks, but messages display player names and joining another team +is forbidden. When it is `true`, messages display team names and players may +join existing teams. + +Monitors can update the setting over WebSocket: + +```json +{ + "action": "setTeamsEnabled", + "payload": { "enabled": true }, + "authToken": "" +} +``` + +Clients should update local room state after the resulting `updateRoomData` +message. + +## New Action + +`joinTeam` is available when teams are enabled: + +```json +{ + "action": "joinTeam", + "payload": { "teamId": "" }, + "authToken": "" +} +``` + +Clients must handle `joinedTeam`, which returns the destination `Team`, and +`forbidden`, which is returned when teams are disabled or the token lacks the +necessary permission. \ No newline at end of file diff --git a/api/docs/testing-plan.md b/api/docs/testing-plan.md new file mode 100644 index 00000000..484d24f0 --- /dev/null +++ b/api/docs/testing-plan.md @@ -0,0 +1,241 @@ +# Test Coverage Analysis & Improvement Plan + +> **Date:** July 2026 +> **Status:** Proposal +> **Location:** tests + +## Summary + +Our existing test suite covers board generation, utility functions, and basic user registration, but large areas of the codebase remain completely untested — particularly API routes, database operations, Room logic (mark/unmark/join/leave/win conditions), authentication, WebSocket handling, and race integration. + +This document outlines what's currently tested, what's missing, and a phased plan to add both unit tests and integration tests backed by a real PostgreSQL database. + +--- + +## What's Currently Tested + +| Test File | What It Tests | +|-----------|---------------| +| `createUser.test.ts` | Registration endpoint — auth token validation, user creation (mocked DB) | +| `GoalValidation.test.ts` | `validateGoalMeta()` — byte limits, prototype pollution, circular refs, depth bombs | +| `core/boardGenerator.test.ts` | Full board generation pipeline — filters, layouts (random/SRLv5/static), restrictions, determinism | +| `core/Cleanup.test.ts` | Room inactivity detection, `canClose()`, cleanup timer | +| `core/TeamPlayer.test.ts` | Team goal marking/unmarking with BigInt bitmasks, exploration cell reveals | +| `util/Array.test.ts` | Seeded `shuffle()` function | +| `util/WinDetection.test.ts` | `computeLineMasks()` and `hasLineCompletion()` for variable board sizes | + +**What works well:** Board generation is thoroughly tested. Utility functions have solid coverage. The test setup provides a reusable auth mock pattern. + +**What's weak:** All DB interactions are mocked — we have no confidence the actual queries work. Only 1 out of ~15 route files has any test coverage. Core Room logic (the largest file) is barely tested beyond cleanup. + +--- + +## Gaps — Unit Tests Needed + +### Priority 1: Core Game Logic + +**`core/Room.ts`** (~1200 lines, barely tested) + +| Method | What to Test | +|--------|-------------| +| `handleMark` / `handleUnmark` | Cell state changes, broadcast to all players, permission enforcement | +| `handleJoin` / `handleSocketClose` | Player tracking, team assignment, reconnection | +| `handleChat` | Message broadcasting, chat-disabled enforcement | +| `handleNewCard` | Board re-generation, state clearing | +| `checkWinConditions` | All three modes: LOCKOUT, LINES, BLACKOUT | +| `canAutoAuthenticate` | Staff/moderator detection | + +**`auth/RoomAuth.ts`** + +| Function | What to Test | +|----------|-------------| +| `createRoomToken()` | Produces valid JWT, correct payload fields (roomSlug, playerId, permissions) | +| `verifyRoomToken()` | Rejects invalid/expired/wrong-room tokens; accepts valid | +| `invalidateToken()` | Token rejected after invalidation | +| `hasPermission()` | Spectators can't mark/unmark, only monitors can newCard, etc. | + +### Priority 2: Authentication & Users + +**`lib/Auth.ts`** +- `validatePassword()` — correct password → true, wrong → false +- `validateUsernamePasswordCombo()` — same, by username +- `hashPassword()` determinism + +**`util/Session.ts`** +- `removeSessionsForUser()` — finds and removes all sessions for a user + +### Priority 3: API Routes (only Registration has a test) + +| Route File | Endpoints to Test | +|-----------|-------------------| +| `auth/Auth.ts` | Login, logout, session validation | +| `games/Games.ts` | CRUD games | +| `games/Variants.ts` | CRUD variants | +| `goals/Goals.ts` | CRUD goals | +| `goals/GoalCategories.ts` | Category management | +| `goals/Upload.ts` | Bulk goal upload/import | +| `rooms/Rooms.ts` | Room creation, listing | +| `rooms/actions/Actions.ts` | Room action dispatching | +| `users/Users.ts` | User profile retrieval/update | +| `oauth/OAuth.ts` | OAuth flow | +| middleware.ts | `requiresApiToken` — valid/invalid/missing token | + +### Priority 4: Supporting Modules + +| Module | What to Test | +|--------|-------------| +| `core/RoomServer.ts` | WebSocket token verification, 60s auth timeout, message routing, ping/keepalive | +| `core/integration/races/LocalTimer.ts` | Timer start/stop/reset | +| `core/integration/races/RacetimeHandler.ts` | Racetime.gg WebSocket integration (mock external WS) | +| `communication/outgoing/Email.ts` | Template rendering, transport mocking | +| `media/MediaServer.ts` | Avatar upload validation, file type/size checks | + +--- + +## Integration Tests — New Test Suite + +### Why? + +All existing tests mock the database. This means: +- **Zero confidence** that Prisma queries actually work against PostgreSQL +- Schema migrations could break queries without any test catching it +- Complex queries with joins, filters, and relations are completely untested + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ jest.integration.config.ts │ +│ (separate config, *.integration.test.ts) │ +├─────────────────────────────────────────────┤ +│ Global Setup │ +│ - Create test database (bingogg_test) │ +│ - Run prisma migrate deploy │ +│ - Optionally seed reference data │ +├─────────────────────────────────────────────┤ +│ Test Execution │ +│ - Real Prisma client → real PostgreSQL │ +│ - cleanDatabase() between test files │ +├─────────────────────────────────────────────┤ +│ Global Teardown │ +│ - Drop test database │ +└─────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Docker Compose (already exists) │ +│ PostgreSQL 14 on port 5432 │ +└─────────────────────────────────────────────┘ +``` + +### Phase 1: Test Infrastructure + +1. **Create `jest.integration.config.ts`** — separate Jest config targeting `**/*.integration.test.ts` with longer timeouts (30s) +2. **Create `src/tests/integration/setup.ts`** — global setup that creates `bingogg_test` database, runs `prisma migrate deploy`, exports `cleanDatabase()` helper +3. **Create `src/tests/integration/teardown.ts`** — drops the test database +4. **Add npm script:** + ```json + "test:integration": "DATABASE_URL=postgresql://postgres:password@localhost:5432/bingogg_test jest --config jest.integration.config.ts --forceExit --runInBand" + ``` + +### Phase 2: Database Layer Tests + +| Test File | Functions to Cover | +|-----------|-------------------| +| `database/Users.integration.test.ts` | `registerUser`, `userByEmail`, `userByUsername`, `emailUsed`, `usernameUsed`, `getUser` | +| `database/Rooms.integration.test.ts` | `createRoom`, `addJoinAction`, `addMarkAction`, `setRoomBoard`, `getFullRoomList` | +| `database/games/Games.integration.test.ts` | Full CRUD for games | +| `database/games/Goals.integration.test.ts` | CRUD goals, category/tag associations, filtering | +| `database/auth/ApiTokens.integration.test.ts` | Token creation, `validateToken`, revocation | + +### Phase 3: Route Integration Tests (HTTP + Real DB) + +Use `supertest` with the real Express app + real database: + +| Test File | Flow to Test | +|-----------|-------------| +| `routes/registration.integration.test.ts` | Full registration → verify user in DB | +| `routes/auth.integration.test.ts` | Register → login → session cookie → authenticated request → logout | +| `routes/games.integration.test.ts` | Create game → list → get → update → delete | +| `routes/goals.integration.test.ts` | Create goal → assign categories/tags → filter → delete | +| `routes/rooms.integration.test.ts` | Create room → list → verify DB entry | + +### Phase 4: WebSocket Integration Tests (can defer) + +- Use the `ws` library as a test client +- Full lifecycle: connect → authenticate → join room → mark cell → verify state → detect win → disconnect + +### Cleanup Strategy + +Truncate all tables between test files: + +```typescript +export async function cleanDatabase() { + const tablenames = await prisma.$queryRaw<{ tablename: string }[]>` + SELECT tablename FROM pg_tables WHERE schemaname='public' + `; + for (const { tablename } of tablenames) { + if (tablename !== '_prisma_migrations') { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE "public"."${tablename}" CASCADE;`); + } + } +} +``` + +--- + +## Suggested First Contributions + +| Task | Difficulty | Impact | +|------|-----------|--------| +| Unit tests for `auth/RoomAuth.ts` | Easy | High — critical auth path | +| Unit tests for `hasPermission()` | Easy | High — security-relevant | +| Integration test infrastructure (Phase 1) | Medium | High — unblocks all integration work | +| Unit tests for `Room.checkWinConditions` | Medium | High — core game logic | +| Database integration tests for Users | Easy | Medium — template for other DB tests | +| Route tests for middleware.ts | Easy | Medium — auth boundary | +| Unit tests for `core/Room.handleMark` | Hard | High — complex state management | + +--- + +## CI Integration + +```yaml +- name: Start test database + run: docker compose up -d + +- name: Wait for PostgreSQL + run: until pg_isready -h localhost -p 5432; do sleep 1; done + +- name: Run unit tests + run: npm test + +- name: Run integration tests + run: npm run test:integration + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/bingogg_test + +- name: Stop test database + run: docker compose down +``` + +--- + +## Coverage Targets + +| Module | Current (est.) | Target | +|--------|----------------|--------| +| `core/` | ~30% | >70% | +| `database/` | 0% | >80% | +| `routes/` | ~5% | >60% | +| `auth/` | 0% | >90% | +| `util/` | ~60% | >90% | +| `lib/` | 0% | >80% | + +--- + +## Open Questions + +1. **Test DB seeding** — Should integration tests use `prisma db seed` for baseline reference data, or create all needed data in each test? +2. **CI environment** — Does CI already have Docker available, or do we need a service container? +3. **WebSocket tests** — Should we defer Phase 4 until Phases 1-3 are solid? \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json index 79f27613..b332ab89 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -870,6 +870,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3683,6 +3684,7 @@ "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/expect": "30.2.0", @@ -4013,6 +4015,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4252,6 +4255,7 @@ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -5539,6 +5543,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", "integrity": "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5712,6 +5717,7 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -6230,6 +6236,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6275,6 +6282,7 @@ "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-EDtsGZS964mf9zAUXAl9Ew16eYbeyAFWhsPr0fX6oaJxgd8rApYlPBf0joyhnUHz88WxrigyFtTaqqzXNzPgqw==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -6881,6 +6889,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8245,6 +8254,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8431,6 +8441,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8915,7 +8926,6 @@ "resolved": "https://registry.npmjs.org/express-handlebars/-/express-handlebars-8.0.4.tgz", "integrity": "sha512-1mXd9jxLfZgFjpPGamAizVhwukvwLlXRV0dPcsEvW2hqUlYICMtJAQrqFSmgwHFvbVeIA/afPOtmHtNF516pmQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "glob": "^12.0.0", "graceful-fs": "^4.2.11", @@ -8930,7 +8940,6 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "peer": true, "engines": { "node": "18 || 20 || >=22" } @@ -8940,7 +8949,6 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^4.0.2" }, @@ -8953,7 +8961,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-12.0.0.tgz", "integrity": "sha512-5Qcll1z7IKgHr5g485ePDdHcNQY0k2dtv/bjYy0iuyGxQw2qSOiiXUXJ+AYQpg3HNoUMHqAruX478Jeev7UULw==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", @@ -8977,7 +8984,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", "license": "BlueOak-1.0.0", - "peer": true, "dependencies": { "brace-expansion": "^5.0.2" }, @@ -10641,6 +10647,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -12069,6 +12076,7 @@ "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -12298,6 +12306,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -13086,6 +13095,7 @@ "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" @@ -13386,6 +13396,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13395,6 +13406,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14625,6 +14637,7 @@ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.8.tgz", "integrity": "sha512-Kq/W41AKQloOqKM39zfaMdJ4BcYDw/N5CIq4/GTI0YjU6pKcZ1KKhk6b4du0a+6RA9pIfOP/eu94Ge7cu+PDCA==", "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", @@ -15062,6 +15075,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15329,6 +15343,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15465,6 +15480,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -15721,6 +15737,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", diff --git a/api/package.json b/api/package.json index d538a40b..5ea2c496 100644 --- a/api/package.json +++ b/api/package.json @@ -10,8 +10,8 @@ "dev": "tsc-watch --noClear --onSuccess \"node build/src/main.js\"", "build": "tsc --sourceMap false", "db:reset": "prisma migrate reset", - "db:pre-migrate": "npm run db:update-defaults && prisma migrate dev", - "db:migrate": "npm run db:pre-migrate && prisma migrate dev", + "db:pre-migrate": "npm run db:update-defaults && prisma migrate deploy", + "db:migrate": "npm run db:pre-migrate && prisma migrate deploy", "db:seed": "prisma db seed", "db:generate-client": "prisma generate", "db:update-defaults": "tsx scripts/update-prisma-defaults.ts", diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts new file mode 100644 index 00000000..dd4940b7 --- /dev/null +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/data-migration.ts @@ -0,0 +1,45 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.$transaction( + async (tx) => { + // Player.spectator is deprecated but retained for this migration. + // Player.color is also retained in the database but is no longer used. + const players = await tx.player.findMany({ + select: { + id: true, + spectator: true, + roomId: true, + nickname: true, + }, + }); + + for (const player of players) { + if (!player.spectator) { + const team = await tx.team.create({ + data: { + name: `${player.nickname}'s Team`, + key: player.id, + roomId: player.roomId, + }, + }); + + await tx.player.update({ + where: { id: player.id }, + data: { teamId: team.id }, + }); + } + } + }, + { timeout: 300000 }, + ); +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => await prisma.$disconnect()); diff --git a/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql new file mode 100644 index 00000000..9ac5f2c9 --- /dev/null +++ b/api/prisma/migrations/20260515200019_refactor_for_teams/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +-- Legacy Player.color and Player.spectator data are retained for a later migration. +ALTER TABLE "Player" ADD COLUMN "teamId" TEXT; + +-- AlterTable +ALTER TABLE "Room" ADD COLUMN "teamsEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "Team" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT NOT NULL, + "color" TEXT NOT NULL DEFAULT 'blue', + "roomId" TEXT NOT NULL, + + CONSTRAINT "Team_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Team_id_key" ON "Team"("id"); + +-- AddForeignKey +ALTER TABLE "Team" ADD CONSTRAINT "Team_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Player" ADD CONSTRAINT "Player_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d3f346e1..93b3b46c 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -167,22 +167,41 @@ model Room { bingoMode BingoMode @default(LINES) lineCount Int @default(1) players Player[] + teams Team[] variant Variant? @relation(fields: [variantId], references: [id]) variantId String? exploration Boolean @default(false) explorationStart String? + teamsEnabled Boolean @default(false) raceHandler RaceHandler? startedAt DateTime? finishedAt DateTime? } +model Team { + id String @id @unique @default(cuid(2)) + key String + name String + color String @default("blue") + room Room @relation(fields: [roomId], references: [id]) + roomId String + players Player[] + + @@unique([id, roomId]) +} + model Player { id String @id @unique @default(cuid()) key String user User? @relation(fields: [userId], references: [id]) room Room @relation(fields: [roomId], references: [id]) + // A player either belongs to a team or is a spectator + team Team? @relation(fields: [teamId, roomId], references: [id, roomId]) + teamId String? nickname String + /// @deprecated Use Team.color. Retained temporarily to preserve existing data. color String @default("blue") + /// @deprecated Use teamId to distinguish players from spectators. Retained temporarily for data migration compatibility. spectator Boolean monitor Boolean @default(false) roomId String diff --git a/api/src/auth/RoomAuth.ts b/api/src/auth/RoomAuth.ts index 116057ca..acf46140 100644 --- a/api/src/auth/RoomAuth.ts +++ b/api/src/auth/RoomAuth.ts @@ -80,6 +80,7 @@ export const hasPermission = ( case 'resetTimer': return payload.isMonitor; case 'setChatEnabled': + case 'setTeamsEnabled': return payload.isMonitor; default: return true; diff --git a/api/src/core/Player.ts b/api/src/core/Player.ts index f02b9bac..ad42424f 100644 --- a/api/src/core/Player.ts +++ b/api/src/core/Player.ts @@ -1,14 +1,15 @@ import { - HiddenCell, Player as PlayerClientData, - RevealedCell, ServerMessage, + HiddenCell, + RevealedCell } from '@playbingo/types'; import { OPEN, WebSocket } from 'ws'; import { RoomTokenPayload } from '../auth/RoomAuth'; -import { computeRevealedMask, rowColToMask } from '../util/RoomUtils'; import Room from './Room'; +export type BoardViewProvider = () => (RevealedCell | HiddenCell)[][]; + /** * Represents a player connected to a room. While largely just a data class, this * class offers utilities to make keeping track of players, identities, and their @@ -32,24 +33,11 @@ export default class Player { id: string; /** Player display name */ nickname: string; - /** The players chosen color */ - color: string; userId?: string; - /** If the player is in spectator mode or not */ - spectator: boolean; /** If the player has permission to perform monitor actions in the room */ monitor: boolean; - - /** Bitset of the goals the player has marked */ - markedGoals: bigint; - /** The number of goals the player has marked */ - goalCount: number; - /** Whether or not the player has completed the goal of the room */ - goalComplete: boolean; - linesComplete: number; - /** Bitset of goals that are revealed for the player in exploration based - * modes */ - exploredGoals: bigint; + /** Parent Team Id, undefined if spectator */ + teamId?: string; /** Open connections for the player, mapped by the id in the auth token that * is authorized for the connection */ @@ -57,27 +45,23 @@ export default class Player { finishedAt?: string; + getBoardView: BoardViewProvider; + constructor( room: Room, id: string, nickname: string, - color: string = 'blue', - spectator: boolean, monitor: boolean, - userId?: string, + getBoardView: BoardViewProvider, + teamId?: string, + userId?: string ) { this.room = room; ((this.id = id), (this.nickname = nickname)); - this.color = color; - this.spectator = spectator; + this.teamId = teamId; this.monitor = monitor; this.userId = userId; - - this.markedGoals = 0n; - this.goalCount = 0; - this.goalComplete = false; - this.linesComplete = 0; - this.exploredGoals = 0n; + this.getBoardView = getBoardView; this.connections = new Map(); } @@ -147,15 +131,13 @@ export default class Player { return { id: this.id, nickname: this.nickname, - color: this.color, - goalCount: this.goalCount, + teamId: this.teamId || '', raceStatus: raceUser ? { connected: true, ...raceUser, } : { connected: false }, - spectator: this.spectator, monitor: this.monitor, showInRoom: this.showInRoom(), }; @@ -177,19 +159,19 @@ export default class Player { action: 'syncBoard', board: { hidden: false, - board: this.obfuscateBoard(), + board: this.getBoardView(), width: this.room.board[0].length, height: this.room.board.length, }, }; } else if (message.action === 'syncBoard' && this.room.exploration) { if (!message.board.hidden) { - message.board.board = this.obfuscateBoard(); + message.board.board = this.getBoardView(); } finalMessage = message; } else if (message.action === 'connected') { if (!message.board.hidden) { - message.board.board = this.obfuscateBoard(); + message.board.board = this.getBoardView(); } finalMessage = message; } else { @@ -212,86 +194,6 @@ export default class Player { return this.connections.size > 0; } - //#region Goal Tracking - mark(row: number, col: number) { - const mask = rowColToMask(row, col, this.room.board[0].length); - if ((this.markedGoals & mask) === 0n) { - this.markedGoals |= mask; - this.goalCount++; - if (this.room.exploration) { - this.exploredGoals = this.getRevealedMask(); - } - } - } - - unmark(row: number, col: number) { - const mask = rowColToMask(row, col, this.room.board[0].length); - if ((this.markedGoals & mask) !== 0n) { - this.markedGoals &= ~mask; - this.goalCount--; - if (this.room.exploration) { - this.exploredGoals = this.getRevealedMask(); - } - } - } - - hasMarked(row: number, col: number): boolean { - const mask = rowColToMask(row, col, this.room.board[0].length); - return (this.markedGoals & mask) !== 0n; - } - - hasRevealed(row: number, col: number): boolean { - const mask = rowColToMask(row, col, this.room.board[0].length); - return (this.exploredGoals & mask) !== 0n; - } - - getRevealedMask(): bigint { - return ( - computeRevealedMask( - this.markedGoals, - this.room.board[0].length, - this.room.board.length, - ) | this.room.alwaysRevealedMask - ); - } - - obfuscateBoard() { - if (this.spectator) { - this.exploredGoals = 0n; - this.room.players.forEach((player) => { - if (!player.spectator) { - this.exploredGoals |= player.getRevealedMask(); - } - }); - } else { - this.exploredGoals = this.getRevealedMask(); - } - return this.room.board.map((row, rowIndex) => - row.map((cell, colIndex) => - this.hasRevealed(rowIndex, colIndex) - ? ({ - revealed: true, - goal: cell.goal, - completedPlayers: cell.completedPlayers, - } as RevealedCell) - : ({ - revealed: false, - completedPlayers: cell.completedPlayers, - } as HiddenCell), - ), - ); - } - - /** - * Checks if this player has completed a set of goals on the board - * - * @param mask The bitmask containing the goals to check for - */ - hasCompletedGoals(mask: bigint) { - return (this.markedGoals & mask) === mask; - } - //#endregion - //#region Races async joinRace() { return this.room.raceHandler.joinPlayer(this); diff --git a/api/src/core/Room.ts b/api/src/core/Room.ts index 0046cb6e..3b56ba6f 100644 --- a/api/src/core/Room.ts +++ b/api/src/core/Room.ts @@ -1,4 +1,5 @@ import { GeneratorSettings } from '@playbingo/shared'; +import { randomUUID } from 'crypto'; import { ChangeColorAction, ChangeRaceHandlerAction, @@ -9,10 +10,13 @@ import { MarkAction, NewCardAction, Player as PlayerData, + Team as TeamData, RevealedCell, ServerMessage, UnmarkAction, SetChatEnabledAction, + JoinTeamAction, + SetTeamsEnabledAction, } from '@playbingo/types'; import { BingoMode } from '@prisma/client'; import { WebSocket } from 'ws'; @@ -31,8 +35,10 @@ import { addMarkAction, addUnmarkAction, createUpdatePlayer, + createUpdateTeam, setRoomBoard, updateRaceHandler, + updateTeamsEnabled, } from '../database/Rooms'; import { isStaff } from '../database/Users'; import { @@ -62,6 +68,12 @@ import { generateSRLv5 } from './generation/SRLv5'; import LocalTimer from './integration/races/LocalTimer'; import RaceHandler from './integration/races/RaceHandler'; import RacetimeHandler, { RaceData } from './integration/races/RacetimeHandler'; +import Team from './Team'; + +export type HiddenCell = { + revealed: false; + completedTeams: string[]; +}; export enum BoardGenerationMode { RANDOM = 'Random', @@ -113,6 +125,7 @@ export default class Room { exploration: boolean = false; alwaysRevealedMask: bigint = 0n; seed: number; + teamsEnabled: boolean; chatEnabled: boolean = true; lastGenerationMode: BoardGenerationOptions; @@ -131,7 +144,9 @@ export default class Room { inactivityWarningTimeout?: NodeJS.Timeout; closeTimeout?: NodeJS.Timeout; - players: Map; + // players: Map; + teams: Map; + spectators: Map; constructor( name: string, @@ -149,6 +164,7 @@ export default class Room { explorationStart?: string, racetimeUrl?: string, generatorSettings?: GeneratorSettings, + teamsEnabled: boolean = false, ) { this.name = name; this.game = game; @@ -190,9 +206,11 @@ export default class Room { roomCleanupInactive, ); - this.players = new Map(); + this.teams = new Map(); + this.spectators = new Map(); this.seed = seed; + this.teamsEnabled = teamsEnabled; if (explorationStart) { this.exploration = true; @@ -237,6 +255,59 @@ export default class Room { } } + getAllPlayers(): Player[] { + const players = this.teams + .values() + .flatMap((team) => team.players.values()); + return [...this.spectators.values(), ...players]; + } + + getPlayerById(playerId: string): Player | undefined { + return this.getAllPlayers().find((player) => player.id === playerId); + } + + getPlayerDisplayName(player: Player, team?: Team): string { + return this.teamsEnabled && team ? team.name : player.nickname; + } + + getTeamDisplayName(team: Team): string { + if (this.teamsEnabled) { + return team.name; + } + return team.players.values().next().value?.nickname ?? team.name; + } + + deleteTeam(teamId: string) { + this.teams.get(teamId)?.destroy(); + this.teams.delete(teamId); + } + + spectatorObfuscateBoard(): (RevealedCell | HiddenCell)[][] { + let exploredGoals = 0n; + this.teams.forEach((team) => { + exploredGoals |= team.getRevealedMask(); + }); + return this.board.map((row, rowIndex) => + row.map((cell, colIndex) => { + const mask = rowColToMask( + rowIndex, + colIndex, + this.board[0].length, + ); + return (exploredGoals & mask) !== 0n + ? ({ + revealed: true, + goal: cell.goal, + completedTeams: cell.completedTeams, + } as RevealedCell) + : ({ + revealed: false, + completedTeams: cell.completedTeams, + } as HiddenCell); + }), + ); + } + async generateBoard(options: BoardGenerationOptions) { this.lastGenerationMode = options; const { mode, seed } = options; @@ -264,7 +335,7 @@ export default class Room { this.board = generator.board.map((row) => row.map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), ); @@ -366,12 +437,23 @@ export default class Room { ); } - getPlayers(): PlayerData[] { - const players: PlayerData[] = []; - this.players.forEach((player) => { - players.push(player.toClientData()); - }); - return players; + getPlayerData(): { teams: TeamData[]; spectators: PlayerData[] } { + const teams: TeamData[] = []; + this.teams.forEach((team) => teams.push(team.toClientData())); + const spectators: PlayerData[] = []; + this.spectators.forEach((spectator) => + spectators.push(spectator.toClientData()), + ); + return { teams, spectators }; + } + + getTeamForPlayer(playerId: string): Team | undefined { + for (const team of this.teams.values()) { + if (team.players.has(playerId)) { + return team; + } + } + return undefined; } //#region Handlers @@ -380,30 +462,50 @@ export default class Room { auth: RoomTokenPayload, socket: WebSocket, ): ServerMessage { - let player: Player | undefined; + let player = this.getPlayerById(auth.playerId); + let playerTeam = auth.isSpectating + ? undefined + : player + ? this.getTeamForPlayer(player.id) + : undefined; let newPlayer = false; - if (this.players.has(auth.playerId)) { - player = this.players.get(auth.playerId); - if (!player) { - return { action: 'unauthorized' }; + if (!player && action.payload) { + const teamId = auth.isSpectating ? undefined : randomUUID(); + if (!auth.isSpectating) { + playerTeam = new Team( + this, + teamId!, + `Team ${action.payload.nickname}`, + 'blue', + ); + player = new Player( + this, + auth.playerId, + action.payload.nickname, + auth.isMonitor, + playerTeam.obfuscateBoard, + teamId, + auth.userId, + ); + playerTeam.addPlayer(player); + this.teams.set(playerTeam!.id, playerTeam!); + } else { + player = new Player( + this, + auth.playerId, + action.payload.nickname, + auth.isMonitor, + this.spectatorObfuscateBoard, + teamId, + auth.userId, + ); + this.spectators.set(auth.playerId, player); } - } else if (action.payload) { - player = new Player( - this, - auth.playerId, - action.payload.nickname, - undefined, - auth.isSpectating, - auth.isMonitor, - auth.userId, - ); - this.players.set(player.id, player); newPlayer = true; - } else { - player = this.players.get(auth.playerId); - if (!player) { - return { action: 'unauthorized' }; - } + } + + if (!player || (!auth.isSpectating && !playerTeam)) { + return { action: 'unauthorized' }; } if (newPlayer) { @@ -411,14 +513,17 @@ export default class Room { this.sendChat(`${player.nickname} is now spectating`); } else { this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has joined.', + { contents: player.nickname, color: playerTeam!.color }, + ` has joined playing for ${playerTeam!.name}.`, ]); } } player.addConnection(auth.uuid, socket); - addJoinAction(this.id, player.nickname, player.color).then(); + addJoinAction(this.id, player.nickname).then(); + if (playerTeam) { + createUpdateTeam(this.id, playerTeam).then(); + } createUpdatePlayer(this.id, player).then(); return { action: 'connected', @@ -430,7 +535,7 @@ export default class Room { : { hidden: false, board: this.exploration - ? player.obfuscateBoard() + ? player.getBoardView() : this.board, }), }, @@ -457,11 +562,60 @@ export default class Room { : { gameActive: this.racetimeEligible, url: undefined }, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, startedAt: this.raceHandler?.getStartTime(), finishedAt: this.raceHandler?.getEndTime(), raceHandler: this.raceHandler?.key(), }, - players: this.getPlayers(), + players: this.getPlayerData(), + }; + } + + handleJoinTeam( + action: JoinTeamAction, + auth: RoomTokenPayload, + ): ServerMessage { + if (!this.teamsEnabled) { + return { action: 'forbidden' }; + } + const player = this.getPlayerById(auth.playerId); + if (!player) { + return { action: 'unauthorized' }; + } + const team = this.teams.get(action.payload.teamId); + if (!team) { + return { action: 'unauthorized' }; + } + const oldTeam = this.getTeamForPlayer(player.id); + if (oldTeam) { + oldTeam.removePlayer(player.id); + if (oldTeam.players.size === 0) { + oldTeam.destroy(); + this.teams.delete(oldTeam.id); + } + } else { + // player was spectator before + this.spectators.delete(player.id); + } + player.teamId = team.id; + team.addPlayer(player); + createUpdatePlayer(this.id, player).then(); + if (oldTeam) { + createUpdateTeam(this.id, oldTeam).then(); + } + if (team) { + createUpdateTeam(this.id, team).then(); + } + this.sendChat([ + { + contents: player.nickname, + color: team.color, + }, + ` joined ${team.name}`, + ]); + return { + action: 'joinedTeam', + team: team.toClientData(), }; } @@ -471,7 +625,7 @@ export default class Room { token: string, ): ServerMessage { let player: Player | undefined = undefined; - for (const p of this.players.values()) { + for (const p of this.getAllPlayers()) { if (p.closeConnection(auth.uuid)) { player = p; break; @@ -482,12 +636,24 @@ export default class Room { } const hasLeft = !player.hasConnections(); if (hasLeft) { - this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has left.', - ]); - addLeaveAction(this.id, player.nickname, player.color).then(); - if (this.players.size === 0) { + const playerTeam = this.getTeamForPlayer(player.id); + if (playerTeam) { + playerTeam.removePlayer(player.id); + if (playerTeam.players.size === 0) { + playerTeam.destroy(); + this.teams.delete(playerTeam.id); + } + } + if (playerTeam) { + this.sendChat([ + { contents: player.nickname, color: playerTeam.color }, + ' has left.', + ]); + } else { + this.sendChat(`${player.nickname} has left.`); + } + addLeaveAction(this.id, player.nickname).then(); + if (this.getAllPlayers().length === 0) { this.close(); } } @@ -499,7 +665,7 @@ export default class Room { action: ChatAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); + const player = this.getPlayerById(auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -509,7 +675,6 @@ export default class Room { addChatAction( this.id, player.nickname, - player.color, chatMessage, ).then(); } @@ -518,29 +683,30 @@ export default class Room { action: MarkAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); - if (!player) { + const team = this.getTeamForPlayer(auth.playerId); + const player = team?.players.get(auth.playerId); + if (!team || !player) { return { action: 'unauthorized' }; } const { row, col } = action.payload; if (row === undefined || col === undefined) return; - if (player.hasMarked(row, col)) return; + if (team.hasMarked(row, col)) return; if ( this.bingoMode === BingoMode.LOCKOUT && - this.board[row][col].completedPlayers.length > 0 + this.board[row][col].completedTeams.length > 0 ) return; - this.board[row][col].completedPlayers.push(player.id); - this.board[row][col].completedPlayers.sort((a, b) => + this.board[row][col].completedTeams.push(team.id); + this.board[row][col].completedTeams.sort((a, b) => a.localeCompare(b), ); - player.mark(row, col); + team.mark(row, col); this.sendCellUpdate(row, col); this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getPlayerDisplayName(player, team), + color: team.color, }, ` marked ${this.board[row][col].goal.goal} (${row},${col})`, ]); @@ -552,20 +718,24 @@ export default class Room { action: UnmarkAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); - if (!player) { + const team = this.getTeamForPlayer(auth.playerId); + const player = team?.players.get(auth.playerId); + if (!team || !player) { return { action: 'unauthorized' }; } const { row: unRow, col: unCol } = action.payload; if (unRow === undefined || unCol === undefined) return; - if (!player.hasMarked(unRow, unCol)) return; - this.board[unRow][unCol].completedPlayers = this.board[unRow][ + if (!team.hasMarked(unRow, unCol)) return; + this.board[unRow][unCol].completedTeams = this.board[unRow][ unCol - ].completedPlayers.filter((playerId) => playerId !== player.id); - player.unmark(unRow, unCol); + ].completedTeams.filter((teamId) => teamId !== team.id); + team.unmark(unRow, unCol); this.sendCellUpdate(unRow, unCol); this.sendChat([ - { contents: player.nickname, color: player.color }, + { + contents: this.getPlayerDisplayName(player, team), + color: team.color, + }, ` unmarked ${this.board[unRow][unCol].goal.goal} (${unRow},${unCol})`, ]); addUnmarkAction(this.id, player.id, unRow, unCol).then(); @@ -576,7 +746,7 @@ export default class Room { action: ChangeColorAction, auth: RoomTokenPayload, ): ServerMessage | undefined { - const player = this.players.get(auth.playerId); + const player = this.getPlayerById(auth.playerId); if (!player) { return { action: 'unauthorized' }; } @@ -584,17 +754,16 @@ export default class Room { if (!color) { return; } - addChangeColorAction( - this.id, - player.nickname, - player.color, - color, - ).then(); - player.color = color; - createUpdatePlayer(this.id, player).then(); + const team = this.getTeamForPlayer(player.id); + if (!team) { + return { action: 'unauthorized' }; + } + addChangeColorAction(this.id, team.name, team.color, color).then(); + team.color = color; + createUpdateTeam(this.id, team).then(); this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has changed their color to ', + { contents: this.getTeamDisplayName(team), color: team.color }, + ' has changed its color to ', { contents: color, color }, ]); } @@ -641,19 +810,22 @@ export default class Room { handleSocketClose(ws: WebSocket) { let player: Player | undefined; - for (const p of this.players.values()) { - if (p.handleSocketClose(ws)) { - player = p; - } - } + this.getAllPlayers().forEach((p) => { + if (p.handleSocketClose(ws)) player = p; + }); if (player) { if (!player.hasConnections()) { - this.sendChat([ - { contents: player.nickname, color: player.color }, - ' has left.', - ]); - addLeaveAction(this.id, player.nickname, player.color).then(); - if (this.players.size === 0) { + const team = this.getTeamForPlayer(player.id); + if (team) { + this.sendChat([ + { contents: player.nickname, color: team.color }, + ' has left.', + ]); + } else { + this.sendChat(`${player.nickname} has left.`); + } + addLeaveAction(this.id, player.nickname).then(); + if (this.getAllPlayers().length === 0) { this.close(); } } @@ -677,6 +849,7 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, raceHandler: this.raceHandler?.key(), }, }); @@ -701,13 +874,14 @@ export default class Room { newGenerator: this.newGenerator, mode: getModeString(this.bingoMode, this.lineCount), variant: this.variantName, + teamsEnabled: this.teamsEnabled, raceHandler: this.raceHandler?.key(), }, }); } handleRevealCard(payload: RoomTokenPayload) { - const player = this.players.get(payload.playerId); + const player = this.getPlayerById(payload.playerId); if (!player) { return null; } @@ -718,6 +892,12 @@ export default class Room { this.chatEnabled = action.payload.enabled; this.sendRoomData(); } + + handleSetTeamsEnabled(action: SetTeamsEnabledAction) { + this.teamsEnabled = action.payload.enabled; + updateTeamsEnabled(this.id, this.teamsEnabled).then(); + this.sendRoomData(); + } //#endregion //#region Send Messages @@ -768,7 +948,7 @@ export default class Room { this.logInfo('Dispatching race data update'); this.sendServerMessage({ action: 'syncRaceData', - players: this.getPlayers(), + players: this.getPlayerData(), racetimeConnection: { gameActive: this.racetimeEligible, url: (this.raceHandler as RacetimeHandler).url, @@ -807,6 +987,7 @@ export default class Room { finishedAt: this.raceHandler?.getEndTime(), raceHandler: this.raceHandler?.key(), chatEnabled: this.chatEnabled, + teamsEnabled: this.teamsEnabled, }, }); } @@ -815,8 +996,8 @@ export default class Room { message: ServerMessage, updateInactivity: boolean = true, ) { - this.players.forEach((player) => { - player.sendMessage({ ...message, players: this.getPlayers() }); + this.getAllPlayers().forEach((player) => { + player.sendMessage({ ...message, players: this.getPlayerData() }); }); if (updateInactivity) { @@ -828,98 +1009,107 @@ export default class Room { } private checkWinConditions() { - this.players.forEach((player) => { + this.teams.forEach((team) => { if (this.bingoMode === BingoMode.LOCKOUT) { const goalsNeeded = Math.ceil( (this.board.length * this.board[0].length) / 2, ); - if (!player.goalComplete && player.goalCount >= goalsNeeded) { + if (!team.goalComplete && team.goalCount >= goalsNeeded) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' has achieved lockout!', ]); - player.goalComplete = true; - this.raceHandler?.playerFinished(player); + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player); + }); } - if (player.goalComplete && player.goalCount < goalsNeeded) { + if (team.goalComplete && team.goalCount < goalsNeeded) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' no longer has lockout.', ]); - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player); + }); } } else { if (this.bingoMode === BingoMode.LINES) { const linesComplete = this.victoryMasks.reduce( (count, mask) => - count + (player.hasCompletedGoals(mask) ? 1 : 0), + count + (team.hasCompletedGoals(mask) ? 1 : 0), 0, ); - if (linesComplete > player.linesComplete) { + if (linesComplete > team.linesComplete) { this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' has completed a line!', ]); } - if ( - linesComplete >= this.lineCount && - !player.goalComplete - ) { - player.goalComplete = true; - this.raceHandler?.playerFinished(player).then(); + if (linesComplete >= this.lineCount && !team.goalComplete) { + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player).then(); + }); this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' has completed the goal!', ]); } else if ( linesComplete < this.lineCount && - player.goalComplete + team.goalComplete ) { - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player).then(); + }); this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' has no longer completed the goal.', ]); } - player.linesComplete = linesComplete; + team.linesComplete = linesComplete; } else { const complete = this.victoryMasks.every((mask) => - player.hasCompletedGoals(mask), + team.hasCompletedGoals(mask), ); - if (complete && !player.goalComplete) { - player.goalComplete = true; - this.raceHandler?.playerFinished(player); + if (complete && !team.goalComplete) { + team.goalComplete = true; + team.players.values().forEach((player) => { + this.raceHandler?.playerFinished(player); + }); this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' has achieved blackout!', ]); - } else if (!complete && player.goalComplete) { - player.goalComplete = false; - this.raceHandler?.playerUnfinshed(player); + } else if (!complete && team.goalComplete) { + team.goalComplete = false; + team.players.values().forEach((player) => { + this.raceHandler?.playerUnfinshed(player); + }); this.sendChat([ { - contents: player.nickname, - color: player.color, + contents: this.getTeamDisplayName(team), + color: team.color, }, ' no longer has blackout.', ]); @@ -928,8 +1118,8 @@ export default class Room { } }); let allComplete = true; - this.players.forEach((player) => { - if (!player.spectator && !player.goalComplete) { + this.teams.forEach((team) => { + if (!team.goalComplete) { allComplete = false; } }); @@ -968,13 +1158,13 @@ export default class Room { return false; } - const player = this.players.get( + const player = this.getPlayerById( `${isSession ? 'session' : 'user'}:${user}`, ); if (player) { return { isMonitor: player.monitor, - isSpectating: player.spectator, + isSpectating: this.spectators.has(player.id), }; } @@ -1001,7 +1191,7 @@ export default class Room { } joinRaceRoom(racetimeId: string, authToken: RoomTokenPayload) { - const player = this.players.get(authToken.playerId); + const player = this.getPlayerById(authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1011,7 +1201,7 @@ export default class Room { } leaveRaceRoom(authToken: RoomTokenPayload) { - const player = this.players.get(authToken.playerId); + const player = this.getPlayerById(authToken.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1025,7 +1215,7 @@ export default class Room { } readyPlayer(roomAuth: RoomTokenPayload) { - const player = this.players.get(roomAuth.playerId); + const player = this.getPlayerById(roomAuth.playerId); if (!player) { this.logWarn('Unable to find a player for a verified room token'); return false; @@ -1035,7 +1225,7 @@ export default class Room { } unreadyPlayer(roomAuth: RoomTokenPayload) { - const player = this.players.get(roomAuth.playerId); + const player = this.getPlayerById(roomAuth.playerId); if (!player) { this.logWarn( 'Unable to find an identity for a verified room token', @@ -1081,7 +1271,7 @@ export default class Room { */ canClose() { if (Date.now() - this.lastMessage > roomCleanupInactive) { - return this.players.size <= 0; + return this.getAllPlayers().length <= 0; } return false; } @@ -1092,7 +1282,7 @@ export default class Room { close() { this.logInfo('Closing room.'); this.sendSystemMessage('This room has been closed due to inactivity.'); - this.players.forEach((player) => { + this.getAllPlayers().forEach((player) => { player.connections.forEach((connection) => { this.handleSocketClose(connection); connection.close(1001, 'Room is closing.'); @@ -1102,13 +1292,15 @@ export default class Room { } revealCardForPlayer(player: Player) { - this.sendChat([ - { - contents: player.nickname, - color: player.color, - }, - ' has revealed the card.', - ]); + const team = this.getTeamForPlayer(player.id); + if (team) { + this.sendChat([ + { contents: player.nickname, color: team.color }, + ' has revealed the card.', + ]); + } else { + this.sendChat(`${player.nickname} has revealed the card.`); + } player.sendMessage({ action: 'syncBoard', board: { @@ -1121,7 +1313,7 @@ export default class Room { } revealCardForAllPlayers() { - this.players.forEach((player) => { + this.getAllPlayers().forEach((player) => { this.revealCardForPlayer(player); }); } diff --git a/api/src/core/RoomServer.ts b/api/src/core/RoomServer.ts index 627eb347..a7d2767d 100644 --- a/api/src/core/RoomServer.ts +++ b/api/src/core/RoomServer.ts @@ -8,6 +8,8 @@ import { import { roomCleanupInterval } from '../Environment'; import { logInfo, logWarn } from '../Logger'; import Room from './Room'; +import Team from './Team'; +import Player from './Player'; export const roomWebSocketServer: WebSocketServer = new WebSocketServer({ noServer: true, @@ -104,6 +106,12 @@ roomWebSocketServer.on('connection', (ws, req) => { ws.send(JSON.stringify(unmarkResult)); } break; + case 'joinTeam': + const joinTeamResult = room.handleJoinTeam(action, payload); + if (joinTeamResult) { + ws.send(JSON.stringify(joinTeamResult)); + } + break; case 'chat': const chatResult = room.handleChat(action, payload); if (chatResult) { @@ -135,18 +143,30 @@ roomWebSocketServer.on('connection', (ws, req) => { payload.playerId.split(':')[1], payload.userId, ); - const player = room.players.get(payload.playerId); + const player = room.getPlayerById(payload.playerId); + const team = player + ? room.getTeamForPlayer(player.id) + : undefined; if (player) { - player.spectator = action.payload.spectate; - player.sendMessage({ - action: 'reauthenticate', - authToken: newToken, - }); - if (player.spectator) { - player.markedGoals = 0n; - player.goalCount = 0; + if (action.payload.spectate) { + if (team) { + team.removePlayer(player.id); + if (team.players.size === 0) { + team.destroy(); + room.teams.delete(team.id); + } + } + player.sendMessage({ + action: 'reauthenticate', + authToken: newToken, + }); room.sendChat(`${player.nickname} is now spectating`); + break; } else { + player.sendMessage({ + action: 'reauthenticate', + authToken: newToken, + }); room.sendChat(`${player.nickname} is now playing`); } } @@ -163,6 +183,9 @@ roomWebSocketServer.on('connection', (ws, req) => { case 'setChatEnabled': room.handleSetChatEnabled(action); break; + case 'setTeamsEnabled': + room.handleSetTeamsEnabled(action); + break; } }); ws.on('close', (code, reason) => { diff --git a/api/src/core/Team.ts b/api/src/core/Team.ts new file mode 100644 index 00000000..ba7e1f54 --- /dev/null +++ b/api/src/core/Team.ts @@ -0,0 +1,139 @@ +import Room from './Room'; +import Player from './Player'; +import { HiddenCell, RevealedCell, Team as TeamData } from '@playbingo/types'; +import { computeRevealedMask, rowColToMask } from '../util/RoomUtils'; + +export default class Team { + room: Room; + /** + * Unique id for the team + */ + id: string; + /** + * The name of the team + */ + name: string; + /** The color used to display this team's marks */ + color: string; + /** + * The players on the team + */ + players: Map; + + /** Bitset of the goals the player has marked */ + markedGoals: bigint; + /** The number of goals the player has marked */ + goalCount: number; + /** Whether or not the player has completed the goal of the room */ + goalComplete: boolean; + linesComplete: number; + /** Bitset of goals that are revealed for the player in exploration based + * modes */ + exploredGoals: bigint; + + constructor(room: Room, id: string, name: string, color: string = 'blue') { + this.room = room; + ((this.id = id), (this.name = name), (this.color = color)); + this.players = new Map(); + this.markedGoals = 0n; + this.goalCount = 0; + this.goalComplete = false; + this.linesComplete = 0; + this.exploredGoals = 0n; + } + + addPlayer(player: Player) { + this.players.set(player.id, player); + } + + removePlayer(id: string) { + this.players.delete(id); + } + + destroy() { + this.players.clear(); + } + + toClientData(): TeamData { + return { + id: this.id, + name: this.name, + color: this.color, + players: Array.from(this.players.values()).map((player) => + player.toClientData(), + ), + goalCount: this.goalCount + }; + } + + //#region Goal Tracking + mark(row: number, col: number) { + const mask = rowColToMask(row, col, this.room.board[0].length); + if ((this.markedGoals & mask) === 0n) { + this.markedGoals |= mask; + this.goalCount++; + if (this.room.exploration) { + this.exploredGoals = this.getRevealedMask(); + } + } + } + + unmark(row: number, col: number) { + const mask = rowColToMask(row, col, this.room.board[0].length); + if ((this.markedGoals & mask) !== 0n) { + this.markedGoals &= ~mask; + this.goalCount--; + if (this.room.exploration) { + this.exploredGoals = this.getRevealedMask(); + } + } + } + + hasMarked(row: number, col: number): boolean { + const mask = rowColToMask(row, col, this.room.board[0].length); + return (this.markedGoals & mask) !== 0n; + } + + hasRevealed(row: number, col: number): boolean { + const mask = rowColToMask(row, col, this.room.board[0].length); + return (this.exploredGoals & mask) !== 0n; + } + + getRevealedMask(): bigint { + return ( + computeRevealedMask( + this.markedGoals, + this.room.board[0].length, + this.room.board.length, + ) | this.room.alwaysRevealedMask + ); + } + + obfuscateBoard() { + this.exploredGoals = this.getRevealedMask(); + return this.room.board.map((row, rowIndex) => + row.map((cell, colIndex) => + this.hasRevealed(rowIndex, colIndex) + ? ({ + revealed: true, + goal: cell.goal, + completedTeams: cell.completedTeams, + } as RevealedCell) + : ({ + revealed: false, + completedTeams: cell.completedTeams, + } as HiddenCell), + ), + ); + } + + /** + * Checks if this player has completed a set of goals on the board + * + * @param mask The bitmask containing the goals to check for + */ + hasCompletedGoals(mask: bigint) { + return (this.markedGoals & mask) === mask; + } + //#endregion +} diff --git a/api/src/core/integration/races/LocalTimer.ts b/api/src/core/integration/races/LocalTimer.ts index e5061d20..5077a253 100644 --- a/api/src/core/integration/races/LocalTimer.ts +++ b/api/src/core/integration/races/LocalTimer.ts @@ -66,7 +66,7 @@ export default class LocalTimer implements RaceHandler { this.finishedAt = undefined; updateStartTime(this.room.id, null).then(); updateFinishTime(this.room.id, null).then(); - this.room.players.forEach((player) => { + this.room.getAllPlayers().forEach((player) => { player.finishedAt = undefined; createUpdatePlayer(this.room.id, player).then(); }); diff --git a/api/src/database/Rooms.ts b/api/src/database/Rooms.ts index e4906519..c5e28816 100644 --- a/api/src/database/Rooms.ts +++ b/api/src/database/Rooms.ts @@ -2,6 +2,7 @@ import { BingoMode, RaceHandler, RoomActionType } from '@prisma/client'; import { prisma } from './Database'; import { JsonObject } from '@prisma/client/runtime/library'; import Player from '../core/Player'; +import Team from '../core/Team'; export const createRoom = ( slug: string, @@ -15,6 +16,7 @@ export const createRoom = ( variant?: string, explorationStart?: string, seed?: number, + teamsEnabled: boolean = false, ) => { return prisma.room.create({ data: { @@ -30,6 +32,7 @@ export const createRoom = ( exploration: !!explorationStart, explorationStart, seed, + teamsEnabled, }, }); }; @@ -48,11 +51,11 @@ const addRoomAction = ( }); }; -export const addJoinAction = (room: string, nickname: string, color: string) => - addRoomAction(room, RoomActionType.JOIN, { nickname, color }); +export const addJoinAction = (room: string, nickname: string) => + addRoomAction(room, RoomActionType.JOIN, { nickname }); -export const addLeaveAction = (room: string, nickname: string, color: string) => - addRoomAction(room, RoomActionType.LEAVE, { nickname, color }); +export const addLeaveAction = (room: string, nickname: string) => + addRoomAction(room, RoomActionType.LEAVE, { nickname }); export const addMarkAction = ( room: string, @@ -71,18 +74,17 @@ export const addUnmarkAction = ( export const addChatAction = ( room: string, nickname: string, - color: string, message: string, -) => addRoomAction(room, RoomActionType.CHAT, { nickname, color, message }); +) => addRoomAction(room, RoomActionType.CHAT, { nickname, message }); export const addChangeColorAction = ( room: string, - nickname: string, + teamName: string, oldColor: string, newColor: string, ) => addRoomAction(room, RoomActionType.CHANGECOLOR, { - nickname, + teamName, oldColor, newColor, }); @@ -102,7 +104,7 @@ export const getAllRooms = () => { export const getRoomFromSlug = (slug: string) => { return prisma.room.findUnique({ where: { slug }, - include: { history: true, game: true, players: true }, + include: { history: true, game: true, players: true, teams: true }, }); }; @@ -123,28 +125,48 @@ export const createUpdatePlayer = async (room: string, player: Player) => { create: { key: player.id, nickname: player.nickname, - color: player.color, room: { connect: { id: room } }, user: player.userId ? { connect: { id: player.userId } } : undefined, - spectator: player.spectator, + team: player.teamId + ? { connect: { id: player.teamId } } + : undefined, + spectator: !player.teamId, monitor: player.monitor, finishedAt: player.finishedAt, }, update: { nickname: player.nickname, - color: player.color, user: player.userId ? { connect: { id: player.userId } } : { disconnect: true }, - spectator: player.spectator, monitor: player.monitor, + team: player.teamId + ? { connect: { id: player.teamId } } + : { disconnect: true }, + spectator: !player.teamId, finishedAt: player.finishedAt ?? null, }, }); }; +export const createUpdateTeam = async (room: string, team: Team) => { + return prisma.team.upsert({ + where: { id_roomId: { id: team.id, roomId: room } }, + create: { + key: team.id, + name: team.name, + color: team.color, + room: { connect: { id: room } }, + }, + update: { + name: team.name, + color: team.color, + }, + }); +}; + export const updateStartTime = async (room: string, startedAt: Date | null) => { return prisma.room.update({ where: { id: room }, @@ -170,3 +192,6 @@ export const updateRaceHandler = async ( data: { raceHandler }, }); }; + +export const updateTeamsEnabled = async (room: string, teamsEnabled: boolean) => + prisma.room.update({ where: { id: room }, data: { teamsEnabled } }); diff --git a/api/src/routes/rooms/Rooms.ts b/api/src/routes/rooms/Rooms.ts index 60b189bc..d452b200 100644 --- a/api/src/routes/rooms/Rooms.ts +++ b/api/src/routes/rooms/Rooms.ts @@ -32,6 +32,7 @@ import { GenerationFailedError } from '../../core/generation/GenerationFailedErr import RacetimeHandler from '../../core/integration/races/RacetimeHandler'; import LocalTimer from '../../core/integration/races/LocalTimer'; import { error } from 'console'; +import Team from "../../core/Team"; const MIN_ROOM_GOALS_REQUIRED = 25; const rooms = Router(); @@ -82,6 +83,7 @@ rooms.post('/', async (req, res) => { exploration, explorationStart, explorationStartCount, + teamsEnabled, } = req.body; const seed = req.body.seed ?? Math.ceil(999999 * Math.random()); @@ -191,6 +193,7 @@ rooms.post('/', async (req, res) => { : explorationStart : undefined, seed, + !!teamsEnabled, ); const room = new Room( name, @@ -214,6 +217,7 @@ rooms.post('/', async (req, res) => { : undefined, '', generatorSettings, + !!teamsEnabled, ); const options: BoardGenerationOptions = { mode: BoardGenerationMode.RANDOM, @@ -270,7 +274,7 @@ rooms.post('/', async (req, res) => { }); async function getOrLoadRoom(slug: string): Promise { - let room = allRooms.get(slug); + const room = allRooms.get(slug); if (room) return room; const dbRoom = await getRoomFromSlug(slug); @@ -324,13 +328,14 @@ async function getOrLoadRoom(slug: string): Promise { dbRoom.explorationStart ?? undefined, dbRoom.racetimeRoom ?? '', generatorSettings, + dbRoom.teamsEnabled, ); if (generatorSettings?.boardLayout.mode === 'custom') { newRoom.board = chunk( (await getGoalList(dbRoom.board)).map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), generatorSettings.boardLayout.layout[0].length, @@ -339,7 +344,7 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.board = chunk( (await getGoalList(dbRoom.board)).map((goal) => ({ goal: goal, - completedPlayers: [], + completedTeams: [], revealed: true, })), 5, @@ -347,18 +352,48 @@ async function getOrLoadRoom(slug: string): Promise { } newRoom.computeVictoryMasks(); + dbRoom.teams.forEach((dbTeam) => { + const team = new Team( + newRoom, + dbTeam.key, + dbTeam.name, + dbTeam.color, + ); + newRoom.teams.set(team.id, team); + }) + dbRoom.players.forEach((dbPlayer) => { + // Player is spectator, no need to add a team + if (!dbPlayer.teamId) { + const player = new Player( + newRoom, + dbPlayer.key, + dbPlayer.nickname, + dbPlayer.monitor, + newRoom.spectatorObfuscateBoard, + undefined, + dbPlayer.userId ?? undefined, + ); + player.finishedAt = dbPlayer.finishedAt?.toISOString(); + newRoom.spectators.set(player.id, player); + return; + } + // player is not spectator and is on a team + const team = newRoom.teams.get(dbPlayer.teamId); + if (!team) { + // This really shouldn't happen, this is mostly here for type safety + throw new Error(`Team for player ${dbPlayer.nickname} not found, please report this to a developer.`); + } const player = new Player( newRoom, dbPlayer.key, dbPlayer.nickname, - dbPlayer.color, - dbPlayer.spectator, dbPlayer.monitor, + team.obfuscateBoard, + team.id, dbPlayer.userId ?? undefined, - ); - player.finishedAt = dbPlayer.finishedAt?.toISOString(); - newRoom.players.set(player.id, player); + ) + team.players.set(player.id, player); }); dbRoom.history.forEach((action) => { @@ -373,7 +408,11 @@ async function getOrLoadRoom(slug: string): Promise { player: playerId, } = action.payload as any; - const player = newRoom.players.get(playerId)!; + const player = newRoom.getPlayerById(playerId)!; + let team: Team | undefined; + if (player.teamId) { + team = newRoom.teams.get(player.teamId) + } switch (action.action) { case 'JOIN': @@ -386,28 +425,34 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.sendChat([{ contents: nickname, color }, ' has left.']); break; case 'MARK': - if (!player.hasMarked(row, col)) { - newRoom.board[row][col].completedPlayers.push(playerId); - newRoom.board[row][col].completedPlayers.sort((a, b) => + if (!team) { + break; + } + if (!team.hasMarked(row, col)) { + newRoom.board[row][col].completedTeams.push(team.id); + newRoom.board[row][col].completedTeams.sort((a, b) => a.localeCompare(b), ); - player.mark(row, col); + team.mark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: team.name, color: team.color }, ` marked ${newRoom.board[row][col].goal.goal} (${row},${col})`, ]); } break; case 'UNMARK': - if (player.hasMarked(row, col)) { - newRoom.board[row][col].completedPlayers = newRoom.board[ + if (!team) { + break; + } + if (team.hasMarked(row, col)) { + newRoom.board[row][col].completedTeams = newRoom.board[ row - ][col].completedPlayers.filter((p) => p !== playerId); - player.unmark(row, col); + ][col].completedTeams.filter((teamId) => teamId !== team.id); + team.unmark(row, col); newRoom.sendCellUpdate(row, col); newRoom.sendChat([ - { contents: player.nickname, color: player.color }, + { contents: team.name, color: team.color }, ` unmarked ${newRoom.board[row][col].goal.goal} (${row},${col})`, ]); } @@ -416,6 +461,9 @@ async function getOrLoadRoom(slug: string): Promise { newRoom.sendChat(`${nickname}: ${message}`); break; case 'CHANGECOLOR': + if (team) { + team.color = newColor; + } newRoom.sendChat([ { contents: nickname, color: oldColor }, ' has changed their color to ', @@ -470,6 +518,7 @@ rooms.get('/:slug', async (req, res) => { mode: room.bingoMode, variant: room.variantName, chatEnabled: room.chatEnabled, + teamsEnabled: room.teamsEnabled, }; const userKey = req.session.user ?? req.session.id; diff --git a/api/src/tests/core/Player.test.ts b/api/src/tests/core/Player.test.ts deleted file mode 100644 index 41da1a5a..00000000 --- a/api/src/tests/core/Player.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { mock } from 'jest-mock-extended'; -import Player from '../../core/Player'; -import Room from '../../core/Room'; -import { RevealedCell } from '@playbingo/types'; - -const room = mock(); -room.board = [Array(5).fill(mock()), [], [], [], []]; - -const createPlayer = () => - new Player(room, 'test', 'Test Player', 'blue', false, false); - -describe('Goal Tracking', () => { - beforeEach(() => { - room.exploration = false; - }); - - it('Correctly marks unmarked cells', () => { - const player = createPlayer(); - player.mark(0, 0); - expect(player.markedGoals).toEqual(1n); - expect(player.goalCount).toEqual(1); - player.mark(0, 4); - expect(player.markedGoals).toEqual(BigInt(0b10001)); - expect(player.goalCount).toEqual(2); - player.mark(0, 3); - player.mark(1, 4); - expect(player.markedGoals).toEqual(BigInt(0b1000011001)); - expect(player.goalCount).toEqual(4); - }); - - it("Doesn't change marked cells when marking a cell that is already marked", () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 2); - const original = player.markedGoals; - player.mark(1, 2); - expect(player.markedGoals).toEqual(original); - expect(player.goalCount).toEqual(2); - }); - - it('Correctly unmarks marked cells', () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 2); - player.mark(1, 4); - player.mark(2, 2); - player.mark(3, 0); - player.mark(3, 2); - player.unmark(2, 2); - expect(player.markedGoals).toEqual(BigInt(0b101000001010000001)); - expect(player.goalCount).toEqual(5); - player.unmark(3, 2); - player.unmark(1, 4); - expect(player.goalCount).toEqual(3); - expect(player.markedGoals).toEqual(BigInt(0b1000000010000001)); - }); - - it("Doesn't change marked cells when unmarking a cell that is not marked", () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(1, 3); - const original = player.markedGoals; - player.unmark(3, 0); - expect(player.markedGoals).toEqual(original); - expect(player.goalCount).toEqual(2); - }); - - it('Correctly tells if a cell is marked', () => { - const player = createPlayer(); - const toMark = [3, 7, 9, 16, 21]; - const unmarked = Array.from(Array(25), (_, index) => index).filter( - (index) => !toMark.includes(index), - ); - toMark.forEach((index) => - player.mark(index % 5, Math.floor(index / 5)), - ); - toMark.forEach((index) => - expect( - player.hasMarked(index % 5, Math.floor(index / 5)), - ).toBeTruthy(), - ); - unmarked.forEach((index) => - expect( - player.hasMarked(index % 5, Math.floor(index / 5)), - ).toBeFalsy(), - ); - }); - - it('Correctly determines if a set of goals is marked', () => { - const player = createPlayer(); - player.mark(0, 0); - player.mark(0, 1); - player.mark(0, 2); - player.mark(0, 3); - player.mark(0, 4); - player.mark(1, 0); - player.mark(2, 0); - player.mark(3, 0); - player.mark(4, 0); - const row1Mask = BigInt(0b11111); - const row2Mask = BigInt(0b1111100000); - const col1Mask = BigInt(0b0000100001000010000100001); - expect(player.hasCompletedGoals(row1Mask)).toBeTruthy(); - expect(player.hasCompletedGoals(col1Mask)).toBeTruthy(); - expect(player.hasCompletedGoals(row2Mask)).toBeFalsy(); - }); -}); - -describe('Exploration', () => { - beforeEach(() => { - room.exploration = true; - room.alwaysRevealedMask = 1n; - }); - - it('Correctly reveals cells when marking with exploration enabled', () => { - const player = createPlayer(); - player.room.exploration = true; - player.mark(2, 2); - expect(player.hasRevealed(1, 2)).toBeTruthy(); - expect(player.hasRevealed(3, 2)).toBeTruthy(); - expect(player.hasRevealed(2, 1)).toBeTruthy(); - expect(player.hasRevealed(2, 3)).toBeTruthy(); - }); - - it('Correctly hides cells when marking with exploration enabled', () => { - const player = createPlayer(); - player.room.exploration = true; - player.mark(2, 2); - player.unmark(2, 2); - expect(player.hasRevealed(1, 2)).toBeFalsy(); - expect(player.hasRevealed(3, 2)).toBeFalsy(); - expect(player.hasRevealed(2, 1)).toBeFalsy(); - expect(player.hasRevealed(2, 3)).toBeFalsy(); - }); -}); diff --git a/api/src/tests/core/RoomTeams.test.ts b/api/src/tests/core/RoomTeams.test.ts new file mode 100644 index 00000000..1413c7fe --- /dev/null +++ b/api/src/tests/core/RoomTeams.test.ts @@ -0,0 +1,177 @@ +import { BingoMode } from '@prisma/client'; +import { + ChangeColorAction, + JoinAction, + JoinTeamAction, + MarkAction, + RevealedCell, + SetTeamsEnabledAction, + UnmarkAction, +} from '@playbingo/types'; +import { WebSocket } from 'ws'; +import { RoomTokenPayload } from '../../auth/RoomAuth'; +import Room from '../../core/Room'; +import { + createUpdatePlayer, + createUpdateTeam, + updateTeamsEnabled, +} from '../../database/Rooms'; + +jest.mock('../../database/Rooms', () => ({ + addChangeColorAction: jest.fn().mockResolvedValue(undefined), + addChatAction: jest.fn().mockResolvedValue(undefined), + addJoinAction: jest.fn().mockResolvedValue(undefined), + addLeaveAction: jest.fn().mockResolvedValue(undefined), + addMarkAction: jest.fn().mockResolvedValue(undefined), + addUnmarkAction: jest.fn().mockResolvedValue(undefined), + createUpdatePlayer: jest.fn().mockResolvedValue(undefined), + createUpdateTeam: jest.fn().mockResolvedValue(undefined), + setRoomBoard: jest.fn().mockResolvedValue(undefined), + updateRaceHandler: jest.fn().mockResolvedValue(undefined), + updateTeamsEnabled: jest.fn().mockResolvedValue(undefined), +})); + +const createRoom = () => { + const room = new Room( + 'Room', + 'Game', + 'game', + 'room', + '', + 'room-id', + false, + BingoMode.LINES, + 1, + false, + 'Normal', + 1, + ); + room.board = Array.from({ length: 5 }, (_, row) => + Array.from( + { length: 5 }, + (_, col) => + ({ + goal: { + id: `${row}-${col}`, + goal: 'Goal', + description: null, + }, + completedTeams: [], + revealed: true, + }) as RevealedCell, + ), + ); + room.computeVictoryMasks(); + return room; +}; + +const auth = (playerId: string, isSpectating = false): RoomTokenPayload => ({ + roomSlug: 'room', + uuid: `${playerId}-connection`, + playerId, + isSpectating, + isMonitor: true, +}); + +const socket = () => ({ readyState: 0, send: jest.fn() }) as unknown as WebSocket; + +const joinAction = (nickname: string) => + ({ action: 'join', payload: { nickname } }) as JoinAction; + +describe('Room team workflows', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates a team for a joining player and exposes team-owned state', () => { + const room = createRoom(); + const result = room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + + expect(result.action).toBe('connected'); + expect(room.teams.size).toBe(1); + expect(result).toMatchObject({ + roomData: { teamsEnabled: false }, + }); + const team = room.getTeamForPlayer('alice'); + expect(team).toMatchObject({ name: 'Team Alice', color: 'blue' }); + expect(room.getPlayerById('alice')?.teamId).toBe(team?.id); + expect(createUpdateTeam).toHaveBeenCalledWith('room-id', team); + expect(createUpdatePlayer).toHaveBeenCalledWith( + 'room-id', + room.getPlayerById('alice'), + ); + expect(room.chatHistory).toContainEqual([ + { contents: 'Alice', color: 'blue' }, + ' has joined.', + ]); + }); + + it('tracks marks by team ID and applies the team color', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + const team = room.getTeamForPlayer('alice')!; + + room.handleMark( + { action: 'mark', payload: { row: 0, col: 0 } } as MarkAction, + auth('alice'), + ); + expect(room.board[0][0].completedTeams).toEqual([team.id]); + expect(team.hasMarked(0, 0)).toBe(true); + + room.handleUnmark( + { action: 'unmark', payload: { row: 0, col: 0 } } as UnmarkAction, + auth('alice'), + ); + expect(room.board[0][0].completedTeams).toEqual([]); + expect(team.hasMarked(0, 0)).toBe(false); + + room.handleChangeColor( + { action: 'changeColor', payload: { color: 'red' } } as ChangeColorAction, + auth('alice'), + ); + expect(team.color).toBe('red'); + expect(createUpdateTeam).toHaveBeenLastCalledWith('room-id', team); + }); + + it('permits joining another team only when teams are enabled', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + room.handleJoin(joinAction('Bob'), auth('bob'), socket()); + const aliceTeam = room.getTeamForPlayer('alice')!; + + const joinTeam = { + action: 'joinTeam', + payload: { teamId: aliceTeam.id }, + } as JoinTeamAction; + expect(room.handleJoinTeam(joinTeam, auth('bob'))).toEqual({ + action: 'forbidden', + }); + + room.handleSetTeamsEnabled( + { + action: 'setTeamsEnabled', + payload: { enabled: true }, + } as SetTeamsEnabledAction, + ); + expect(updateTeamsEnabled).toHaveBeenCalledWith('room-id', true); + expect(room.handleJoinTeam(joinTeam, auth('bob'))?.action).toBe( + 'joinedTeam', + ); + expect(room.getTeamForPlayer('bob')).toBe(aliceTeam); + }); + + it('uses player names for single-player rooms and team names when enabled', () => { + const room = createRoom(); + room.handleJoin(joinAction('Alice'), auth('alice'), socket()); + const team = room.getTeamForPlayer('alice')!; + + expect(room.getTeamDisplayName(team)).toBe('Alice'); + room.handleSetTeamsEnabled( + { + action: 'setTeamsEnabled', + payload: { enabled: true }, + } as SetTeamsEnabledAction, + ); + expect(room.getTeamDisplayName(team)).toBe('Team Alice'); + }); +}); \ No newline at end of file diff --git a/api/src/tests/core/TeamPlayer.test.ts b/api/src/tests/core/TeamPlayer.test.ts new file mode 100644 index 00000000..b1bfedb3 --- /dev/null +++ b/api/src/tests/core/TeamPlayer.test.ts @@ -0,0 +1,153 @@ +import { mock } from 'jest-mock-extended'; +import Player from '../../core/Player'; +import Room from '../../core/Room'; +import { RevealedCell } from '@playbingo/types'; +import Team from '../../core/Team'; + +const room = mock(); +room.board = [Array(5).fill(mock()), [], [], [], []]; + +const createTeam = () => new Team(room, 'test', 'Test Team'); + +const createPlayer = (team?: Team) => + new Player( + room, + 'test', + 'Test Player', + false, + team ? team.obfuscateBoard : room.spectatorObfuscateBoard, + team?.id, + ); + +describe('Goal Tracking', () => { + beforeEach(() => { + room.exploration = false; + }); + + it('Correctly marks unmarked cells', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + expect(team.markedGoals).toEqual(1n); + expect(team.goalCount).toEqual(1); + team.mark(0, 4); + expect(team.markedGoals).toEqual(BigInt(0b10001)); + expect(team.goalCount).toEqual(2); + team.mark(0, 3); + team.mark(1, 4); + expect(team.markedGoals).toEqual(BigInt(0b1000011001)); + expect(team.goalCount).toEqual(4); + }); + + it("Doesn't change marked cells when marking a cell that is already marked", () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 2); + const original = team.markedGoals; + team.mark(1, 2); + expect(team.markedGoals).toEqual(original); + expect(team.goalCount).toEqual(2); + }); + + it('Correctly unmarks marked cells', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 2); + team.mark(1, 4); + team.mark(2, 2); + team.mark(3, 0); + team.mark(3, 2); + team.unmark(2, 2); + expect(team.markedGoals).toEqual(BigInt(0b101000001010000001)); + expect(team.goalCount).toEqual(5); + team.unmark(3, 2); + team.unmark(1, 4); + expect(team.goalCount).toEqual(3); + expect(team.markedGoals).toEqual(BigInt(0b1000000010000001)); + }); + + it("Doesn't change marked cells when unmarking a cell that is not marked", () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(1, 3); + const original = team.markedGoals; + team.unmark(3, 0); + expect(team.markedGoals).toEqual(original); + expect(team.goalCount).toEqual(2); + }); + + it('Correctly tells if a cell is marked', () => { + const team = createTeam(); + const player = createPlayer(team); + const toMark = [3, 7, 9, 16, 21]; + const unmarked = Array.from(Array(25), (_, index) => index).filter( + (index) => !toMark.includes(index), + ); + toMark.forEach((index) => + team.mark(index % 5, Math.floor(index / 5)), + ); + toMark.forEach((index) => + expect( + team.hasMarked(index % 5, Math.floor(index / 5)), + ).toBeTruthy(), + ); + unmarked.forEach((index) => + expect( + team.hasMarked(index % 5, Math.floor(index / 5)), + ).toBeFalsy(), + ); + }); + + it('Correctly determines if a set of goals is marked', () => { + const team = createTeam(); + const player = createPlayer(team); + team.mark(0, 0); + team.mark(0, 1); + team.mark(0, 2); + team.mark(0, 3); + team.mark(0, 4); + team.mark(1, 0); + team.mark(2, 0); + team.mark(3, 0); + team.mark(4, 0); + const row1Mask = BigInt(0b11111); + const row2Mask = BigInt(0b1111100000); + const col1Mask = BigInt(0b0000100001000010000100001); + expect(team.hasCompletedGoals(row1Mask)).toBeTruthy(); + expect(team.hasCompletedGoals(col1Mask)).toBeTruthy(); + expect(team.hasCompletedGoals(row2Mask)).toBeFalsy(); + }); +}); + +describe('Exploration', () => { + beforeEach(() => { + room.exploration = true; + room.alwaysRevealedMask = 1n; + }); + + it('Correctly reveals cells when marking with exploration enabled', () => { + const team = createTeam(); + const player = createPlayer(team); + team.room.exploration = true; + team.mark(2, 2); + expect(team.hasRevealed(1, 2)).toBeTruthy(); + expect(team.hasRevealed(3, 2)).toBeTruthy(); + expect(team.hasRevealed(2, 1)).toBeTruthy(); + expect(team.hasRevealed(2, 3)).toBeTruthy(); + }); + + it('Correctly hides cells when marking with exploration enabled', () => { + const team = createTeam(); + team.room.exploration = true; + team.mark(2, 2); + team.unmark(2, 2); + expect(team.hasRevealed(1, 2)).toBeFalsy(); + expect(team.hasRevealed(3, 2)).toBeFalsy(); + expect(team.hasRevealed(2, 1)).toBeFalsy(); + expect(team.hasRevealed(2, 3)).toBeFalsy(); + expect(team.hasRevealed(2, 3)).toBeFalsy(); + }); +}); diff --git a/api/src/tests/util/WinDetection.test.ts b/api/src/tests/util/WinDetection.test.ts index 92bcc431..ddf3999d 100644 --- a/api/src/tests/util/WinDetection.test.ts +++ b/api/src/tests/util/WinDetection.test.ts @@ -23,7 +23,7 @@ const boardToBitset = (board: Cell[][], color: string) => { let bitset = 0n; board.forEach((row, rowIndex) => row.forEach((cell, colIndex) => { - if (cell.completedPlayers.includes(color)) { + if (cell.completedTeams.includes(color)) { bitset |= 1n << BigInt(rowIndex * board.length + colIndex); } }), @@ -109,27 +109,27 @@ describe('Win Conditions', () => { it('Correctly detects single rows', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['blue']; - board[0][4].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['blue']; + board[0][4].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects single rows with additional values on the board', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'red']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['red', 'blue', 'green']; - board[0][4].completedPlayers = ['blue']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; + board[0][0].completedTeams = ['blue', 'red']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['red', 'blue', 'green']; + board[0][4].completedTeams = ['blue']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -137,20 +137,20 @@ describe('Win Conditions', () => { it('Correctly detects multiple rows', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'red']; - board[0][1].completedPlayers = ['blue']; - board[0][2].completedPlayers = ['blue']; - board[0][3].completedPlayers = ['red', 'blue', 'green']; - board[0][4].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['red', 'blue']; - board[1][1].completedPlayers = ['blue', 'red']; - board[1][2].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; + board[0][0].completedTeams = ['blue', 'red']; + board[0][1].completedTeams = ['blue']; + board[0][2].completedTeams = ['blue']; + board[0][3].completedTeams = ['red', 'blue', 'green']; + board[0][4].completedTeams = ['blue']; + board[1][0].completedTeams = ['red', 'blue']; + board[1][1].completedTeams = ['blue', 'red']; + board[1][2].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -158,27 +158,27 @@ describe('Win Conditions', () => { it('Correctly detects single columns', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue']; - board[2][0].completedPlayers = ['blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue']; + board[2][0].completedTeams = ['blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects single column with additional values on the board', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue', 'red']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; - board[4][3].completedPlayers = ['red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[1][4].completedPlayers = ['blue', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue', 'red']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; + board[4][3].completedTeams = ['red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[1][4].completedTeams = ['blue', 'green']; + board[2][2].completedTeams = ['green', 'blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -186,19 +186,19 @@ describe('Win Conditions', () => { it('Correctly detects multiple columns', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][0].completedPlayers = ['blue', 'red']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'red', 'green']; - board[0][3].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue', 'red']; - board[2][3].completedPlayers = ['green', 'blue']; - board[3][3].completedPlayers = ['blue']; - board[4][3].completedPlayers = ['blue', 'red', 'green']; - board[2][2].completedPlayers = ['green', 'blue']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red']; + board[0][0].completedTeams = ['blue']; + board[1][0].completedTeams = ['blue', 'red']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'red', 'green']; + board[0][3].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue', 'red']; + board[2][3].completedTeams = ['green', 'blue']; + board[3][3].completedTeams = ['blue']; + board[4][3].completedTeams = ['blue', 'red', 'green']; + board[2][2].completedTeams = ['green', 'blue']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(0); @@ -206,53 +206,53 @@ describe('Win Conditions', () => { it('Correctly detects the main diagonal', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue']; - board[1][1].completedPlayers = ['blue']; - board[2][2].completedPlayers = ['blue']; - board[3][3].completedPlayers = ['blue']; - board[4][4].completedPlayers = ['blue']; + board[0][0].completedTeams = ['blue']; + board[1][1].completedTeams = ['blue']; + board[2][2].completedTeams = ['blue']; + board[3][3].completedTeams = ['blue']; + board[4][4].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects the antiDiagonal', () => { const board = createBoard(); - board[0][4].completedPlayers = ['blue']; - board[1][3].completedPlayers = ['blue']; - board[2][2].completedPlayers = ['blue']; - board[3][1].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue']; + board[0][4].completedTeams = ['blue']; + board[1][3].completedTeams = ['blue']; + board[2][2].completedTeams = ['blue']; + board[3][1].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue']; expect(countLines(board, 'blue')).toEqual(1); expect(countLines(board, 'red')).toEqual(0); }); it('Correctly detects mixed lines and colors', () => { const board = createBoard(); - board[0][0].completedPlayers = ['blue', 'green']; - board[0][1].completedPlayers = ['blue', 'red', 'green']; - board[0][2].completedPlayers = ['green', 'blue']; - board[0][3].completedPlayers = ['blue', 'green']; - board[0][4].completedPlayers = [ + board[0][0].completedTeams = ['blue', 'green']; + board[0][1].completedTeams = ['blue', 'red', 'green']; + board[0][2].completedTeams = ['green', 'blue']; + board[0][3].completedTeams = ['blue', 'green']; + board[0][4].completedTeams = [ 'blue', 'red', 'green', 'yellow', 'orange', ]; - board[1][0].completedPlayers = ['blue', 'red', 'orange']; - board[2][0].completedPlayers = ['green', 'blue']; - board[3][0].completedPlayers = ['blue']; - board[4][0].completedPlayers = ['blue', 'yellow', 'green', 'orange']; - board[1][1].completedPlayers = ['blue', 'red', 'green']; - board[2][2].completedPlayers = ['green']; - board[3][3].completedPlayers = ['red', 'green']; - board[4][4].completedPlayers = ['blue', 'red', 'green']; - board[1][3].completedPlayers = ['blue', 'red']; - board[2][3].completedPlayers = ['green', 'blue']; - board[4][3].completedPlayers = ['blue', 'red', 'green']; - board[3][1].completedPlayers = ['blue', 'red', 'green']; - board[4][2].completedPlayers = ['red', 'yellow']; - board[4][3].completedPlayers = ['red', 'green', 'orange']; + board[1][0].completedTeams = ['blue', 'red', 'orange']; + board[2][0].completedTeams = ['green', 'blue']; + board[3][0].completedTeams = ['blue']; + board[4][0].completedTeams = ['blue', 'yellow', 'green', 'orange']; + board[1][1].completedTeams = ['blue', 'red', 'green']; + board[2][2].completedTeams = ['green']; + board[3][3].completedTeams = ['red', 'green']; + board[4][4].completedTeams = ['blue', 'red', 'green']; + board[1][3].completedTeams = ['blue', 'red']; + board[2][3].completedTeams = ['green', 'blue']; + board[4][3].completedTeams = ['blue', 'red', 'green']; + board[3][1].completedTeams = ['blue', 'red', 'green']; + board[4][2].completedTeams = ['red', 'yellow']; + board[4][3].completedTeams = ['red', 'green', 'orange']; expect(countLines(board, 'blue')).toEqual(2); expect(countLines(board, 'red')).toEqual(0); expect(countLines(board, 'green')).toEqual(2); diff --git a/api/src/util/RoomUtils.ts b/api/src/util/RoomUtils.ts index 108f3b6b..6166f5f5 100644 --- a/api/src/util/RoomUtils.ts +++ b/api/src/util/RoomUtils.ts @@ -10,7 +10,7 @@ export const listToBoard = ( return chunk( list.map((g) => ({ goal: g, - completedPlayers: [], + completedTeams: [], revealed: true, })), length, diff --git a/schema/index.d.ts b/schema/index.d.ts index f442ad54..3bc40be4 100644 --- a/schema/index.d.ts +++ b/schema/index.d.ts @@ -10,4 +10,5 @@ export * from './types/Player'; export * from './types/RoomAction'; export * from './types/RoomData'; export * from './types/ServerMessage'; +export * from './types/Team'; export * from './types/User'; diff --git a/schema/package-lock.json b/schema/package-lock.json index a76450c2..4e6456c8 100644 --- a/schema/package-lock.json +++ b/schema/package-lock.json @@ -570,6 +570,7 @@ "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -800,6 +801,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -918,6 +920,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/schema/schemas/Cell.json b/schema/schemas/Cell.json index b6a94909..5a9ee64f 100644 --- a/schema/schemas/Cell.json +++ b/schema/schemas/Cell.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "additionalProperties": false, - "required": ["goal", "description", "completedPlayers"], + "required": ["goal", "description", "completedTeams"], "anyOf": [ {"$ref": "#/$defs/RevealedCell"}, {"$ref": "#/$defs/HiddenCell"} @@ -10,19 +10,19 @@ "$defs": { "RevealedCell": { "additionalProperties": false, - "required": ["revealed", "goal", "completedPlayers"], + "required": ["revealed", "goal", "completedTeams"], "properties": { "goal": {"$ref": "./Goal.json"}, - "completedPlayers": {"type": "array", "items": {"type": "string"}}, + "completedTeams": {"type": "array", "items": {"type": "string"}}, "revealed": { "enum": [ true ]} } }, "HiddenCell": { "additionalProperties": false, - "required": ["revealed", "completedPlayers"], + "required": ["revealed", "completedTeams"], "properties": { "revealed": { "enum": [ false ]}, - "completedPlayers": {"type": "array", "items": {"type": "string"}} + "completedTeams": {"type": "array", "items": {"type": "string"}} } } } diff --git a/schema/schemas/Player.json b/schema/schemas/Player.json index f1c83918..9b0bb218 100644 --- a/schema/schemas/Player.json +++ b/schema/schemas/Player.json @@ -5,12 +5,10 @@ "required": [ "id", "nickname", - "color", - "goalCount", "raceStatus", - "spectator", "monitor", - "showInRoom" + "showInRoom", + "teamId" ], "properties": { "id": { @@ -19,12 +17,6 @@ "nickname": { "type": "string" }, - "color": { - "type": "string" - }, - "goalCount": { - "type": "number" - }, "raceStatus": { "oneOf": [ { @@ -35,14 +27,14 @@ } ] }, - "spectator": { - "type": "boolean" - }, "monitor": { "type": "boolean" }, "showInRoom": { "type": "boolean" + }, + "teamId": { + "type": "string" } }, "$defs": { diff --git a/schema/schemas/RoomAction.json b/schema/schemas/RoomAction.json index b4253362..174cdace 100644 --- a/schema/schemas/RoomAction.json +++ b/schema/schemas/RoomAction.json @@ -10,6 +10,7 @@ "anyOf": [ {"$ref": "#/$defs/JoinAction"}, {"$ref": "#/$defs/LeaveAction"}, + {"$ref": "#/$defs/JoinTeamAction"}, {"$ref": "#/$defs/ChatAction"}, {"$ref": "#/$defs/MarkAction"}, {"$ref": "#/$defs/UnmarkAction"}, @@ -20,7 +21,8 @@ {"$ref": "#/$defs/StartTimerAction"}, {"$ref": "#/$defs/ChangeRaceHandlerAction"}, {"$ref": "#/$defs/ResetTimerAction"}, - {"$ref": "#/$defs/SetChatEnabledAction"} + {"$ref": "#/$defs/SetChatEnabledAction"}, + {"$ref": "#/$defs/SetTeamsEnabledAction"} ], "$defs": { "JoinAction": { @@ -44,6 +46,20 @@ "action": "leave" } }, + "JoinTeamAction": { + "required": ["action", "payload"], + "additionalProperties": false, + "properties": { + "action": "joinTeam", + "payload": { + "required": ["teamId"], + "additionalProperties": false, + "properties": { + "teamId": {"type": "string"} + } + } + } + }, "ChatAction": { "required": ["action", "payload"], "additionalProperties": false, @@ -174,6 +190,20 @@ } } } + }, + "SetTeamsEnabledAction": { + "required": ["action", "payload"], + "additionalProperties": false, + "properties": { + "action": "setTeamsEnabled", + "payload": { + "required": ["enabled"], + "additionalProperties": false, + "properties": { + "enabled": {"type": "boolean"} + } + } + } } } } \ No newline at end of file diff --git a/schema/schemas/RoomData.json b/schema/schemas/RoomData.json index fd7b770b..7812157d 100644 --- a/schema/schemas/RoomData.json +++ b/schema/schemas/RoomData.json @@ -10,7 +10,8 @@ "newGenerator", "variant", "mode", - "seed" + "seed", + "teamsEnabled" ], "description": "Basic information about a room", "properties": { @@ -45,6 +46,9 @@ "seed": { "type": "number" }, + "teamsEnabled": { + "type": "boolean" + }, "startedAt": { "type": "string" }, diff --git a/schema/schemas/ServerMessage.json b/schema/schemas/ServerMessage.json index 280b350d..acca85db 100644 --- a/schema/schemas/ServerMessage.json +++ b/schema/schemas/ServerMessage.json @@ -6,9 +6,20 @@ "description": "An incoming websocket message from the server telling the client of a change in room state or instructing it to take an action", "properties": { "players": { - "type": "array", - "items": { - "$ref": "./Player.json" + "type": "object", + "additionalProperties": false, + "required": ["teams", "spectators"], + "properties": { + "teams": { + "items": { + "$ref": "./Team.json" + } + }, + "spectators": { + "items": { + "$ref": "./Player.json" + } + } } }, "connectedPlayer": { @@ -29,6 +40,19 @@ } } }, + { + "required": [ + "action", + "team" + ], + "additionalProperties": false, + "properties": { + "action": "joinedTeam", + "team": { + "$ref": "./Team.json" + } + } + }, { "required": [ "action", @@ -133,9 +157,20 @@ "properties": { "action": "syncRaceData", "players": { - "type": "array", - "items": { - "$ref": "./Player.json" + "type": "object", + "additionalProperties": false, + "required": ["teams", "spectators"], + "properties": { + "teams": { + "items": { + "$ref": "./Team.json" + } + }, + "spectators": { + "items": { + "$ref": "./Player.json" + } + } } }, "racetimeConnection": { diff --git a/schema/schemas/Team.json b/schema/schemas/Team.json new file mode 100644 index 00000000..03a48144 --- /dev/null +++ b/schema/schemas/Team.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "color", + "goalCount", + "players" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "goalCount": { + "type": "number" + }, + "players": { + "type": "array", + "items": { + "$ref": "./Player.json" + } + } + } +} \ No newline at end of file diff --git a/schema/types/Board.d.ts b/schema/types/Board.d.ts index b2fa3bed..31d18c6a 100644 --- a/schema/types/Board.d.ts +++ b/schema/types/Board.d.ts @@ -16,7 +16,7 @@ export interface RevealedBoard { } export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -55,7 +55,7 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } export interface HiddenBoard { hidden: true; diff --git a/schema/types/Cell.d.ts b/schema/types/Cell.d.ts index 3a48cf41..8f35052b 100644 --- a/schema/types/Cell.d.ts +++ b/schema/types/Cell.d.ts @@ -9,7 +9,7 @@ export type Cell = RevealedCell | HiddenCell; export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -48,5 +48,5 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } diff --git a/schema/types/Player.d.ts b/schema/types/Player.d.ts index e560db70..8530bae0 100644 --- a/schema/types/Player.d.ts +++ b/schema/types/Player.d.ts @@ -8,12 +8,10 @@ export interface Player { id: string; nickname: string; - color: string; - goalCount: number; raceStatus: RaceStatusDisconnected | RaceStatusConnected; - spectator: boolean; monitor: boolean; showInRoom: boolean; + teamId: string; } export interface RaceStatusDisconnected { connected: false; diff --git a/schema/types/RoomAction.d.ts b/schema/types/RoomAction.d.ts index 0566e324..5bb6753a 100644 --- a/schema/types/RoomAction.d.ts +++ b/schema/types/RoomAction.d.ts @@ -11,6 +11,7 @@ export type RoomAction = ( | JoinAction | LeaveAction + | JoinTeamAction | ChatAction | MarkAction | UnmarkAction @@ -22,6 +23,7 @@ export type RoomAction = ( | ChangeRaceHandlerAction | ResetTimerAction | SetChatEnabledAction + | SetTeamsEnabledAction ) & { /** * JWT for the room obtained from the server @@ -38,6 +40,12 @@ export interface JoinAction { export interface LeaveAction { action: "leave"; } +export interface JoinTeamAction { + action: "joinTeam"; + payload: { + teamId: string; + }; +} export interface ChatAction { action: "chat"; payload: { @@ -97,3 +105,9 @@ export interface SetChatEnabledAction { enabled: boolean; }; } +export interface SetTeamsEnabledAction { + action: "setTeamsEnabled"; + payload: { + enabled: boolean; + }; +} diff --git a/schema/types/RoomData.d.ts b/schema/types/RoomData.d.ts index 7e4352e4..e1f6a59d 100644 --- a/schema/types/RoomData.d.ts +++ b/schema/types/RoomData.d.ts @@ -22,6 +22,7 @@ export interface RoomData { variant: string; mode: string; seed: number; + teamsEnabled: boolean; startedAt?: string; finishedAt?: string; raceHandler?: "LOCAL" | "RACETIME"; diff --git a/schema/types/ServerMessage.d.ts b/schema/types/ServerMessage.d.ts index 00beadc0..15395589 100644 --- a/schema/types/ServerMessage.d.ts +++ b/schema/types/ServerMessage.d.ts @@ -13,6 +13,10 @@ export type ServerMessage = ( action: "chat"; message: ChatMessage; } + | { + action: "joinedTeam"; + team: Team; + } | { action: "cellUpdate"; row: number; @@ -42,7 +46,10 @@ export type ServerMessage = ( } | { action: "syncRaceData"; - players: Player[]; + players: { + teams: Team[]; + spectators: Player[]; + }; racetimeConnection: RacetimeConnection; } | { @@ -57,7 +64,10 @@ export type ServerMessage = ( startTime: string; } ) & { - players?: Player[]; + players?: { + teams: Team[]; + spectators: Player[]; + }; connectedPlayer?: Player; }; export type ChatMessage = ( @@ -70,9 +80,39 @@ export type ChatMessage = ( export type Cell = RevealedCell | HiddenCell; export type Board = RevealedBoard | HiddenBoard; +export interface Team { + id: string; + name: string; + color: string; + goalCount: number; + players: Player[]; +} +export interface Player { + id: string; + nickname: string; + raceStatus: RaceStatusDisconnected | RaceStatusConnected; + monitor: boolean; + showInRoom: boolean; + teamId: string; +} +export interface RaceStatusDisconnected { + connected: false; +} +export interface RaceStatusConnected { + connected: true; + /** + * Username connected to this player for the race, if it is separate from PlayBingo + */ + username: string; + ready?: boolean; + /** + * Race finish time (ISO 8601 duration) + */ + finishTime?: string; +} export interface RevealedCell { goal: Goal; - completedPlayers: string[]; + completedTeams: string[]; revealed: true; } /** @@ -111,7 +151,7 @@ export interface GoalTag { } export interface HiddenCell { revealed: false; - completedPlayers: string[]; + completedTeams: string[]; } export interface RevealedBoard { board: Cell[][]; @@ -141,6 +181,7 @@ export interface RoomData { variant: string; mode: string; seed: number; + teamsEnabled: boolean; startedAt?: string; finishedAt?: string; raceHandler?: "LOCAL" | "RACETIME"; @@ -168,28 +209,3 @@ export interface RacetimeConnection { */ startDelay?: string; } -export interface Player { - id: string; - nickname: string; - color: string; - goalCount: number; - raceStatus: RaceStatusDisconnected | RaceStatusConnected; - spectator: boolean; - monitor: boolean; - showInRoom: boolean; -} -export interface RaceStatusDisconnected { - connected: false; -} -export interface RaceStatusConnected { - connected: true; - /** - * Username connected to this player for the race, if it is separate from PlayBingo - */ - username: string; - ready?: boolean; - /** - * Race finish time (ISO 8601 duration) - */ - finishTime?: string; -} diff --git a/schema/types/Team.d.ts b/schema/types/Team.d.ts new file mode 100644 index 00000000..50f82289 --- /dev/null +++ b/schema/types/Team.d.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface Team { + id: string; + name: string; + color: string; + goalCount: number; + players: Player[]; +} +export interface Player { + id: string; + nickname: string; + raceStatus: RaceStatusDisconnected | RaceStatusConnected; + monitor: boolean; + showInRoom: boolean; + teamId: string; +} +export interface RaceStatusDisconnected { + connected: false; +} +export interface RaceStatusConnected { + connected: true; + /** + * Username connected to this player for the race, if it is separate from PlayBingo + */ + username: string; + ready?: boolean; + /** + * Race finish time (ISO 8601 duration) + */ + finishTime?: string; +}