Two teams try and fill in any horizontal, vertical, or diagonal line on a bingo board by playing maps on osu! osu.bingo
osu

Merge remote-tracking branch 'origin/main' into microservice

clxxiii 588ad0d4 b19d8087

+262 -65
+22 -24
README.md
··· 1 - # create-svelte 2 1 3 - Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte). 4 2 5 - ## Creating a project 3 + <div align="center"> 4 + <h1><a href="https://clxxiii.notion.site/Rewrite-in-Rust-lmao-2733f4b9c82c8029977dde15cac45c52">✨This project is in the process of being rewritten!✨</a></h1> 5 + </div> 6 6 7 - If you're seeing this, you've probably already done this step. Congrats! 7 + <div align="center"> 8 + <img width="362" height="119" alt="image" src="https://github.com/user-attachments/assets/f761f22c-77f1-48c5-91ca-d64123ae5b0c" /> 9 + </div> 8 10 9 - ```bash 10 - # create a new project in the current directory 11 - npm create svelte@latest 11 + --- 12 12 13 - # create a new project in my-app 14 - npm create svelte@latest my-app 15 - ``` 13 + ### How it Works 14 + osu! Bingo is just like regular Bingo, but with a twist. Two teams of up to 50 players compete to try and fill in any horizontal, vertical, or diagonal line on a Bingo Board. 15 + Claim a square 16 16 17 - ## Developing 17 + In public games, squares start off by requiring an FC. Over the course of the game, the requirement goes down, from just needing an A rank, to any pass working as a claim. 18 18 19 - Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: 19 + ### No square is safe! 20 20 21 - ```bash 22 - npm run dev 21 + Even if a team claims a square, the other team can reclaim that square by getting a high score on the map that also meets the claim condition. 22 + ### Play Anywhere 23 23 24 - # or start the server and open the app in a new browser tab 25 - npm run dev -- --open 26 - ``` 24 + Games support submitting scores from both osu!stable and osu!lazer. Play wherever you're most comfortable! 27 25 28 - ## Building 26 + ### Play with Friends 27 + 28 + Host a lobby privately with friends, where only those you send the invite code to can join, or host a public lobby open to anybody who desires! 29 29 30 - To create a production version of your app: 30 + ### Play it your way 31 31 32 - ```bash 33 - npm run build 34 - ``` 32 + The settings for the game are infinitely customizable. Change the claim conditions, edit the claim change timings or remove them all together. Change reclaim conditions, set your own custom board, or pick from a map pool, or make your own custom shaped board all together. 35 33 36 - You can preview the production build with `npm run preview`. 34 + ### Stream-friendly 37 35 38 - > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. 36 + If you're streaming, you can keep your viewers up-to-date without leaving the stream using one of our stream overlays, that will automatically update as the game progresses
+5 -1
frontend/src/lib/components/GameListItem.svelte
··· 16 16 ></div> 17 17 <div> 18 18 <h2 class="text-lg"> 19 - Game {game.link_id} 19 + {#if game.name} 20 + {game.name} 21 + {:else} 22 + Game {game.link_id} 23 + {/if} 20 24 </h2> 21 25 <h3 class="text-lg font-light uppercase text-zinc-600"> 22 26 {text[game.state]}
+55 -15
frontend/src/lib/components/HostSettings.svelte
··· 4 4 import NumberScale from './NumberScale.svelte'; 5 5 import Invite from './Invite.svelte'; 6 6 import DeleteButton from './DeleteButton.svelte'; 7 + import TextInput from './TextInput.svelte'; 7 8 8 9 const changeVisibility = async (is_public: boolean) => { 9 10 const body = new FormData(); 10 11 body.set('public', `${is_public}`); 11 12 await fetch('?/change_settings', { 13 + method: 'POST', 14 + body 15 + }); 16 + }; 17 + 18 + const changeTeamSwitching = async (can_switch: boolean) => { 19 + const body = new FormData(); 20 + body.set('can_switch', `${can_switch}`); 21 + await fetch('?/change_team_switching', { 12 22 method: 'POST', 13 23 body 14 24 }); ··· 36 46 }); 37 47 }; 38 48 49 + const changeName = async (name: string) => { 50 + const body = new FormData(); 51 + body.set('name', name); 52 + await fetch('?/change_name', { 53 + method: 'POST', 54 + body 55 + }); 56 + }; 57 + 39 58 const start = async () => { 40 59 const data = new FormData(); 41 60 await fetch(`?/start_game`, { ··· 49 68 <div class="absolute right-0 h-full w-full p-2"> 50 69 {#if $store} 51 70 <h1 class="pb-4 pt-4 text-center font-rounded text-2xl font-bold">Host Settings</h1> 52 - <div class="flex gap-4"> 53 - <div 54 - class="mb-4 flex w-full flex-col items-center rounded-lg bg-zinc-900/50 p-2 font-rounded font-bold uppercase" 55 - > 56 - Game Visibility 57 - <div class="w-full max-w-[200px] pt-1"> 58 - <ToggleSwitch 59 - onText="Public" 60 - onColor="#16a34a" 61 - offColor="#755780" 62 - offText="Private" 63 - on:update={(e) => changeVisibility(e.detail)} 64 - toggle={$store.public} 71 + <div class="mb-4 flex gap-4"> 72 + <div class="w-full rounded-lg bg-zinc-900/50 p-4 font-rounded font-bold uppercase"> 73 + <div class="w-full pl-1">Lobby Name</div> 74 + <div class="mt-1 w-full"> 75 + <TextInput 76 + value={$store.name ?? ''} 77 + limit={50} 78 + submitDelay={500} 79 + on:change={(e) => changeName(e.detail)} 65 80 /> 66 81 </div> 67 82 </div> 68 - 69 - <div class="mb-4 flex w-full"> 83 + <div class="flex w-full flex-col items-center"> 70 84 <Invite hidden={!$store?.public} linkCode={$store?.link_id ?? ''} /> 85 + </div> 86 + </div> 87 + <div class="mb-4 flex gap-4"> 88 + <div class="flex w-full rounded-lg bg-zinc-900/50 p-2 font-rounded font-bold uppercase"> 89 + <div class="flex w-full flex-col items-center"> 90 + Game Visibility 91 + <div class="w-full max-w-[200px] pt-1"> 92 + <ToggleSwitch 93 + onText="Public" 94 + onColor="#16a34a" 95 + offColor="#755780" 96 + offText="Private" 97 + on:update={(e) => changeVisibility(e.detail)} 98 + toggle={$store.public} 99 + /> 100 + </div> 101 + </div> 102 + <div class="flex w-full flex-col items-center"> 103 + Lock Teams 104 + <div class="w-full max-w-[200px] pt-1"> 105 + <ToggleSwitch 106 + on:update={(e) => changeTeamSwitching(e.detail)} 107 + toggle={$store.allow_team_switching ?? true} 108 + /> 109 + </div> 110 + </div> 71 111 </div> 72 112 </div> 73 113 <div
+1 -1
frontend/src/lib/components/Invite.svelte
··· 19 19 }; 20 20 </script> 21 21 22 - <div class="h-full w-full rounded-lg bg-zinc-900/50 p-2"> 22 + <div class="flex h-full w-full flex-col justify-center rounded-lg bg-zinc-900/50 p-2"> 23 23 <div class="w-full text-center font-rounded font-bold uppercase">Invite Link</div> 24 24 <div class="flex h-8 rounded-lg border-[1px] border-zinc-700 bg-zinc-900"> 25 25 <button
+1 -1
frontend/src/lib/components/TeamList.svelte
··· 91 91 /> 92 92 {/each} 93 93 </div> 94 - {#if invited && $gameStore && $gameStore.state == 0} 94 + {#if invited && $gameStore && $gameStore.state == 0 && $gameStore.allow_team_switching} 95 95 <div class="absolute bottom-0 w-full rounded-xl bg-black/30 p-1"> 96 96 {#if !gameuser?.team_name} 97 97 <button
+32
frontend/src/lib/components/TextInput.svelte
··· 1 + <script lang="ts"> 2 + import { createEventDispatcher } from 'svelte'; 3 + import type { FormEventHandler } from 'svelte/elements'; 4 + 5 + export let submitDelay = 300; 6 + export let limit = 999; 7 + export let allowBlank = false; 8 + export let value: string; 9 + 10 + const dispatch = createEventDispatcher(); 11 + 12 + let timer: Timer; 13 + const input: FormEventHandler<HTMLInputElement> = (ev) => { 14 + const value = ev.currentTarget.value; 15 + if (value.length > limit) { 16 + ev.currentTarget.value = ev.currentTarget.value.slice(0, limit); 17 + return; 18 + } 19 + 20 + if (!allowBlank && value == '') return; 21 + 22 + clearTimeout(timer); 23 + timer = setTimeout(() => dispatch('change', value), submitDelay); 24 + }; 25 + </script> 26 + 27 + <input 28 + type="text" 29 + on:input={input} 30 + {value} 31 + class="text-s h-10 w-full rounded-lg border-[1px] border-zinc-700 bg-zinc-900 px-2 font-rounded outline-none transition-all focus:border-2 focus:border-zinc-600" 32 + />
+31 -2
frontend/src/lib/drizzle/queries/game.ts
··· 6 6 import { invitedTeam, kickedTeam, noneTeam } from './gameuser'; 7 7 import type { Options } from '$lib/gamerules/options'; 8 8 9 - export const newGame = async () => { 9 + export const newGame = async (name?: string) => { 10 10 const randomLetter = () => { 11 11 const A = 65; 12 12 const random = Math.floor(Math.random() * 26); ··· 32 32 await db 33 33 .insert(BingoGame) 34 34 .values({ 35 - link_id 35 + link_id, 36 + name: name ?? `Game ${link_id}` 36 37 }) 37 38 .returning() 38 39 )[0]; ··· 251 252 await db.update(BingoGame).set({ options: JSON.stringify(options) }).where(eq(BingoGame.id, game_id)).returning() 252 253 )[0]; 253 254 logger.silly('Finished db request', { function: 'updateGameSettings', obj: 'query', dir: 'end' }); 255 + if (!q) return null; 256 + return q; 257 + }; 258 + 259 + export const changeGameName = async (game_id: string, name: string) => { 260 + logger.silly('Started db request', { 261 + function: 'changeGameName', 262 + obj: 'query', 263 + dir: 'start' 264 + }); 265 + const q = ( 266 + await db.update(BingoGame).set({ name }).where(eq(BingoGame.id, game_id)).returning() 267 + )[0]; 268 + logger.silly('Finished db request', { function: 'changeGameName', obj: 'query', dir: 'end' }); 269 + if (!q) return null; 270 + return q; 271 + }; 272 + 273 + export const changeTeamSwitchSetting = async (game_id: string, allow_team_switching: boolean) => { 274 + logger.silly('Started db request', { 275 + function: 'changeTeamSwitching', 276 + obj: 'query', 277 + dir: 'start' 278 + }); 279 + const q = ( 280 + await db.update(BingoGame).set({ allow_team_switching }).where(eq(BingoGame.id, game_id)).returning() 281 + )[0]; 282 + logger.silly('Finished db request', { function: 'changeTeamSwitching', obj: 'query', dir: 'end' }); 254 283 if (!q) return null; 255 284 return q; 256 285 };
+52 -2
frontend/src/routes/(main)/game/[id]/+page.server.ts
··· 56 56 const game = await q.gameExists(linkId); 57 57 58 58 if (!user) error(StatusCodes.UNAUTHORIZED); 59 - if (!team || !game || typeof team != 'string') error(StatusCodes.BAD_REQUEST); 59 + if (!game || (team && typeof team != 'string')) error(StatusCodes.BAD_REQUEST); 60 60 61 61 if (!game.public) { 62 62 const invited = await q.isInvited(game.id, user.id); 63 63 if (!invited) error(StatusCodes.UNAUTHORIZED); 64 64 } 65 65 66 - const fulluser = await q.joinGame(game.id, user.id, team); 66 + const fulluser = await q.joinGame(game.id, user.id, team ?? undefined); 67 67 if (fulluser == null) error(StatusCodes.BAD_REQUEST, 'User is already in game'); 68 68 69 69 sendToGame(game.id, { ··· 276 276 277 277 const success = await q.deleteGame(game_check.id); 278 278 if (!success) error(StatusCodes.BAD_REQUEST); 279 + }, 280 + change_name: async ({ params, locals, request }) => { 281 + const user = locals.user; 282 + const linkId = params.id; 283 + const game = await q.getGame(`gam_${linkId}`); 284 + 285 + const body = await request.formData(); 286 + 287 + const name = body.get("name") 288 + if (!name || typeof name != 'string' || name.length > 50) error(StatusCodes.BAD_REQUEST); 289 + 290 + if (!user) error(StatusCodes.UNAUTHORIZED); 291 + if (!game) error(StatusCodes.BAD_REQUEST); 292 + 293 + const is_host = await q.isHost(game.id, user.id); 294 + if (!is_host) error(StatusCodes.UNAUTHORIZED); 295 + 296 + const success = await q.changeGameName(game.id, name); 297 + if (!success) error(StatusCodes.BAD_REQUEST); 298 + 299 + game.name = name; 300 + sendToGame(game.id, { 301 + type: 'fullUpdate', 302 + data: game 303 + }); 304 + }, 305 + change_team_switching: async ({ params, locals, request }) => { 306 + const user = locals.user; 307 + const linkId = params.id; 308 + const game = await q.getGame(`gam_${linkId}`); 309 + 310 + const body = await request.formData(); 311 + 312 + const can_switch = body.get('can_switch') === 'true'; 313 + if (!body.has('can_switch') || typeof can_switch != 'boolean') error(StatusCodes.BAD_REQUEST) 314 + 315 + if (!user) error(StatusCodes.UNAUTHORIZED); 316 + if (!game) error(StatusCodes.BAD_REQUEST); 317 + 318 + const is_host = await q.isHost(game.id, user.id); 319 + if (!is_host) error(StatusCodes.UNAUTHORIZED); 320 + 321 + const success = await q.changeTeamSwitchSetting(game.id, can_switch); 322 + if (!success) error(StatusCodes.BAD_REQUEST); 323 + 324 + game.allow_team_switching = can_switch; 325 + sendToGame(game.id, { 326 + type: 'fullUpdate', 327 + data: game 328 + }); 279 329 }, 280 330 };
+62 -18
frontend/src/routes/(main)/game/[id]/+page.svelte
··· 15 15 import WinConfetti from '$lib/components/WinConfetti.svelte'; 16 16 import PageContainer from '$lib/components/PageContainer.svelte'; 17 17 import { getRules } from '$lib/gamerules/get_rules'; 18 + import { browser } from '$app/environment'; 18 19 19 20 export let data: PageData; 20 21 ··· 56 57 store.set(null); 57 58 square.set(null); 58 59 }); 60 + 61 + const leave = async () => { 62 + await fetch(`?/leave_game`, { 63 + method: 'POST', 64 + body: new FormData() 65 + }); 66 + }; 67 + 68 + const join = async () => { 69 + if (!data.user) { 70 + if (browser) { 71 + const params = new URLSearchParams(); 72 + params.set('from', window.location.href + `?join`); 73 + window.location.href = `/auth/login/osu?${params.toString()}`; 74 + } 75 + } 76 + 77 + const form = new FormData(); 78 + await fetch(`?/join_game`, { 79 + body: form, 80 + method: 'POST' 81 + }); 82 + }; 59 83 </script> 60 84 61 85 <svelte:head> ··· 81 105 <div class="grid"> 82 106 {#if $store} 83 107 <InterfaceGrids {host} state={$store.state}> 84 - <article slot="player-list" class="grid h-full grid-rows-2 gap-y-2 bg-zinc-900"> 85 - <div class="h-full w-full"> 86 - <TeamList 87 - invited={data.invited} 88 - team="BLUE" 89 - gameStore={store} 90 - host={data.is_host} 91 - user={data.user} 92 - /> 93 - </div> 94 - <div class="h-full w-full"> 95 - <TeamList 96 - invited={data.invited} 97 - team="RED" 98 - gameStore={store} 99 - host={data.is_host} 100 - user={data.user} 101 - /> 108 + <article slot="player-list" class="flex h-full flex-col bg-zinc-900"> 109 + <div class="grid h-full grid-rows-2 gap-y-2"> 110 + <div class="h-full w-full"> 111 + <TeamList 112 + invited={data.invited} 113 + team="BLUE" 114 + gameStore={store} 115 + host={data.is_host} 116 + user={data.user} 117 + /> 118 + </div> 119 + <div class="h-full w-full"> 120 + <TeamList 121 + invited={data.invited} 122 + team="RED" 123 + gameStore={store} 124 + host={data.is_host} 125 + user={data.user} 126 + /> 127 + </div> 102 128 </div> 129 + {#if !$store.allow_team_switching} 130 + <div> 131 + {#if !currentTeam} 132 + <button 133 + on:click={join} 134 + class="h-12 w-full rounded-lg bg-green-600 p-1 px-2 font-rounded text-xl font-bold transition hover:bg-green-700 active:bg-green-800" 135 + >Join Game</button 136 + > 137 + {:else} 138 + <button 139 + on:click={leave} 140 + class="h-12 w-full rounded-lg bg-amber-600 p-1 px-2 font-rounded text-xl font-bold transition" 141 + > 142 + Leave Game 143 + </button> 144 + {/if} 145 + </div> 146 + {/if} 103 147 </article> 104 148 105 149 <article slot="board" class="grid aspect-square">
+1 -1
frontend/src/routes/(main)/games/new/+server.ts
··· 14 14 error(StatusCodes.BAD_REQUEST, "You already hosting an active game!") 15 15 } 16 16 17 - const game = await q.newGame(); 17 + const game = await q.newGame(`${locals.user.username}'s game`); 18 18 await q.joinGame(game.id, locals.user.id, noneTeam); 19 19 await q.setHost(game.id, locals.user.id); 20 20 registerGame(game.id);