├── src ├── react-app-env.d.ts ├── state │ ├── RandomSpectrumTarget.ts │ ├── GetScore.ts │ ├── RandomFourCharacterString.ts │ ├── GameModelContext.ts │ ├── NewGame.ts │ ├── NewRound.ts │ ├── ScoreRound.ts │ ├── BuildGameModel.ts │ └── GameState.ts ├── style.css ├── setupTests.ts ├── TrackEvent.ts ├── components │ ├── common │ │ ├── GetContrastingColors.ts │ │ ├── Button.tsx │ │ ├── Info.tsx │ │ ├── Animate.tsx │ │ ├── Title.tsx │ │ ├── LayoutElements.tsx │ │ ├── GetContrastingText.ts │ │ ├── CommonFooter.tsx │ │ ├── RoomIdHeader.tsx │ │ ├── LandingPage.tsx │ │ └── Spectrum.tsx │ ├── gameplay │ │ ├── i18nForTests.ts │ │ ├── InputName.tsx │ │ ├── TestContext.tsx │ │ ├── ActiveGame.tsx │ │ ├── PreviousTurnResult.tsx │ │ ├── SetupGame.tsx │ │ ├── MakeGuess.test.tsx │ │ ├── CounterGuess.tsx │ │ ├── FakeRooms.tsx │ │ ├── GameRoom.tsx │ │ ├── JoinTeam.test.tsx │ │ ├── JoinTeam.tsx │ │ ├── GiveClue.tsx │ │ ├── MakeGuess.tsx │ │ ├── ViewScore.test.tsx │ │ ├── Scoreboard.tsx │ │ └── ViewScore.tsx │ ├── hooks │ │ ├── useStorageBackedState.tsx │ │ └── useNetworkBackedGameState.tsx │ └── App.tsx ├── firebaseConfig.ts ├── index.css ├── i18n.tsx ├── index.tsx ├── App.css ├── logo.svg └── serviceWorker.ts ├── public ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── wavelength_box_small.jpg ├── Digital-Patreon-Wordmark_FieryCoral.png ├── manifest.json ├── index.html └── locales │ ├── pt │ ├── translation.json │ └── spectrum-cards.json │ ├── en │ ├── translation.json │ └── spectrum-cards.json │ ├── fr │ ├── translation.json │ └── spectrum-cards.json │ ├── es │ ├── translation.json │ └── spectrum-cards.json │ ├── it │ ├── translation.json │ └── spectrum-cards.json │ └── de │ ├── translation.json │ └── spectrum-cards.json ├── database.rules.json ├── .firebaserc ├── .github └── workflows │ ├── cloudzilla │ └── master │ │ ├── k8s │ │ ├── ingress-upstream.yaml │ │ └── deploy.yaml │ │ ├── nginx.conf │ │ └── Dockerfile │ └── lens-deploy-master.yaml ├── .gitignore ├── tsconfig.json ├── firebase.json ├── LICENSE.txt ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cynicaloptimist/longwave/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cynicaloptimist/longwave/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cynicaloptimist/longwave/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/wavelength_box_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cynicaloptimist/longwave/HEAD/public/wavelength_box_small.jpg -------------------------------------------------------------------------------- /src/state/RandomSpectrumTarget.ts: -------------------------------------------------------------------------------- 1 | export function RandomSpectrumTarget() { 2 | return Math.floor(Math.random() * 21); 3 | } 4 | -------------------------------------------------------------------------------- /public/Digital-Patreon-Wordmark_FieryCoral.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cynicaloptimist/longwave/HEAD/public/Digital-Patreon-Wordmark_FieryCoral.png -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "rooms": { 4 | "$key": { 5 | ".read": true, 6 | ".write": true 7 | } 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /src/state/GetScore.ts: -------------------------------------------------------------------------------- 1 | export function GetScore(target: number, guess: number) { 2 | const difference = Math.abs(target - guess); 3 | if (difference > 2) { 4 | return 0; 5 | } 6 | return 4 - Math.floor(difference); 7 | } 8 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #f2f2f2; 3 | } 4 | 5 | #root { 6 | margin: 10%; 7 | } 8 | 9 | #root > div > div { 10 | border: none !important; 11 | background-color: white; 12 | padding: 2rem !important; 13 | border-radius: 1rem; 14 | } 15 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect"; 6 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "wavelength-online" 4 | }, 5 | "targets": { 6 | "wavelength-online": { 7 | "hosting": { 8 | "longwave": [ 9 | "longwave" 10 | ], 11 | "dev": [ 12 | "wavelength-online" 13 | ] 14 | } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/state/RandomFourCharacterString.ts: -------------------------------------------------------------------------------- 1 | export function RandomFourCharacterString() { 2 | const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; 3 | let randomString = ""; 4 | for (let i = 0; i < 4; i++) { 5 | randomString += characters[Math.floor(Math.random() * characters.length)]; 6 | } 7 | return randomString; 8 | } 9 | -------------------------------------------------------------------------------- /src/TrackEvent.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import "firebase/analytics"; 3 | 4 | export function RecordEvent( 5 | eventName: string, 6 | eventBody: { [parameterName: string]: string } 7 | ) { 8 | firebase.analytics().logEvent(eventName, { 9 | app_name: "Longwave", 10 | screen_name: "index", 11 | ...eventBody, 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/common/GetContrastingColors.ts: -------------------------------------------------------------------------------- 1 | const ColorScheme: any = require("color-scheme"); 2 | 3 | export function GetContrastingColors(hue: number): [string, string] { 4 | const scheme = new ColorScheme(); 5 | scheme.from_hue(hue).scheme("contrast").variation("soft"); 6 | const [primary, , , , secondary]: string[] = scheme.colors(); 7 | return ["#" + primary, "#" + secondary]; 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/cloudzilla/master/k8s/ingress-upstream.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | app: ingress-upstream 6 | name: ingress-upstream 7 | spec: 8 | ports: 9 | - name: 80-3000 10 | port: 80 11 | protocol: TCP 12 | targetPort: 80 13 | selector: 14 | app: console-project 15 | sessionAffinity: None 16 | type: ClusterIP 17 | status: 18 | loadBalancer: {} -------------------------------------------------------------------------------- /src/components/common/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Button(props: { 4 | text: string; 5 | onClick: () => void; 6 | disabled?: boolean; 7 | }) { 8 | return ( 9 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/firebaseConfig.ts: -------------------------------------------------------------------------------- 1 | export const firebaseConfig = { 2 | apiKey: "AIzaSyAyPMuIGreq81gLErYeKGkEje851Xdh_kc", 3 | authDomain: "wavelength-online.firebaseapp.com", 4 | databaseURL: "https://wavelength-online.firebaseio.com", 5 | projectId: "wavelength-online", 6 | storageBucket: "wavelength-online.appspot.com", 7 | messagingSenderId: "811157116454", 8 | appId: "1:811157116454:web:be3d3e6b17629700c038e7", 9 | measurementId: "G-XZ13S15LMC", 10 | }; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .firebase/*.cache 26 | .eslintcache 27 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | ul { 16 | margin: 0; 17 | padding-inline-start: 16px; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/common/Info.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; 4 | import Tippy from "@tippyjs/react"; 5 | import { ReactElement } from "react"; 6 | 7 | export function Info(props: { children: string | ReactElement }) { 8 | return ( 9 | 10 |
11 | 12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Longwave", 3 | "name": "Longwave", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/components/common/Animate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | export function Animate(props: { 4 | children: React.ReactNode; 5 | animation: "wipe-reveal-right" | "fade-disappear-up"; 6 | style?: React.CSSProperties; 7 | }) { 8 | const [className, setClassName] = useState(props.animation); 9 | 10 | useEffect(() => { 11 | setTimeout(() => { 12 | return setClassName(props.animation + " animate"); 13 | }); 14 | }); 15 | 16 | return ( 17 |
18 | {props.children} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/gameplay/i18nForTests.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | import Backend from "i18next-fs-backend"; 4 | 5 | //https://github.com/i18next/i18next-fs-backend 6 | 7 | i18n 8 | .use(initReactI18next) 9 | .use(Backend) 10 | .init({ 11 | lng: "en", 12 | fallbackLng: "en", 13 | initImmediate: false, 14 | 15 | interpolation: { 16 | escapeValue: false, // not needed for react!! 17 | }, 18 | 19 | backend: { 20 | loadPath: "public/locales/{{lng}}/{{ns}}.json", 21 | }, 22 | }); 23 | 24 | export default i18n; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx", 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/hooks/useStorageBackedState.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export function useStorageBackedState( 4 | initialValue: T, 5 | key: string 6 | ): [T, (value: T) => void] { 7 | const storedItem = localStorage.getItem(key); 8 | if (storedItem == null) { 9 | localStorage.setItem(key, JSON.stringify(initialValue)); 10 | } else { 11 | initialValue = JSON.parse(storedItem); 12 | } 13 | 14 | const [value, setValue] = useState(initialValue); 15 | 16 | return [ 17 | value, 18 | (newValue: T) => { 19 | localStorage.setItem(key, JSON.stringify(newValue)); 20 | setValue(newValue); 21 | }, 22 | ]; 23 | } 24 | -------------------------------------------------------------------------------- /src/state/GameModelContext.ts: -------------------------------------------------------------------------------- 1 | import { Team, InitialGameState } from "./GameState"; 2 | import { createContext } from "react"; 3 | import { GameModel } from "./BuildGameModel"; 4 | 5 | export const GameModelContext = createContext({ 6 | gameState: InitialGameState("en"), 7 | localPlayer: { 8 | id: "localPlayer", 9 | name: "Player", 10 | team: Team.Unset, 11 | }, 12 | clueGiver: null, 13 | spectrumCard: ["left", "right"], 14 | setGameState: (newState) => { 15 | console.warn( 16 | "GameModelContext not provided. Got setGameState: " + 17 | JSON.stringify(newState) 18 | ); 19 | }, 20 | setPlayerName: () => {}, 21 | }); 22 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": [ 6 | { 7 | "target": "longwave", 8 | "public": "build", 9 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 10 | "rewrites": [ 11 | { 12 | "source": "**", 13 | "destination": "/index.html" 14 | } 15 | ] 16 | }, 17 | { 18 | "target": "dev", 19 | "public": "build", 20 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 21 | "rewrites": [ 22 | { 23 | "source": "**", 24 | "destination": "/index.html" 25 | } 26 | ] 27 | } 28 | ], 29 | "emulators": {} 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/cloudzilla/master/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | # Serve files from the default directory 5 | root /usr/share/nginx/html; 6 | 7 | index index.html; 8 | 9 | # Handle routing for single-page applications 10 | location / { 11 | try_files $uri /index.html; 12 | } 13 | 14 | # Optional: Cache static assets for performance 15 | location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ { 16 | expires 6M; 17 | access_log off; 18 | add_header Cache-Control "public"; 19 | } 20 | 21 | # Optional: Security headers 22 | add_header X-Content-Type-Options nosniff; 23 | add_header X-Frame-Options DENY; 24 | add_header X-XSS-Protection "1; mode=block"; 25 | } 26 | -------------------------------------------------------------------------------- /src/state/NewGame.ts: -------------------------------------------------------------------------------- 1 | import { TFunction } from "i18next"; 2 | import { GameType, PlayersTeams, GameState, Team } from "./GameState"; 3 | import { NewRound } from "./NewRound"; 4 | 5 | export function NewTeamGame( 6 | players: PlayersTeams, 7 | startPlayer: string, 8 | gameState: GameState, 9 | tSpectrumCards: TFunction<"spectrum-cards"> 10 | ): Partial { 11 | const initialScores: Partial = { 12 | leftScore: 0, 13 | rightScore: 0, 14 | }; 15 | 16 | const playerTeam = players[startPlayer].team; 17 | if (playerTeam === Team.Left) { 18 | initialScores.rightScore = 1; 19 | } else { 20 | initialScores.leftScore = 1; 21 | } 22 | 23 | return { 24 | ...NewRound(startPlayer, gameState, tSpectrumCards), 25 | ...initialScores, 26 | previousTurn: null, 27 | gameType: GameType.Teams, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/i18n.tsx: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import Backend from "i18next-http-backend"; 3 | import LanguageDetector from "i18next-browser-languagedetector"; 4 | import { initReactI18next } from "react-i18next"; 5 | 6 | export const allLanguages = ["en", "de", "fr", "pt-BR", "it", "es"]; 7 | 8 | i18n 9 | // Enables the i18next backend 10 | .use(Backend) 11 | // Enable automatic language detection 12 | .use(LanguageDetector) 13 | // Enables the hook initialization module 14 | .use(initReactI18next) 15 | .init({ 16 | // Standard language used 17 | fallbackLng: "en", 18 | //debug: true, 19 | //Detects and caches a cookie from the language provided 20 | detection: { 21 | order: ["queryString", "cookie"], 22 | caches: ["cookie"], 23 | }, 24 | interpolation: { 25 | escapeValue: false, 26 | }, 27 | }); 28 | 29 | export default i18n; 30 | -------------------------------------------------------------------------------- /src/components/common/Title.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { GetContrastingColors } from "./GetContrastingColors"; 3 | import { useState } from "react"; 4 | import { useEffect } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function LongwaveAppTitle() { 8 | const { t } = useTranslation(); 9 | const [hue, setHue] = useState(0); 10 | const [primary, secondary] = GetContrastingColors(hue); 11 | useEffect(() => { 12 | const interval = setInterval(() => { 13 | setHue((hue) => (hue + 1) % 360); 14 | }, 5); 15 | return () => clearInterval(interval); 16 | }); 17 | return ( 18 |

25 | {t("title.longwave")} 26 |

27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/common/LayoutElements.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const baseFlexStyles: React.CSSProperties = { 4 | display: "flex", 5 | flexFlow: "row", 6 | justifyContent: "space-evenly", 7 | alignItems: "center", 8 | }; 9 | 10 | export function CenteredRow(props: { 11 | children: React.ReactNode; 12 | style?: React.CSSProperties; 13 | }) { 14 | return ( 15 |
22 | {props.children} 23 |
24 | ); 25 | } 26 | 27 | export function CenteredColumn(props: { 28 | children: React.ReactNode; 29 | style?: React.CSSProperties; 30 | }) { 31 | return ( 32 |
39 | {props.children} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import "../App.css"; 2 | 3 | import { BrowserRouter, Route, Switch } from "react-router-dom"; 4 | import { GameRoom } from "./gameplay/GameRoom"; 5 | import { CenteredColumn } from "./common/LayoutElements"; 6 | import { CommonFooter } from "./common/CommonFooter"; 7 | import { LandingPage } from "./common/LandingPage"; 8 | 9 | const style: React.CSSProperties = { 10 | maxWidth: 500, 11 | margin: 4, 12 | padding: 4, 13 | border: "1px solid black", 14 | }; 15 | 16 | function App() { 17 | return ( 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /src/components/gameplay/InputName.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRef } from "react"; 3 | import { CenteredColumn } from "../common/LayoutElements"; 4 | import { LongwaveAppTitle } from "../common/Title"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function InputName(props: { setName: (name: string) => void }) { 8 | const { t } = useTranslation(); 9 | const inputRef = useRef(null); 10 | return ( 11 | 12 | 13 |
{t("inputname.your_name")}:
14 | { 19 | if (!inputRef.current) { 20 | return false; 21 | } 22 | if (event.key !== "Enter") { 23 | return true; 24 | } 25 | props.setName(inputRef.current.value); 26 | }} 27 | /> 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/gameplay/TestContext.tsx: -------------------------------------------------------------------------------- 1 | import { ReactChild, Suspense } from "react"; 2 | import { GameState } from "../../state/GameState"; 3 | import { BuildGameModel } from "../../state/BuildGameModel"; 4 | import { GameModelContext } from "../../state/GameModelContext"; 5 | import { I18nextProvider } from "react-i18next"; 6 | import i18n from "./i18nForTests"; 7 | 8 | export function TestContext(props: { 9 | gameState: GameState; 10 | playerId: string; 11 | children: ReactChild; 12 | setState?: (newState: Partial) => void; 13 | }) { 14 | return ( 15 | ["left", "right"] as any, 21 | () => {} 22 | )} 23 | > 24 | Loading...}> 25 | {props.children} 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/common/GetContrastingText.ts: -------------------------------------------------------------------------------- 1 | interface RGB { 2 | b: number; 3 | g: number; 4 | r: number; 5 | } 6 | 7 | function hex2Rgb(hex: string): RGB | undefined { 8 | if (!hex || hex === undefined || hex === '') { 9 | return undefined; 10 | } 11 | 12 | const result: RegExpExecArray | null = 13 | /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 14 | 15 | return result ? { 16 | r: parseInt(result[1], 16), 17 | g: parseInt(result[2], 16), 18 | b: parseInt(result[3], 16) 19 | } : undefined; 20 | } 21 | 22 | function rgb2YIQ({ r, g, b }: RGB): number { 23 | return ((r * 299) + (g * 587) + (b * 114)) / 1000; 24 | } 25 | 26 | export function GetContrastingText(colorHex: string | undefined, 27 | threshold: number = 128): string { 28 | if (colorHex === undefined) { 29 | return '#000'; 30 | } 31 | 32 | const rgb: RGB | undefined = hex2Rgb(colorHex); 33 | 34 | if (rgb === undefined) { 35 | return '#000'; 36 | } 37 | 38 | return rgb2YIQ(rgb) >= threshold ? '#000' : '#fff'; 39 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Evan Bailey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/state/NewRound.ts: -------------------------------------------------------------------------------- 1 | import { RoundPhase, GameState } from "./GameState"; 2 | import { RandomSpectrumTarget } from "./RandomSpectrumTarget"; 3 | import { BuildGameModel } from "./BuildGameModel"; 4 | import { TFunction } from "i18next"; 5 | 6 | export function NewRound( 7 | playerId: string, 8 | gameState: GameState, 9 | tSpectrumCards: TFunction<"spectrum-cards"> 10 | ): Partial { 11 | const gameModel = BuildGameModel( 12 | gameState, 13 | () => {}, 14 | playerId, 15 | tSpectrumCards, 16 | () => {} 17 | ); 18 | 19 | const newState: Partial = { 20 | clueGiver: playerId, 21 | roundPhase: RoundPhase.GiveClue, 22 | deckIndex: gameState.deckIndex + 1, 23 | turnsTaken: gameState.turnsTaken + 1, 24 | spectrumTarget: RandomSpectrumTarget(), 25 | }; 26 | 27 | if (gameModel.clueGiver !== null) { 28 | newState.previousTurn = { 29 | spectrumCard: gameModel.spectrumCard, 30 | spectrumTarget: gameState.spectrumTarget, 31 | clueGiverName: gameModel.clueGiver.name, 32 | clue: gameState.clue, 33 | guess: gameState.guess, 34 | }; 35 | } 36 | 37 | return newState; 38 | } 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import "rc-slider/assets/index.css"; 3 | import "tippy.js/dist/tippy.css"; 4 | 5 | import React, { Suspense } from "react"; 6 | import ReactDOM from "react-dom"; 7 | 8 | import * as serviceWorker from "./serviceWorker"; 9 | import firebase from "firebase/app"; 10 | 11 | import App from "./components/App"; 12 | import { firebaseConfig } from "./firebaseConfig"; 13 | 14 | // import i18n (needs to be bundled ;)) 15 | import "./i18n"; 16 | 17 | firebase.initializeApp(firebaseConfig); 18 | firebase.analytics().logEvent("screen_view", { 19 | app_name: "Longwave", 20 | screen_name: "index", 21 | }); 22 | 23 | ReactDOM.render( 24 | 25 | Loading...}> 26 | 27 | 28 | , 29 | document.getElementById("root") 30 | ); 31 | 32 | //ReactDOM.render( 33 | // 34 | // 35 | // , 36 | // document.getElementById("root") 37 | //); 38 | 39 | // If you want your app to work offline and load faster, you can change 40 | // unregister() to register() below. Note this comes with some pitfalls. 41 | // Learn more about service workers: https://bit.ly/CRA-PWA 42 | serviceWorker.unregister(); 43 | -------------------------------------------------------------------------------- /.github/workflows/cloudzilla/master/k8s/deploy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: section-project-deployment 5 | labels: 6 | app: console-project 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: console-project 12 | template: 13 | metadata: 14 | labels: 15 | app: console-project 16 | spec: 17 | containers: 18 | - name: console-project 19 | image: $FULL_IMAGE_WITH_TAG 20 | imagePullPolicy: Always 21 | envFrom: 22 | - secretRef: 23 | name: console-project-env-secret 24 | resources: 25 | limits: 26 | cpu: 1000m 27 | memory: 2147483648 28 | requests: 29 | cpu: 50m 30 | memory: 50Mi 31 | ports: 32 | - containerPort: 80 33 | startupProbe: 34 | tcpSocket: 35 | port: 80 36 | initialDelaySeconds: 10 37 | periodSeconds: 5 38 | failureThreshold: 30 39 | readinessProbe: 40 | tcpSocket: 41 | port: 80 42 | initialDelaySeconds: 5 43 | periodSeconds: 5 44 | imagePullSecrets: 45 | - name: regcred 46 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | .wipe-reveal-right { 41 | overflow: hidden; 42 | position: relative; 43 | } 44 | 45 | .wipe-reveal-right::after { 46 | content: " "; 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | height: 100%; 52 | background: #fff; 53 | z-index: 2; 54 | transition: all 1s linear; 55 | } 56 | 57 | .wipe-reveal-right.animate::after { 58 | transform: translateX(100%); 59 | } 60 | 61 | .fade-disappear-up { 62 | transition: all 3s ease-in; 63 | } 64 | 65 | .fade-disappear-up.animate { 66 | transform: translateY(-100%); 67 | opacity: 0; 68 | } 69 | -------------------------------------------------------------------------------- /.github/workflows/cloudzilla/master/Dockerfile: -------------------------------------------------------------------------------- 1 | # Step 1: Use Node.js for building the app 2 | FROM node:21 AS builder 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Add `/app/node_modules/.bin` to PATH 8 | ENV PATH /app/node_modules/.bin:$PATH 9 | 10 | # This is a fix for this reported issue, can likely remove at some point in the future 11 | # https://github.com/nodejs/corepack/issues/612 12 | RUN npm install -g corepack@latest 13 | 14 | # Enable Corepack for managing pnpm and Yarn versions 15 | RUN corepack enable 16 | 17 | # Install dependencies 18 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 19 | 20 | RUN if [ -f package-lock.json ]; then npm install; \ 21 | elif [ -f yarn.lock ]; then yarn install; \ 22 | elif [ -f pnpm-lock.yaml ]; then pnpm install; \ 23 | else echo "No lockfile found. Attempting install anyways with npm" && npm install; \ 24 | fi 25 | 26 | # Copy the app's source code and build it 27 | COPY . ./ 28 | RUN react-scripts build 29 | 30 | # Use a lightweight NGINX image for serving the app 31 | FROM nginx:alpine 32 | 33 | # Copy the built files from the previous stage 34 | COPY --from=builder /app/build /usr/share/nginx/html 35 | 36 | # Copy a custom NGINX configuration file 37 | COPY .github/workflows/cloudzilla/master/nginx.conf /etc/nginx/conf.d/default.conf 38 | 39 | # Expose port 80 40 | EXPOSE 80 41 | 42 | # Start NGINX 43 | CMD ["nginx", "-g", "daemon off;"] 44 | -------------------------------------------------------------------------------- /src/components/gameplay/ActiveGame.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { RoundPhase, GameType, Team } from "../../state/GameState"; 3 | import { GiveClue } from "./GiveClue"; 4 | import { MakeGuess } from "./MakeGuess"; 5 | import { ViewScore } from "./ViewScore"; 6 | import { JoinTeam } from "./JoinTeam"; 7 | import { Scoreboard } from "./Scoreboard"; 8 | import { SetupGame } from "./SetupGame"; 9 | import { CounterGuess } from "./CounterGuess"; 10 | import { useContext } from "react"; 11 | import { GameModelContext } from "../../state/GameModelContext"; 12 | import { PreviousTurnResult } from "./PreviousTurnResult"; 13 | 14 | export function ActiveGame() { 15 | const { gameState, localPlayer } = useContext(GameModelContext); 16 | 17 | if (gameState.roundPhase === RoundPhase.SetupGame) { 18 | return ; 19 | } 20 | 21 | if ( 22 | gameState.gameType === GameType.Teams && 23 | (gameState.roundPhase === RoundPhase.PickTeams || 24 | localPlayer.team === Team.Unset) 25 | ) { 26 | return ; 27 | } 28 | 29 | return ( 30 | <> 31 | {gameState.roundPhase === RoundPhase.GiveClue && } 32 | {gameState.roundPhase === RoundPhase.MakeGuess && } 33 | {gameState.roundPhase === RoundPhase.CounterGuess && } 34 | {gameState.roundPhase === RoundPhase.ViewScore && } 35 | 36 | {gameState.previousTurn && ( 37 | 38 | )} 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/gameplay/PreviousTurnResult.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TurnSummaryModel } from "../../state/GameState"; 3 | import { CenteredColumn } from "../common/LayoutElements"; 4 | import { Spectrum } from "../common/Spectrum"; 5 | 6 | import { useTranslation } from "react-i18next"; 7 | 8 | export function PreviousTurnResult(props: TurnSummaryModel) { 9 | const { t } = useTranslation(); 10 | const style: React.CSSProperties = { 11 | borderTop: "1px solid black", 12 | margin: 16, 13 | paddingTop: 16, 14 | }; 15 | 16 | const glassStyle: React.CSSProperties = { 17 | position: "absolute", 18 | zIndex: 10, 19 | left: 0, 20 | right: 0, 21 | top: 0, 22 | bottom: 0, 23 | backgroundColor: "rgba(255,255,255,0.5)", 24 | }; 25 | 26 | return ( 27 |
28 | 29 | {t("previousturnresult.previous_game")} 30 | 31 |
36 |
37 | 42 | 43 |
44 | {t("previousturnresult.player_clue", { 45 | givername: props.clueGiverName, 46 | })} 47 | : {props.clue} 48 |
49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/gameplay/SetupGame.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { GameType, RoundPhase } from "../../state/GameState"; 3 | import { CenteredRow, CenteredColumn } from "../common/LayoutElements"; 4 | import { Button } from "../common/Button"; 5 | import { LongwaveAppTitle } from "../common/Title"; 6 | import { useContext } from "react"; 7 | import { GameModelContext } from "../../state/GameModelContext"; 8 | import { NewRound } from "../../state/NewRound"; 9 | 10 | import { useTranslation } from "react-i18next"; 11 | 12 | export function SetupGame() { 13 | const { t } = useTranslation(); 14 | const cardsTranslation = useTranslation("spectrum-cards"); 15 | const { gameState, setGameState, localPlayer } = useContext(GameModelContext); 16 | 17 | const startGame = (gameType: GameType) => { 18 | if (gameType === GameType.Teams) { 19 | setGameState({ 20 | roundPhase: RoundPhase.PickTeams, 21 | gameType, 22 | }); 23 | } else { 24 | setGameState({ 25 | ...NewRound(localPlayer.id, gameState, cardsTranslation.t), 26 | gameType, 27 | }); 28 | } 29 | }; 30 | 31 | return ( 32 | 33 | 34 | 35 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/state/GameState.ts: -------------------------------------------------------------------------------- 1 | import { RandomSpectrumTarget } from "./RandomSpectrumTarget"; 2 | import { RandomFourCharacterString } from "./RandomFourCharacterString"; 3 | import { TFunction } from "i18next"; 4 | 5 | export enum RoundPhase { 6 | SetupGame, 7 | PickTeams, 8 | GiveClue, 9 | MakeGuess, 10 | CounterGuess, 11 | ViewScore, 12 | } 13 | 14 | export enum GameType { 15 | Teams, 16 | Cooperative, 17 | Freeplay, 18 | } 19 | 20 | export enum Team { 21 | Unset, 22 | Left, 23 | Right, 24 | } 25 | 26 | export function TeamReverse(team: Team) { 27 | if (team === Team.Left) { 28 | return Team.Right; 29 | } 30 | if (team === Team.Right) { 31 | return Team.Left; 32 | } 33 | return Team.Unset; 34 | } 35 | 36 | export function TeamName(team: Team, t: TFunction) { 37 | if (team === Team.Left) { 38 | return t("gamestate.left_brain"); 39 | } 40 | if (team === Team.Right) { 41 | return t("gamestate.right_brain"); 42 | } 43 | return t("gamestate.the_player"); 44 | } 45 | 46 | export type PlayersTeams = { 47 | [playerId: string]: { 48 | name: string; 49 | team: Team; 50 | }; 51 | }; 52 | 53 | export type TurnSummaryModel = { 54 | spectrumCard: [string, string]; 55 | clueGiverName: string; 56 | clue: string; 57 | spectrumTarget: number; 58 | guess: number; 59 | }; 60 | 61 | export interface GameState { 62 | gameType: GameType; 63 | roundPhase: RoundPhase; 64 | turnsTaken: number; 65 | deckSeed: string; 66 | deckIndex: number; 67 | spectrumTarget: number; 68 | clue: string; 69 | guess: number; 70 | counterGuess: "left" | "right"; 71 | players: PlayersTeams; 72 | clueGiver: string; 73 | leftScore: number; 74 | rightScore: number; 75 | coopScore: number; 76 | coopBonusTurns: number; 77 | previousTurn: TurnSummaryModel | null; 78 | deckLanguage: string | null; 79 | } 80 | 81 | export function InitialGameState(deckLanguage: string): GameState { 82 | return { 83 | gameType: GameType.Teams, 84 | roundPhase: RoundPhase.SetupGame, 85 | turnsTaken: -1, 86 | deckSeed: RandomFourCharacterString(), 87 | deckIndex: 0, 88 | spectrumTarget: RandomSpectrumTarget(), 89 | clue: "", 90 | guess: 0, 91 | counterGuess: "left", 92 | players: {}, 93 | clueGiver: "", 94 | leftScore: 0, 95 | rightScore: 0, 96 | coopScore: 0, 97 | coopBonusTurns: 0, 98 | previousTurn: null, 99 | deckLanguage: deckLanguage, 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/gameplay/FakeRooms.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { GameModelContext } from "../../state/GameModelContext"; 3 | import { ActiveGame } from "./ActiveGame"; 4 | import { BuildGameModel } from "../../state/BuildGameModel"; 5 | import { CenteredRow, CenteredColumn } from "../common/LayoutElements"; 6 | import { 7 | InitialGameState, 8 | GameState, 9 | Team, 10 | GameType, 11 | RoundPhase, 12 | } from "../../state/GameState"; 13 | import { useTranslation } from "react-i18next"; 14 | 15 | export function FakeRooms() { 16 | const cardsTranslation = useTranslation("spectrum-cards"); 17 | 18 | const [gameState, setGameState] = useState({ 19 | ...InitialGameState(cardsTranslation.i18n.language), 20 | gameType: GameType.Teams, 21 | roundPhase: RoundPhase.PickTeams, 22 | players: { 23 | ul: { 24 | name: "Upper Left", 25 | team: Team.Left, 26 | }, 27 | ll: { 28 | name: "Lower Left", 29 | team: Team.Left, 30 | }, 31 | ur: { 32 | name: "Upper Right", 33 | team: Team.Right, 34 | }, 35 | lr: { 36 | name: "Lower Right", 37 | team: Team.Right, 38 | }, 39 | }, 40 | }); 41 | 42 | const setPartialGameState = (newState: Partial) => 43 | setGameState({ 44 | ...gameState, 45 | ...newState, 46 | }); 47 | 48 | const style: React.CSSProperties = { 49 | width: 500, 50 | margin: 4, 51 | padding: 4, 52 | border: "1px solid black", 53 | }; 54 | 55 | const renderGame = (playerId: string) => ( 56 |
57 | {} 64 | )} 65 | > 66 | 67 | 68 |
69 | ); 70 | 71 | return ( 72 | 75 | 81 | {renderGame("ul")} 82 | {renderGame("ll")} 83 | 84 | 90 | {renderGame("ur")} 91 | {renderGame("lr")} 92 | 93 | 94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /src/components/gameplay/GameRoom.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import React from "react"; 3 | import { useStorageBackedState } from "../hooks/useStorageBackedState"; 4 | import { useNetworkBackedGameState } from "../hooks/useNetworkBackedGameState"; 5 | import { InputName } from "./InputName"; 6 | import { RandomFourCharacterString } from "../../state/RandomFourCharacterString"; 7 | import { GameModelContext } from "../../state/GameModelContext"; 8 | import { ActiveGame } from "./ActiveGame"; 9 | import { BuildGameModel } from "../../state/BuildGameModel"; 10 | import { RoomIdHeader } from "../common/RoomIdHeader"; 11 | import { FakeRooms } from "./FakeRooms"; 12 | import { useTranslation } from "react-i18next"; 13 | 14 | export function GameRoom() { 15 | const { roomId } = useParams<{ roomId: string }>(); 16 | if (roomId === undefined) { 17 | throw new Error("RoomId missing"); 18 | } 19 | 20 | const [playerName, setPlayerName] = useStorageBackedState("", "name"); 21 | 22 | const [playerId] = useStorageBackedState( 23 | RandomFourCharacterString(), 24 | "playerId" 25 | ); 26 | 27 | const [gameState, setGameState] = useNetworkBackedGameState( 28 | roomId, 29 | playerId, 30 | playerName 31 | ); 32 | 33 | const cardsTranslation = useTranslation("spectrum-cards"); 34 | 35 | if ( 36 | gameState.deckLanguage !== null && 37 | cardsTranslation.i18n.language !== gameState.deckLanguage 38 | ) { 39 | cardsTranslation.i18n.changeLanguage(gameState.deckLanguage); 40 | return null; 41 | } 42 | 43 | if (roomId === "MULTIPLAYER_TEST") { 44 | return ; 45 | } 46 | 47 | const gameModel = BuildGameModel( 48 | gameState, 49 | setGameState, 50 | playerId, 51 | cardsTranslation.t, 52 | setPlayerName 53 | ); 54 | 55 | if (playerName.length === 0) { 56 | return ( 57 | { 59 | setPlayerName(name); 60 | gameState.players[playerId].name = name; 61 | setGameState(gameState); 62 | }} 63 | /> 64 | ); 65 | } 66 | 67 | const searchParams = new URLSearchParams(window.location.search); 68 | if (searchParams.get("rocketcrab")) { 69 | const rocketcrabPlayerName = searchParams.get("name"); 70 | if (rocketcrabPlayerName !== null && rocketcrabPlayerName !== playerName) { 71 | setPlayerName(rocketcrabPlayerName); 72 | } 73 | } 74 | 75 | if (!gameState?.players?.[playerId]) { 76 | return null; 77 | } 78 | 79 | return ( 80 | 81 | 82 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/components/gameplay/JoinTeam.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent, within, waitFor } from "@testing-library/react"; 2 | import { InitialGameState, GameState, Team } from "../../state/GameState"; 3 | import { JoinTeam } from "./JoinTeam"; 4 | import { TestContext } from "./TestContext"; 5 | 6 | jest.useFakeTimers(); 7 | 8 | test("Assigns player to the selected team", async () => { 9 | const gameState: GameState = { 10 | ...InitialGameState(), 11 | players: { 12 | player1: { 13 | name: "Player", 14 | team: Team.Unset, 15 | }, 16 | }, 17 | }; 18 | 19 | const setState = jest.fn(); 20 | const component = await render( 21 | 22 | 23 | 24 | ); 25 | 26 | let leftBrain: HTMLElement | null = null; 27 | 28 | await waitFor(() => { 29 | leftBrain = component.getByText("LEFT BRAIN"); 30 | return leftBrain; 31 | }); 32 | 33 | expect(leftBrain).toBeInTheDocument(); 34 | 35 | const button = leftBrain!.parentNode?.querySelector("input")!; 36 | 37 | expect(button.value).toEqual("Join"); 38 | 39 | await fireEvent.click(button); 40 | 41 | expect(setState).toHaveBeenCalledWith({ 42 | players: { 43 | player1: { 44 | id: "player1", 45 | name: "Player", 46 | team: Team.Left, 47 | }, 48 | }, 49 | }); 50 | }); 51 | 52 | test("Shows current team members", () => { 53 | const gameState: GameState = { 54 | ...InitialGameState(), 55 | players: { 56 | playerId: { 57 | name: "Player", 58 | team: Team.Unset, 59 | }, 60 | leftTeam1: { 61 | name: "Left Team 1", 62 | team: Team.Left, 63 | }, 64 | leftTeam2: { 65 | name: "Left Team 2", 66 | team: Team.Left, 67 | }, 68 | rightTeam1: { 69 | name: "Right Team 1", 70 | team: Team.Right, 71 | }, 72 | rightTeam2: { 73 | name: "Right Team 2", 74 | team: Team.Right, 75 | }, 76 | }, 77 | }; 78 | 79 | const component = render( 80 | 81 | 82 | 83 | ); 84 | 85 | const leftBrain = within(component.getByText("LEFT BRAIN").parentElement!); 86 | expect(leftBrain.getByText("Left Team 1")).toBeInTheDocument(); 87 | expect(leftBrain.getByText("Left Team 2")).toBeInTheDocument(); 88 | 89 | const rightBrain = within(component.getByText("RIGHT BRAIN").parentElement!); 90 | expect(rightBrain.getByText("Right Team 1")).toBeInTheDocument(); 91 | expect(rightBrain.getByText("Right Team 2")).toBeInTheDocument(); 92 | }); 93 | -------------------------------------------------------------------------------- /src/components/gameplay/JoinTeam.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CenteredRow, CenteredColumn } from "../common/LayoutElements"; 3 | import { RoundPhase, Team, TeamName } from "../../state/GameState"; 4 | import { Button } from "../common/Button"; 5 | import { LongwaveAppTitle } from "../common/Title"; 6 | import { useContext } from "react"; 7 | import { GameModelContext } from "../../state/GameModelContext"; 8 | import { NewTeamGame } from "../../state/NewGame"; 9 | 10 | import { useTranslation } from "react-i18next"; 11 | 12 | export function JoinTeam() { 13 | const { t } = useTranslation(); 14 | const cardsTranslation = useTranslation("spectrum-cards"); 15 | const { gameState, localPlayer, setGameState } = useContext(GameModelContext); 16 | 17 | const leftTeam = Object.keys(gameState.players).filter( 18 | (playerId) => gameState.players[playerId].team === Team.Left 19 | ); 20 | const rightTeam = Object.keys(gameState.players).filter( 21 | (playerId) => gameState.players[playerId].team === Team.Right 22 | ); 23 | 24 | const joinTeam = (team: Team) => { 25 | setGameState({ 26 | players: { 27 | ...gameState.players, 28 | [localPlayer.id]: { 29 | ...localPlayer, 30 | team, 31 | }, 32 | }, 33 | }); 34 | }; 35 | 36 | const startGame = () => 37 | setGameState( 38 | NewTeamGame( 39 | gameState.players, 40 | localPlayer.id, 41 | gameState, 42 | cardsTranslation.t 43 | ) 44 | ); 45 | 46 | return ( 47 | 48 | 49 |
{t("jointeam.join_team")}:
50 | 56 | 57 |
{TeamName(Team.Left, t)}
58 | {leftTeam.map((playerId) => ( 59 |
{gameState.players[playerId].name}
60 | ))} 61 |
62 |
67 |
68 | 69 |
{TeamName(Team.Right, t)}
70 | {rightTeam.map((playerId) => ( 71 |
{gameState.players[playerId].name}
72 | ))} 73 |
74 |
79 |
80 |
81 | {gameState.roundPhase === RoundPhase.PickTeams && ( 82 |