├── .husky ├── .gitignore ├── pre-commit └── commit-msg ├── assets ├── og.png └── images │ └── cards │ ├── back.png │ ├── blank.png │ ├── blue0.png │ ├── blue1.png │ ├── blue2.png │ ├── blue3.png │ ├── blue4.png │ ├── blue5.png │ ├── blue6.png │ ├── blue7.png │ ├── blue8.png │ ├── blue9.png │ ├── green0.png │ ├── green1.png │ ├── green2.png │ ├── green3.png │ ├── green4.png │ ├── green5.png │ ├── green6.png │ ├── green7.png │ ├── green8.png │ ├── green9.png │ ├── red0.png │ ├── red1.png │ ├── red2.png │ ├── red3.png │ ├── red4.png │ ├── red5.png │ ├── red6.png │ ├── red7.png │ ├── red8.png │ ├── red9.png │ ├── wild.png │ ├── blueskip.png │ ├── reddraw2.png │ ├── redskip.png │ ├── unodeck.png │ ├── wildblue.png │ ├── wildred.png │ ├── yellow0.png │ ├── yellow1.png │ ├── yellow2.png │ ├── yellow3.png │ ├── yellow4.png │ ├── yellow5.png │ ├── yellow6.png │ ├── yellow7.png │ ├── yellow8.png │ ├── yellow9.png │ ├── bluedraw2.png │ ├── bluereverse.png │ ├── greendraw2.png │ ├── greenskip.png │ ├── redreverse.png │ ├── wilddraw4.png │ ├── wildgreen.png │ ├── wildyellow.png │ ├── yellowdraw2.png │ ├── yellowskip.png │ ├── greenreverse.png │ ├── wilddraw4blue.png │ ├── wilddraw4red.png │ ├── yellowreverse.png │ ├── wilddraw4green.png │ └── wilddraw4yellow.png ├── .vscode ├── settings.json └── launch.json ├── commitlint.config.js ├── src ├── index.ts ├── lib │ ├── index.ts │ ├── Chat.ts │ └── Game.ts ├── utils │ ├── index.ts │ ├── userHandler.ts │ ├── miniutils.ts │ ├── imageHandler.ts │ └── validator.ts ├── controller │ ├── draw.ts │ ├── cards.ts │ ├── endgame.ts │ ├── joingame.ts │ ├── infogame.ts │ ├── play.ts │ ├── creategame.ts │ ├── leaderboard.ts │ ├── say.ts │ ├── startgame.ts │ ├── kick.ts │ ├── ban.ts │ └── leavegame.ts ├── env.ts ├── handler │ ├── database.ts │ ├── help.ts │ ├── controller.ts │ ├── emitter.ts │ └── message.ts ├── bot.ts └── config │ ├── messages.ts │ └── cards.ts ├── ecosystem.config.js ├── env.sample ├── nodemon.json ├── jest.config.js ├── tsup.config.ts ├── .eslintrc.json ├── .github ├── workflows │ ├── unit-test.yml │ ├── typedoc-generation.yml │ ├── lint-typing.yml │ └── codeql-analysis.yml └── actions │ └── pnpm-install │ └── action.yml ├── .gitignore ├── test └── core │ ├── getCardState.test.ts │ └── cardComparer.test.ts ├── package.json ├── prisma └── schema.prisma ├── tsconfig.json └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /assets/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/og.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules\\typescript\\lib" 3 | } 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Bot from "./bot"; 2 | 3 | const bot = new Bot("WUNO_BOT"); 4 | bot.init(); 5 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Card"; 2 | export * from "./Game"; 3 | export * from "./Chat"; 4 | -------------------------------------------------------------------------------- /assets/images/cards/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/back.png -------------------------------------------------------------------------------- /assets/images/cards/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blank.png -------------------------------------------------------------------------------- /assets/images/cards/blue0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue0.png -------------------------------------------------------------------------------- /assets/images/cards/blue1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue1.png -------------------------------------------------------------------------------- /assets/images/cards/blue2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue2.png -------------------------------------------------------------------------------- /assets/images/cards/blue3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue3.png -------------------------------------------------------------------------------- /assets/images/cards/blue4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue4.png -------------------------------------------------------------------------------- /assets/images/cards/blue5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue5.png -------------------------------------------------------------------------------- /assets/images/cards/blue6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue6.png -------------------------------------------------------------------------------- /assets/images/cards/blue7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue7.png -------------------------------------------------------------------------------- /assets/images/cards/blue8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue8.png -------------------------------------------------------------------------------- /assets/images/cards/blue9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blue9.png -------------------------------------------------------------------------------- /assets/images/cards/green0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green0.png -------------------------------------------------------------------------------- /assets/images/cards/green1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green1.png -------------------------------------------------------------------------------- /assets/images/cards/green2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green2.png -------------------------------------------------------------------------------- /assets/images/cards/green3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green3.png -------------------------------------------------------------------------------- /assets/images/cards/green4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green4.png -------------------------------------------------------------------------------- /assets/images/cards/green5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green5.png -------------------------------------------------------------------------------- /assets/images/cards/green6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green6.png -------------------------------------------------------------------------------- /assets/images/cards/green7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green7.png -------------------------------------------------------------------------------- /assets/images/cards/green8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green8.png -------------------------------------------------------------------------------- /assets/images/cards/green9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/green9.png -------------------------------------------------------------------------------- /assets/images/cards/red0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red0.png -------------------------------------------------------------------------------- /assets/images/cards/red1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red1.png -------------------------------------------------------------------------------- /assets/images/cards/red2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red2.png -------------------------------------------------------------------------------- /assets/images/cards/red3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red3.png -------------------------------------------------------------------------------- /assets/images/cards/red4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red4.png -------------------------------------------------------------------------------- /assets/images/cards/red5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red5.png -------------------------------------------------------------------------------- /assets/images/cards/red6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red6.png -------------------------------------------------------------------------------- /assets/images/cards/red7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red7.png -------------------------------------------------------------------------------- /assets/images/cards/red8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red8.png -------------------------------------------------------------------------------- /assets/images/cards/red9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/red9.png -------------------------------------------------------------------------------- /assets/images/cards/wild.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wild.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /assets/images/cards/blueskip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/blueskip.png -------------------------------------------------------------------------------- /assets/images/cards/reddraw2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/reddraw2.png -------------------------------------------------------------------------------- /assets/images/cards/redskip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/redskip.png -------------------------------------------------------------------------------- /assets/images/cards/unodeck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/unodeck.png -------------------------------------------------------------------------------- /assets/images/cards/wildblue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wildblue.png -------------------------------------------------------------------------------- /assets/images/cards/wildred.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wildred.png -------------------------------------------------------------------------------- /assets/images/cards/yellow0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow0.png -------------------------------------------------------------------------------- /assets/images/cards/yellow1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow1.png -------------------------------------------------------------------------------- /assets/images/cards/yellow2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow2.png -------------------------------------------------------------------------------- /assets/images/cards/yellow3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow3.png -------------------------------------------------------------------------------- /assets/images/cards/yellow4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow4.png -------------------------------------------------------------------------------- /assets/images/cards/yellow5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow5.png -------------------------------------------------------------------------------- /assets/images/cards/yellow6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow6.png -------------------------------------------------------------------------------- /assets/images/cards/yellow7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow7.png -------------------------------------------------------------------------------- /assets/images/cards/yellow8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow8.png -------------------------------------------------------------------------------- /assets/images/cards/yellow9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellow9.png -------------------------------------------------------------------------------- /assets/images/cards/bluedraw2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/bluedraw2.png -------------------------------------------------------------------------------- /assets/images/cards/bluereverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/bluereverse.png -------------------------------------------------------------------------------- /assets/images/cards/greendraw2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/greendraw2.png -------------------------------------------------------------------------------- /assets/images/cards/greenskip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/greenskip.png -------------------------------------------------------------------------------- /assets/images/cards/redreverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/redreverse.png -------------------------------------------------------------------------------- /assets/images/cards/wilddraw4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wilddraw4.png -------------------------------------------------------------------------------- /assets/images/cards/wildgreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wildgreen.png -------------------------------------------------------------------------------- /assets/images/cards/wildyellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wildyellow.png -------------------------------------------------------------------------------- /assets/images/cards/yellowdraw2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellowdraw2.png -------------------------------------------------------------------------------- /assets/images/cards/yellowskip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellowskip.png -------------------------------------------------------------------------------- /assets/images/cards/greenreverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/greenreverse.png -------------------------------------------------------------------------------- /assets/images/cards/wilddraw4blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wilddraw4blue.png -------------------------------------------------------------------------------- /assets/images/cards/wilddraw4red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wilddraw4red.png -------------------------------------------------------------------------------- /assets/images/cards/yellowreverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/yellowreverse.png -------------------------------------------------------------------------------- /assets/images/cards/wilddraw4green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wilddraw4green.png -------------------------------------------------------------------------------- /assets/images/cards/wilddraw4yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reacto11mecha/wuno-bot/HEAD/assets/images/cards/wilddraw4yellow.png -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: "wuno-bot", 5 | script: "./dist/index.js", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./miniutils"; 2 | export * from "./userHandler"; 3 | export * from "./validator"; 4 | export * from "./imageHandler"; 5 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | PREFIX="U#" 2 | DATABASE_URL="mysql://user:pw@localhost:3306/wuno" 3 | CHROME_PATH="C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe" -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "execMap": { 3 | "ts": "node --require ts-node/register" 4 | }, 5 | "ignore": ["_IGNORE_WUNO_BOT", "WUNO_BOT.data.json", "dist"] 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | rootDir: "test", 6 | }; 7 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | "src/index.ts", 6 | "src/bot.ts", 7 | "src/env.ts", 8 | "src/lib/*.ts", 9 | "src/utils/*.ts", 10 | "src/config/*.ts", 11 | "src/handler/*.ts", 12 | "src/controller/*.ts", 13 | ], 14 | splitting: true, 15 | sourcemap: true, 16 | clean: true, 17 | }); 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint"], 17 | "rules": { 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/no-unused-vars": "error" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/controller/draw.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession } from "../utils"; 2 | 3 | export default requiredJoinGameSession(async ({ chat, card, game }) => { 4 | if (game.state.WAITING) { 5 | await chat.replyToCurrentPerson("Sedang menunggu permainan dimulai!"); 6 | } else if (game.state.ENDED) { 7 | await chat.replyToCurrentPerson("Game sudah selesai!"); 8 | } else if (game.isCurrentChatTurn) { 9 | await card.drawToCurrentPlayer(); 10 | } else { 11 | await chat.replyToCurrentPerson("Bukan giliranmu saat ini!"); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-core"; 2 | import { config } from "dotenv"; 3 | import { z } from "zod"; 4 | 5 | config(); 6 | 7 | export const env = createEnv({ 8 | /* 9 | * Specify what prefix the client-side variables must have. 10 | * This is enforced both on type-level and at runtime. 11 | */ 12 | clientPrefix: "PUBLIC_", 13 | server: { 14 | PREFIX: z.preprocess((value) => value ?? "U#", z.string().min(1)), 15 | DATABASE_URL: z.string().url(), 16 | CHROME_PATH: z.string().min(1), 17 | }, 18 | client: {}, 19 | runtimeEnv: process.env, 20 | }); 21 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit test 2 | on: [pull_request, push] 3 | 4 | env: 5 | DATABASE_URL: "mysql://root:password@localhost:3306/random-name" 6 | CHROME_PATH: "/usr/bin/google-chrome-stable" 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | name: Mengecek apakah kode lulus unit test jest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Setup NodeJS 18 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18 19 | 20 | - name: PNPM install 21 | uses: ./.github/actions/pnpm-install 22 | 23 | - name: Run unit test 24 | run: pnpm test 25 | -------------------------------------------------------------------------------- /.github/workflows/typedoc-generation.yml: -------------------------------------------------------------------------------- 1 | name: Typedoc Generation 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | docs: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup NodeJS 18 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | 18 | - name: PNPM Install 19 | uses: ./.github/actions/pnpm-install 20 | 21 | - name: Build compiled typedoc 22 | run: pnpm build:doc 23 | 24 | - name: Deploy 25 | uses: JamesIves/github-pages-deploy-action@v4 26 | with: 27 | branch: gh-pages 28 | folder: docs 29 | -------------------------------------------------------------------------------- /.github/workflows/lint-typing.yml: -------------------------------------------------------------------------------- 1 | name: ES Lint & Typing test 2 | on: [pull_request, push] 3 | 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-latest 7 | name: Mengecek apakah file aman lint eslint dan typescript 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Setup NodeJS 18 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | 16 | - name: PNPM Install 17 | uses: ./.github/actions/pnpm-install 18 | 19 | - name: Test Prettier 20 | run: pnpm format:check 21 | 22 | - name: Test Code Linting 23 | run: pnpm lint 24 | 25 | - name: Test Code Typing 26 | run: pnpm typecheck 27 | -------------------------------------------------------------------------------- /src/controller/cards.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession, createCardsImageFront } from "../utils"; 2 | 3 | import type { allCard } from "../config/cards"; 4 | 5 | export default requiredJoinGameSession(async ({ chat, game, card }) => { 6 | if (game.state.WAITING) { 7 | await chat.replyToCurrentPerson("Sedang menunggu permainan dimulai!"); 8 | } else if (game.state.ENDED) { 9 | await chat.replyToCurrentPerson("Game sudah selesai!"); 10 | } else { 11 | const image = await createCardsImageFront(card.cards! as allCard[]); 12 | const cards = card.cards!.join(", "); 13 | 14 | await chat.replyToCurrentPerson( 15 | { caption: `Kartu kamu: ${cards}.` }, 16 | image, 17 | ); 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/handler/database.ts: -------------------------------------------------------------------------------- 1 | import { Prisma, PrismaClient } from "@prisma/client"; 2 | 3 | export * from "@prisma/client"; 4 | 5 | const globalForPrisma = globalThis as { prisma?: PrismaClient }; 6 | 7 | export const prisma = 8 | globalForPrisma.prisma || 9 | new PrismaClient({ 10 | log: 11 | process.env.NODE_ENV === "development" 12 | ? ["query", "error", "warn"] 13 | : ["error"], 14 | }); 15 | 16 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 17 | 18 | export type FullGameType = Prisma.GameGetPayload<{ 19 | include: { 20 | cards: true; 21 | bannedPlayers: true; 22 | playerOrders: true; 23 | allPlayers: true; 24 | }; 25 | }>; 26 | 27 | export type FullUserCardType = Prisma.UserCardGetPayload<{ 28 | include: { 29 | cards: true; 30 | }; 31 | }>; 32 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest: current file", 11 | //"env": { "NODE_ENV": "test" }, 12 | "program": "${workspaceFolder}/node_modules/.bin/jest", 13 | "args": ["${fileBasenameNoExtension}", "--config", "jest.config.js"], 14 | "console": "integratedTerminal", 15 | "windows": { 16 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 17 | } 18 | }, 19 | { 20 | "name": "Bot development w/ debugging", 21 | "request": "launch", 22 | "runtimeArgs": ["run-script", "debug"], 23 | "runtimeExecutable": "npm", 24 | "skipFiles": ["/**"], 25 | "type": "node" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/controller/endgame.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession } from "../utils"; 2 | 3 | export default requiredJoinGameSession(async ({ chat, game }) => { 4 | if (game.NotFound) { 5 | await chat.replyToCurrentPerson("Game tidak ditemukan."); 6 | } else if (game.isGameCreator) { 7 | if (!game.state.ENDED) { 8 | const playerList = game.players.filter( 9 | (player) => player.playerId !== chat.user!.id, 10 | ); 11 | 12 | const creator = await game.getCreatorUser(); 13 | 14 | await game.endGame(); 15 | 16 | await Promise.all([ 17 | chat.replyToCurrentPerson( 18 | "Game berhasil dihentikan. Terimakasih sudah bermain!", 19 | ), 20 | game.sendToSpecificPlayerList( 21 | `${ 22 | creator!.username 23 | } telah menghentikan permainan. Terimakasih sudah bermain!`, 24 | playerList, 25 | ), 26 | ]); 27 | } else { 28 | await chat.replyToCurrentPerson("Game sudah dihentikan!"); 29 | } 30 | } else { 31 | await chat.replyToCurrentPerson("Kamu bukan orang yang membuat gamenya!"); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /src/handler/help.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "../lib/Chat"; 2 | import { getController } from "./controller"; 3 | 4 | import { helpTemplate, replies } from "../config/messages"; 5 | 6 | /** 7 | * Help command handler function 8 | * @param controller Array of controllers object 9 | * @returns void 10 | */ 11 | export const handleHelpCommand = 12 | (controller: Awaited>) => 13 | async (chat: Chat) => { 14 | const commands = Object.keys(controller); 15 | 16 | const choosenCommand: string = chat.args 17 | .join("") 18 | .trim() 19 | .replace(" ", "") 20 | .toLocaleLowerCase(); 21 | 22 | if (choosenCommand && !commands.includes(choosenCommand)) { 23 | await chat.sendToCurrentPerson( 24 | `Tidak ada perintah yang bernama "${choosenCommand}"`, 25 | ); 26 | } else if (choosenCommand && commands.includes(choosenCommand)) { 27 | if (Object.keys(replies).includes(choosenCommand)) { 28 | await chat.sendToCurrentPerson(Object(replies)[choosenCommand]); 29 | } 30 | } else { 31 | await chat.sendToCurrentPerson(helpTemplate(commands)); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/handler/controller.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | let dirPath = path.join(__dirname, "..", "controller"); 5 | 6 | // This is for compiled file in production mode 7 | if (!fs.existsSync(dirPath)) { 8 | dirPath = path.join(__dirname, "controller"); 9 | } 10 | 11 | const files = fs 12 | .readdirSync(dirPath) 13 | .filter((file) => file.endsWith(".js") || !file.includes(".d.ts")) 14 | .filter((file) => !file.endsWith(".map")); 15 | 16 | /** 17 | * Lists of all controller name 18 | */ 19 | export const controllerName = files.map((file) => 20 | file.replace(".js", "").replace(".ts", ""), 21 | ); 22 | 23 | /** 24 | * Function that call all of the controller from controller directory 25 | * @returns List of all controllers object 26 | */ 27 | export async function getController() { 28 | const controller = await Promise.all( 29 | files.map(async (file) => { 30 | const keyName = file.replace(".js", "").replace(".ts", ""); 31 | const calledFunction = await import(path.join(dirPath, file)); 32 | 33 | if (calledFunction.default === undefined) 34 | throw new Error(`${keyName} is not a function`); 35 | 36 | return { 37 | [keyName]: calledFunction.default, 38 | }; 39 | }), 40 | ); 41 | 42 | return controller.reduce((curr, acc) => Object.assign(curr, acc)); 43 | } 44 | -------------------------------------------------------------------------------- /src/controller/joingame.ts: -------------------------------------------------------------------------------- 1 | import { atLeastGameID } from "../utils"; 2 | 3 | import { env } from "../env"; 4 | 5 | export default atLeastGameID( 6 | async ({ chat, game }) => { 7 | if (game.state.PLAYING) { 8 | return await chat.replyToCurrentPerson( 9 | "Game ini sedang bermain, konfirmasikan ke orang yang membuat game atau tunggu giliran selanjutnya!", 10 | ); 11 | } else if (game.state.ENDED) { 12 | return await chat.replyToCurrentPerson("Game ini sudah selesai!"); 13 | } 14 | 15 | if (game.isPlayerGotBanned(chat.user!.id)) 16 | return await chat.sendToCurrentPerson( 17 | "Kamu sudah di banned dari permainan ini!", 18 | ); 19 | 20 | // Copy all available players before new player join in 21 | const playerList = [...game.players]; 22 | 23 | await game.joinGame(); 24 | 25 | await Promise.all([ 26 | await chat.replyToCurrentPerson( 27 | `Berhasil join ke game "${game.gameID}", tunggu pembuat ruang game ini memulai permainannya!`, 28 | ), 29 | await game.sendToSpecificPlayerList( 30 | `Pemain dengan username "${chat.message.userName}" memasuki ruang permainan! Sapa dia dengan menggunakan "${env.PREFIX}say Halo ${chat.message.userName}!"`, 31 | playerList, 32 | ), 33 | ]); 34 | }, 35 | async ({ chat, game }) => 36 | await chat.replyToCurrentPerson( 37 | `Kamu sudah masuk ke sesi game ${ 38 | chat.isGroupChat ? "[REDACTED]" : game.gameID 39 | }`, 40 | ), 41 | ); 42 | -------------------------------------------------------------------------------- /src/handler/emitter.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | import { getController } from "./controller"; 3 | 4 | import { findOrCreateUser, isDMChat } from "../utils"; 5 | import { handleHelpCommand } from "./help"; 6 | 7 | /** 8 | * Event emitter constructor that can be used by the "bone" 9 | * @param controller List of all controllers object 10 | * @returns Event emitter instance that used for handling command to the actual controller 11 | */ 12 | export const emitHandler = ( 13 | controller: Awaited>, 14 | ) => { 15 | const messageHandler = new EventEmitter(); 16 | 17 | messageHandler.on("leaderboard", controller.leaderboard); 18 | 19 | messageHandler.on("creategame", findOrCreateUser(controller.creategame)); 20 | messageHandler.on("joingame", findOrCreateUser(controller.joingame)); 21 | messageHandler.on("infogame", findOrCreateUser(controller.infogame)); 22 | 23 | messageHandler.on( 24 | "startgame", 25 | isDMChat(findOrCreateUser(controller.startgame)), 26 | ); 27 | messageHandler.on("endgame", isDMChat(findOrCreateUser(controller.endgame))); 28 | messageHandler.on( 29 | "leavegame", 30 | isDMChat(findOrCreateUser(controller.leavegame)), 31 | ); 32 | messageHandler.on("play", isDMChat(findOrCreateUser(controller.play))); 33 | messageHandler.on("say", isDMChat(findOrCreateUser(controller.say))); 34 | messageHandler.on("cards", isDMChat(findOrCreateUser(controller.cards))); 35 | messageHandler.on("draw", isDMChat(findOrCreateUser(controller.draw))); 36 | messageHandler.on("kick", isDMChat(findOrCreateUser(controller.kick))); 37 | messageHandler.on("ban", isDMChat(findOrCreateUser(controller.ban))); 38 | 39 | messageHandler.on("help", handleHelpCommand(controller)); 40 | 41 | return messageHandler; 42 | }; 43 | -------------------------------------------------------------------------------- /src/controller/infogame.ts: -------------------------------------------------------------------------------- 1 | import { atLeastGameID, df, type commonCb } from "../utils"; 2 | import { Game } from "../lib"; 3 | 4 | import { prisma } from "../handler/database"; 5 | 6 | const getReplied = async (game: Game) => { 7 | const creator = await game.getCreatorUser(); 8 | const players = await game.getAllPlayerUserObject(); 9 | const currentPlayer = await game.getCurrentPlayerUserData(); 10 | 11 | const mainTemplate = `Pemain yang sudah tergabung: 12 | ${players!.map((player) => `- ${player?.username}`).join("\n")} 13 | 14 | Pembuat game: ${creator?.username}`; 15 | 16 | switch (true) { 17 | case game.state.PLAYING: { 18 | return `${mainTemplate} 19 | 20 | Kartu Saat Ini: ${game.currentCard} 21 | Giliran Pemain Saat Ini: ${ 22 | game.currentPositionId ? currentPlayer?.username : "" 23 | } 24 | 25 | Giliran Bermain: 26 | ${game 27 | .playersOrderIds!.map((player) => players.find((user) => user?.id === player)) 28 | .map((player, idx) => `${idx + 1}. ${player?.username}`) 29 | .join("\n")}`; 30 | } 31 | 32 | case game.state.ENDED: { 33 | if (!game.winner) 34 | return "Permainan dihentikan tanpa ada seorang pemenang."; 35 | 36 | const winner = await prisma.user.findUnique({ 37 | where: { id: game.winner }, 38 | }); 39 | 40 | return `Durasi Permainan: ${game.getElapsedTime()} 41 | Pemenang Permainan: ${winner ? winner.username : ""}`; 42 | } 43 | 44 | // Waiting 45 | default: 46 | return mainTemplate; 47 | } 48 | }; 49 | 50 | const commonCallback: commonCb = async ({ chat, game }) => { 51 | const replied = await getReplied(game); 52 | 53 | await chat.replyToCurrentPerson( 54 | `GAME ID: ${game.gameID} 55 | Game Status: ${game.translatedStatus} 56 | Tanggal Dibuat: ${df(game.created_at)} 57 | 58 | ${replied}`.trim(), 59 | ); 60 | }; 61 | 62 | export default atLeastGameID(commonCallback, commonCallback); 63 | -------------------------------------------------------------------------------- /.github/actions/pnpm-install/action.yml: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/belgattitude/838b2eba30c324f1f0033a797bab2e31 2 | 3 | name: "Monorepo install (pnpm)" 4 | description: "Run pnpm install with cache enabled" 5 | inputs: 6 | enable-corepack: 7 | description: "Enable corepack" 8 | required: false 9 | default: "false" 10 | 11 | runs: 12 | using: "composite" 13 | 14 | steps: 15 | - name: ⚙️ Enable Corepack 16 | if: ${{ inputs.enable-corepack }} == 'true' 17 | shell: bash 18 | working-directory: ${{ inputs.cwd }} 19 | run: corepack enable 20 | 21 | - uses: pnpm/action-setup@v2.2.4 22 | if: ${{ inputs.enable-corepack }} == 'false' 23 | with: 24 | version: 8.6.3 25 | 26 | - name: Expose pnpm config(s) through "$GITHUB_OUTPUT" 27 | id: pnpm-config 28 | shell: bash 29 | run: | 30 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 31 | 32 | - name: Cache rotation keys 33 | id: cache-rotation 34 | shell: bash 35 | run: | 36 | echo "YEAR_MONTH=$(/bin/date -u "+%Y%m")" >> $GITHUB_OUTPUT 37 | 38 | - uses: actions/cache@v3 39 | name: Setup pnpm cache 40 | with: 41 | path: ${{ steps.pnpm-config.outputs.STORE_PATH }} 42 | key: ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}-${{ hashFiles('**/pnpm-lock.yaml') }} 43 | restore-keys: | 44 | ${{ runner.os }}-pnpm-store-cache-${{ steps.cache-rotation.outputs.YEAR_MONTH }}- 45 | 46 | # Prevent store to grow over time (not needed with yarn) 47 | # Note: not perfect as it prune too much in monorepos so the idea 48 | # is to use cache-rotation as above. In the future this might work better. 49 | #- name: Prune pnpm store 50 | # shell: bash 51 | # run: pnpm prune store 52 | 53 | - name: Install dependencies 54 | shell: bash 55 | run: pnpm install --frozen-lockfile --prefer-offline 56 | env: 57 | # Other environment variables 58 | HUSKY: "0" # By default do not run HUSKY install 59 | -------------------------------------------------------------------------------- /src/controller/play.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession } from "../utils"; 2 | import { Card } from "../lib"; 3 | 4 | import { 5 | regexValidWildColorOnly, 6 | regexValidWildColorPlus4Only, 7 | } from "../config/cards"; 8 | import { env } from "../env"; 9 | import type { allCard } from "../config/cards"; 10 | 11 | const isValidWildOrPlus4 = (card: string) => { 12 | return ( 13 | card.match(regexValidWildColorOnly) || 14 | card.match(regexValidWildColorPlus4Only) 15 | ); 16 | }; 17 | 18 | const guessCardIsAlmostValidWildOrPlus4 = (card: string, cardLib: Card) => { 19 | return ( 20 | card.includes("wild") && 21 | !isValidWildOrPlus4(card) && 22 | ["red", "green", "blue", "yellow"] 23 | .map((color) => `${card}${color}`) 24 | .every((guessedCard) => cardLib.isIncluded(guessedCard)) 25 | ); 26 | }; 27 | 28 | export default requiredJoinGameSession(async ({ chat, game, card }) => { 29 | const choosenCard = chat.args 30 | .join("") 31 | .trim() 32 | .replace(" ", "") 33 | .toLocaleLowerCase(); 34 | 35 | if (game.isCurrentChatTurn) { 36 | if (chat.args.length < 1 || choosenCard === "") { 37 | await chat.replyToCurrentPerson("Diperlukan kartu yang ingin dimainkan!"); 38 | } else if (guessCardIsAlmostValidWildOrPlus4(choosenCard, card)) { 39 | await chat.replyToCurrentPerson( 40 | `Kamu memiliki kartu ${choosenCard} tetapi belum ada warnanya. 41 | 42 | Coba tetapkan warna di antara warna \`\`\`red\`\`\` (merah), \`\`\`green\`\`\` (hijau), \`\`\`blue\`\`\` (biru), atau \`\`\`yellow\`\`\` (kuning) dengan menggunakan perintah 43 | 44 | \`\`\`${env.PREFIX}p ${choosenCard} \`\`\``, 45 | ); 46 | } else if (!Card.isValidCard(choosenCard)) { 47 | await chat.replyToCurrentPerson(`${choosenCard} bukanlah sebuah kartu!`); 48 | } else if (!card.isIncluded(choosenCard)) { 49 | await chat.replyToCurrentPerson( 50 | `Kamu tidak memiliki kartu ${choosenCard}!`, 51 | ); 52 | } else { 53 | await card.solve(choosenCard as allCard); 54 | } 55 | } else { 56 | await chat.replyToCurrentPerson("Bukan giliranmu saat ini!"); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /src/controller/creategame.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "../lib"; 2 | import { prisma } from "../handler/database"; 3 | import { nanoid } from "nanoid"; 4 | 5 | import { env } from "../env"; 6 | 7 | export default async function creategame(chat: Chat) { 8 | if (!chat.isJoiningGame) { 9 | const newGame = await prisma.$transaction(async (tx) => { 10 | const userId = chat.user!.id; 11 | const gameID = nanoid(11); 12 | 13 | const game = await tx.game.create({ 14 | data: { 15 | gameID, 16 | gameCreatorId: userId, 17 | allPlayers: { 18 | create: { 19 | playerId: userId, 20 | }, 21 | }, 22 | }, 23 | }); 24 | 25 | await tx.userGameProperty.update({ 26 | where: { 27 | id: userId, 28 | }, 29 | data: { 30 | gameID, 31 | isJoiningGame: true, 32 | }, 33 | }); 34 | 35 | return game; 36 | }); 37 | 38 | chat.logger.info( 39 | `[DB] Berhasil membuat sesi game baru | ${newGame.gameID}`, 40 | ); 41 | 42 | if (chat.isGroupChat) { 43 | await chat.replyToCurrentPerson( 44 | `Game berhasil dibuat.\n\nPemain yang sudah tergabung\n- ${ 45 | chat.user!.username 46 | }\n\nKode: ${newGame.gameID}`, 47 | ); 48 | 49 | await chat.sendToCurrentPerson( 50 | "Ayo semua yang berada di grup ini untuk bermain UNO bersama-sama! Teruskan pesan di bawah ke saya dan tunggu permainan untuk dimulai!", 51 | ); 52 | 53 | await chat.sendToCurrentPerson(`${env.PREFIX}j ${newGame.gameID}`); 54 | 55 | return; 56 | } 57 | 58 | await chat.replyToCurrentPerson( 59 | `Game berhasil dibuat.\nAjak teman kamu untuk bermain.\n\nPemain yang sudah tergabung\n- ${ 60 | chat.user!.username 61 | }\n\nKode: ${newGame.gameID}`, 62 | ); 63 | await chat.replyToCurrentPerson(`${env.PREFIX}j ${newGame.gameID}`); 64 | } else { 65 | await chat.replyToCurrentPerson( 66 | `Kamu sudah masuk ke sesi game: ${ 67 | chat.isGroupChat ? "[REDACTED]" : chat.gameProperty?.gameID 68 | }`, 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/controller/leaderboard.ts: -------------------------------------------------------------------------------- 1 | import type { Chat } from "../lib"; 2 | 3 | import { prisma } from "../handler/database"; 4 | import { calcDuration } from "../utils"; 5 | 6 | export default async function leaderboard(chat: Chat) { 7 | const allGamesWinner = await prisma.game.findMany({ 8 | select: { winnerId: true, started_at: true, ended_at: true }, 9 | where: { 10 | winnerId: { 11 | not: null, 12 | }, 13 | }, 14 | }); 15 | 16 | const allWinnersId = [ 17 | ...new Set(allGamesWinner.map(({ winnerId }) => winnerId)), 18 | ]; 19 | 20 | const winningCount = allWinnersId 21 | .map((playerId) => { 22 | const playerAchievement = allGamesWinner 23 | .filter(({ winnerId }) => winnerId === playerId) 24 | .filter((id) => id !== null); 25 | 26 | return { 27 | playerId, 28 | preCalculatedAverage: playerAchievement.map( 29 | ({ started_at, ended_at }) => 30 | ended_at!.getTime() - started_at!.getTime(), 31 | ), 32 | count: playerAchievement.length, 33 | }; 34 | }) 35 | .sort((a, b) => b.count - a.count); 36 | 37 | const eligibleLeaderboard = await Promise.all( 38 | winningCount.slice(0, 10).map(async (winner) => { 39 | const user = await prisma.user.findUnique({ 40 | select: { 41 | username: true, 42 | }, 43 | where: { 44 | id: winner.playerId!, 45 | }, 46 | }); 47 | 48 | const sumAverage = winner.preCalculatedAverage.reduce( 49 | (curr, acc) => curr + acc, 50 | 0, 51 | ); 52 | const averagePlayingTime = 53 | sumAverage / winner.preCalculatedAverage.length; 54 | 55 | const average = calcDuration(averagePlayingTime); 56 | 57 | return { 58 | average, 59 | username: user?.username ?? "????? (Pemain Sudah Dihapus)", 60 | count: winner.count, 61 | }; 62 | }), 63 | ); 64 | 65 | chat.replyToCurrentPerson(`Papan peringkat pemain saat ini 66 | ===================== 67 | ${eligibleLeaderboard 68 | .map( 69 | (winner, idx) => 70 | `${idx + 1}. ${winner.username} - Menang ${ 71 | winner.count 72 | } kali, rata rata durasi ${winner.average}.`, 73 | ) 74 | .join("\n") 75 | .trim()}`); 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/userHandler.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "../lib/Chat"; 2 | import { prisma } from "../handler/database"; 3 | 4 | /** 5 | * Util for finding or create user if it doesn't exist 6 | * @param callback Callback that has basic chat instance parameter 7 | * @returns void 8 | */ 9 | export const findOrCreateUser = 10 | (callback: (chat: Chat) => Promise) => async (chat: Chat) => { 11 | const user = await prisma.user.findFirst({ 12 | where: { 13 | phoneNumber: chat.message.userNumber, 14 | }, 15 | }); 16 | 17 | if (!user) { 18 | try { 19 | const newUser = await prisma.user.create({ 20 | data: { 21 | phoneNumber: chat.message.userNumber, 22 | username: chat.message.userName, 23 | }, 24 | }); 25 | const newGameProperty = await prisma.userGameProperty.create({ 26 | data: { 27 | userId: newUser.id, 28 | }, 29 | }); 30 | 31 | chat.logger.info( 32 | `[DB] Berhasil mendaftarkan user dengan username: ${chat.message.userName}`, 33 | ); 34 | 35 | chat.setUserAndGameProperty(newUser, newGameProperty); 36 | await callback(chat); 37 | } catch (error) { 38 | chat.logger.error(error); 39 | 40 | await chat.replyToCurrentPerson( 41 | "Terjadi Sebuah Kesalahan. Mohon coba sekali lagi perintah ini. Jika masih berlanjut hubungi administrator.", 42 | ); 43 | } 44 | } else { 45 | let gameProperty = await prisma.userGameProperty.findUnique({ 46 | where: { 47 | userId: user.id, 48 | }, 49 | }); 50 | 51 | if (!gameProperty) { 52 | gameProperty = await prisma.userGameProperty.create({ 53 | data: { 54 | userId: user.id, 55 | }, 56 | }); 57 | } 58 | 59 | if (user.username !== chat.message.userName) { 60 | const updated = await prisma.user.update({ 61 | where: { 62 | id: user.id, 63 | }, 64 | data: { 65 | username: chat.message.userName, 66 | }, 67 | }); 68 | 69 | chat.setUserAndGameProperty(updated, gameProperty); 70 | await callback(chat); 71 | 72 | return; 73 | } 74 | 75 | chat.setUserAndGameProperty(user, gameProperty); 76 | await callback(chat); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # baileys stuff 107 | auth_info_baileys/ 108 | baileys_store_multi.json 109 | 110 | # whatsapp web js stuff 111 | .wwebjs_auth/ 112 | .wwebjs_cache 113 | 114 | # typedoc 115 | docs/ 116 | 117 | # pnpmn't lockfile 118 | package-lock.json 119 | yarn.lock -------------------------------------------------------------------------------- /test/core/getCardState.test.ts: -------------------------------------------------------------------------------- 1 | import { CardPicker, EGetCardState } from "../../src/config/cards"; 2 | import type { allCard } from "../../src/config/cards"; 3 | 4 | const allColor = [ 5 | { color: "red" }, 6 | { color: "green" }, 7 | { color: "blue" }, 8 | { color: "yellow" }, 9 | ]; 10 | const allSpecialCard = [ 11 | { type: "reverse" }, 12 | { type: "skip" }, 13 | { type: "draw2" }, 14 | ]; 15 | const allValidNumbers = Array.from({ length: 10 }).map((_, number) => ({ 16 | number, 17 | })); 18 | 19 | describe("Get card state unit test", () => { 20 | it("Function should return invalid state", () => { 21 | const { state } = CardPicker.getCardState("definitelynotacard" as allCard); 22 | 23 | expect(state).toBe(EGetCardState.INVALID); 24 | }); 25 | 26 | describe.each(allColor)( 27 | "Check normal number card with $color color is VALID_NORMAL", 28 | ({ color }) => { 29 | test.each(allValidNumbers)( 30 | `The card ${color}$number should be a VALID_NORMAL`, 31 | ({ number }) => { 32 | const cardState = CardPicker.getCardState( 33 | `${color}${number}` as allCard, 34 | ); 35 | 36 | expect(cardState.state).toBe(EGetCardState.VALID_NORMAL); 37 | }, 38 | ); 39 | }, 40 | ); 41 | 42 | describe.each(allColor)( 43 | "Check plus 4 card with specific $color color is VALID_WILD_PLUS4", 44 | ({ color }) => { 45 | it(`Card wilddraw4${color} is VALID_WILD`, () => { 46 | const cardState = CardPicker.getCardState( 47 | `wilddraw4${color}` as allCard, 48 | ); 49 | 50 | expect(cardState.state).toBe(EGetCardState.VALID_WILD_PLUS4); 51 | }); 52 | }, 53 | ); 54 | 55 | describe.each(allColor)( 56 | "Check wild card with $color color is VALID_WILD", 57 | ({ color }) => { 58 | it(`Card wild${color} is VALID_WILD`, () => { 59 | const cardState = CardPicker.getCardState(`wild${color}` as allCard); 60 | 61 | expect(cardState.state).toBe(EGetCardState.VALID_WILD); 62 | }); 63 | }, 64 | ); 65 | 66 | describe.each(allColor)( 67 | "Check special card with $color color is special card according to type", 68 | ({ color }) => { 69 | test.each(allSpecialCard)( 70 | `Check if the ${color}$type card is valid special and in the $type type`, 71 | ({ type }) => { 72 | const cardState = CardPicker.getCardState( 73 | `${color}${type}` as allCard, 74 | ); 75 | 76 | expect(cardState.state).toBe(EGetCardState.VALID_SPECIAL); 77 | expect(cardState.type).toBe(type); 78 | }, 79 | ); 80 | }, 81 | ); 82 | }); 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wuno-bot", 3 | "version": "1.0.0", 4 | "description": "Bot whatsapp yang berguna untuk bermain UNO", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "dev": "nodemon src/index.ts", 10 | "debug": "nodemon --inspect src/index.ts", 11 | "start": "node .", 12 | "build": "tsup", 13 | "build:doc": "typedoc", 14 | "build:all": "tsc && typedoc", 15 | "typecheck": "tsc --noEmit", 16 | "typecheck:watch": "tsc --noEmit --watch", 17 | "format": "prettier -w ./src ./test", 18 | "format:check": "prettier --check ./src ./test", 19 | "db:generate": "prisma generate", 20 | "db:push": "prisma db push --skip-generate", 21 | "lint": "eslint ./src ./test", 22 | "postinstall": "prisma generate", 23 | "prepare": "husky install" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/reacto11mecha/wuno-bot.git" 28 | }, 29 | "author": "Ezra Khairan Permana", 30 | "license": "MIT", 31 | "keywords": [ 32 | "typescript", 33 | "uno", 34 | "whatsapp-bot" 35 | ], 36 | "bugs": { 37 | "url": "https://github.com/reacto11mecha/wuno-bot/issues" 38 | }, 39 | "homepage": "https://github.com/reacto11mecha/wuno-bot#readme", 40 | "devDependencies": { 41 | "@commitlint/cli": "^18.4.2", 42 | "@commitlint/config-conventional": "^18.4.2", 43 | "@types/jest": "^29.5.8", 44 | "@types/luxon": "^3.3.4", 45 | "@types/node": "^20.9.0", 46 | "@types/qrcode-terminal": "^0.12.2", 47 | "@typescript-eslint/eslint-plugin": "^6.11.0", 48 | "@typescript-eslint/parser": "^6.11.0", 49 | "eslint": "^8.53.0", 50 | "eslint-config-prettier": "^9.0.0", 51 | "husky": "^8.0.3", 52 | "jest": "^29.7.0", 53 | "lint-staged": "^15.1.0", 54 | "nodemon": "^3.0.1", 55 | "prettier": "^3.1.0", 56 | "prisma": "^5.6.0", 57 | "ts-jest": "^29.1.1", 58 | "ts-node": "^10.9.1", 59 | "tsup": "^7.2.0", 60 | "typedoc": "^0.25.3", 61 | "typescript": "^5.2.2" 62 | }, 63 | "dependencies": { 64 | "@prisma/client": "^5.6.0", 65 | "@t3-oss/env-core": "^0.7.1", 66 | "dotenv": "^16.3.1", 67 | "libphonenumber-js": "^1.10.49", 68 | "luxon": "^3.4.4", 69 | "nanoid": "3.x.x", 70 | "node-cache": "^5.1.2", 71 | "p-limit": "3.1.0", 72 | "p-queue": "6.x.x", 73 | "pino": "^8.16.2", 74 | "pino-pretty": "^10.2.3", 75 | "qrcode-terminal": "^0.12.0", 76 | "sharp": "^0.32.6", 77 | "whatsapp-web.js": "^1.23.0", 78 | "zod": "^3.22.4" 79 | }, 80 | "lint-staged": { 81 | "*.{ts,js}": "eslint --cache --fix", 82 | "*.{ts,js,css,md}": "prettier --write" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/utils/miniutils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { DateTime, Duration } from "luxon"; 3 | 4 | /** 5 | * Random number value generator that generated form webcrypto 6 | * @returns Random number value 7 | */ 8 | export const random = () => { 9 | return ( 10 | ( 11 | crypto.webcrypto as unknown as { 12 | getRandomValues: (input: Uint32Array) => number[]; 13 | } 14 | ).getRandomValues(new Uint32Array(1))[0] / 15 | 2 ** 32 16 | ); 17 | }; 18 | 19 | /** 20 | * Util for formatting JS date object to human readable date 21 | * @param date JS Date Object that will formatted 22 | * @returns Human readable date string 23 | */ 24 | export const df = (date: Date) => 25 | new Intl.DateTimeFormat("id-ID", { dateStyle: "full", timeStyle: "long" }) 26 | .format(date) 27 | .replace(/\./g, ":"); 28 | 29 | const getHumanReadable = (time: string) => { 30 | const [hours, minutes, seconds] = time.split(":"); 31 | 32 | let humanReadableFormat = ""; 33 | 34 | if (parseInt(hours) > 0) { 35 | humanReadableFormat += `${hours} jam `; 36 | } 37 | 38 | if (parseInt(minutes) > 0) { 39 | humanReadableFormat += `${minutes} menit `; 40 | } 41 | 42 | if (parseInt(seconds) > 0) { 43 | humanReadableFormat += `${seconds} detik`; 44 | } 45 | 46 | return humanReadableFormat.trim(); 47 | }; 48 | 49 | /** 50 | * Util for calculating elapsed time from the given start time to the end time and format it to human readable time 51 | * @param start Start time js date object 52 | * @param end End time js date object 53 | * @returns Human readable time 54 | */ 55 | export const calcElapsedTime = (start: Date, end: Date) => { 56 | const luxonStart = DateTime.fromJSDate(start); 57 | const luxonEnd = DateTime.fromJSDate(end); 58 | 59 | const diff = luxonEnd.diff(luxonStart); 60 | 61 | const decidedString = diff.toFormat("H':'mm':'ss"); 62 | 63 | return getHumanReadable(decidedString); 64 | }; 65 | 66 | /** 67 | * Util for formatting human readable duration 68 | * @param duration Duration in milisecond 69 | * @returns Human readable duration 70 | */ 71 | export const calcDuration = (duration: number) => { 72 | const luxonDuration = Duration.fromMillis(duration); 73 | 74 | const decidedString = luxonDuration.toFormat("H':'mm':'ss"); 75 | 76 | return getHumanReadable(decidedString); 77 | }; 78 | 79 | /** 80 | * Get a weighted value from array 81 | */ 82 | export function weightedRandom(values: T[], weights: number[]): T { 83 | const totalWeight = weights.reduce((a, b) => a + b, 0); 84 | let random = Math.random() * totalWeight; 85 | 86 | for (let i = 0; i < weights.length; i++) { 87 | random -= weights[i]; 88 | if (random < 0) { 89 | return values[i]; 90 | } 91 | } 92 | 93 | return values[0]; 94 | } 95 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '41 17 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'typescript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /src/bot.ts: -------------------------------------------------------------------------------- 1 | import { Client, LocalAuth } from "whatsapp-web.js"; 2 | import qrcode from "qrcode-terminal"; 3 | import PQueue from "p-queue"; 4 | import pLimit from "p-limit"; 5 | import path from "path"; 6 | import P from "pino"; 7 | 8 | import { messageHandler } from "./handler/message"; 9 | import { df as formatTime } from "./utils/index"; 10 | import { prisma } from "./handler/database"; 11 | import { env } from "./env"; 12 | 13 | import type { Logger } from "pino"; 14 | 15 | export default class Bot { 16 | private logger: Logger; 17 | 18 | private queue = new PQueue({ 19 | concurrency: 4, 20 | autoStart: false, 21 | }); 22 | private messageLimitter = pLimit(8); 23 | private waClient: Client; 24 | 25 | constructor(clientId: string) { 26 | this.waClient = new Client({ 27 | authStrategy: new LocalAuth({ clientId }), 28 | puppeteer: { 29 | executablePath: env.CHROME_PATH, 30 | }, 31 | }); 32 | 33 | this.logger = P({ 34 | transport: { 35 | targets: [ 36 | { 37 | target: "pino-pretty", 38 | level: "debug", 39 | options: { 40 | colorize: true, 41 | ignore: "pid,hostname", 42 | translateTime: "SYS:standard", 43 | }, 44 | }, 45 | { 46 | target: "pino/file", 47 | level: "debug", 48 | options: { 49 | destination: path.join(__dirname, "..", `${clientId}-bot.log`), 50 | }, 51 | }, 52 | ], 53 | }, 54 | }); 55 | 56 | this.waClient.on("qr", (qr) => qrcode.generate(qr, { small: true })); 57 | this.waClient.on("ready", () => { 58 | this.logger.info("[BOT] Siap digunakan"); 59 | this.waClient.setStatus( 60 | `Ketik "${ 61 | env.PREFIX 62 | }" untuk memulai percakapan! Dinyalakan pada ${formatTime( 63 | new Date(), 64 | )}.`, 65 | ); 66 | }); 67 | this.waClient.on("authenticated", () => 68 | this.logger.info("[BOT] Berhasil melakukan proses autentikasi"), 69 | ); 70 | this.waClient.on("change_state", (state) => 71 | this.logger.info(`[BOT] State bot berubah, saat ini: ${state}`), 72 | ); 73 | 74 | this.queue.start(); 75 | } 76 | 77 | /** 78 | * The main entrance gate for this bot is working 79 | */ 80 | async init() { 81 | this.logger.info("[INIT] Inisialisasi bot"); 82 | 83 | const onMessageQueue = await messageHandler( 84 | this.waClient, 85 | this.logger, 86 | this.messageLimitter, 87 | ); 88 | 89 | this.waClient.on("message", async (message) => { 90 | if (message.body.startsWith(env.PREFIX)) { 91 | const contact = await message.getContact(); 92 | 93 | this.logger.info(`[Pesan] Ada pesan dari: ${contact.pushname}`); 94 | this.queue.add(async () => await onMessageQueue(message, contact)); 95 | } 96 | }); 97 | 98 | prisma.$connect().then(() => { 99 | this.logger.info("[DB] Berhasil terhubung dengan database"); 100 | this.logger.info("[BOT] Menyalakan bot"); 101 | 102 | this.waClient.initialize(); 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | username String 16 | phoneNumber String 17 | created_at DateTime @default(now()) 18 | 19 | gameProperty UserGameProperty? 20 | } 21 | 22 | model UserGameProperty { 23 | id Int @id @default(autoincrement()) 24 | isJoiningGame Boolean @default(false) 25 | gameID String? 26 | 27 | userId Int @unique 28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 29 | 30 | PlayerOrder PlayerOrder? 31 | BannedPlayers BannedPlayer? 32 | UserCard UserCard? 33 | Player Player? 34 | } 35 | 36 | enum GameStatus { 37 | WAITING 38 | PLAYING 39 | ENDED 40 | } 41 | 42 | model Game { 43 | id Int @id @default(autoincrement()) 44 | gameID String @unique @db.Char(11) 45 | status GameStatus @default(WAITING) 46 | created_at DateTime @default(now()) 47 | currentCard String? @db.Text() 48 | allPlayers Player[] 49 | playerOrders PlayerOrder[] 50 | bannedPlayers BannedPlayer[] 51 | currentPlayerId Int? 52 | gameCreatorId Int 53 | winnerId Int? 54 | cards UserCard[] 55 | started_at DateTime? 56 | ended_at DateTime? 57 | } 58 | 59 | model Player { 60 | id Int @id @default(autoincrement()) 61 | game Game @relation(fields: [gameId], references: [id]) 62 | gameId Int 63 | player UserGameProperty @relation(fields: [playerId], references: [id], onDelete: Cascade) 64 | playerId Int @unique 65 | } 66 | 67 | model PlayerOrder { 68 | id Int @id @default(autoincrement()) 69 | game Game @relation(fields: [gameId], references: [id]) 70 | gameId Int 71 | playerOrder Int 72 | player UserGameProperty @relation(fields: [playerId], references: [id], onDelete: Cascade) 73 | playerId Int @unique 74 | } 75 | 76 | model BannedPlayer { 77 | id Int @id @default(autoincrement()) 78 | game Game @relation(fields: [gameId], references: [id]) 79 | gameId Int 80 | player UserGameProperty @relation(fields: [playerId], references: [id], onDelete: Cascade) 81 | playerId Int @unique 82 | } 83 | 84 | model UserCard { 85 | id Int @id @default(autoincrement()) 86 | game Game @relation(fields: [gameId], references: [id]) 87 | gameId Int 88 | player UserGameProperty @relation(fields: [playerId], references: [id], onDelete: Cascade) 89 | playerId Int @unique 90 | 91 | cards Card[] 92 | } 93 | 94 | model Card { 95 | id Int @id @default(autoincrement()) 96 | cardName String @db.Text() 97 | card UserCard @relation(fields: [cardId], references: [id], onDelete: Cascade) 98 | cardId Int 99 | } 100 | -------------------------------------------------------------------------------- /src/handler/message.ts: -------------------------------------------------------------------------------- 1 | import type { Client, Contact, Message } from "whatsapp-web.js"; 2 | import pLimit from "p-limit"; 3 | import { Logger } from "pino"; 4 | 5 | import { env } from "../env"; 6 | import { Chat } from "../lib/Chat"; 7 | import { emitHandler } from "./emitter"; 8 | import { getController } from "./controller"; 9 | 10 | import { botInfo } from "../config/messages"; 11 | 12 | /** 13 | * A "bone" for this bot handling incoming messages whatsoever 14 | * @param client whatsapp-web.js client instance 15 | * @param logger pino logger instance 16 | * @param limitter p-limit instance 17 | * @returns A function that can be used for queue callback 18 | */ 19 | export const messageHandler = async ( 20 | client: Client, 21 | logger: Logger, 22 | limitter: ReturnType, 23 | ) => { 24 | const controller = await getController(); 25 | const emitter = emitHandler(controller); 26 | 27 | return async (message: Message, contact: Contact) => { 28 | const command = message.body 29 | .slice(env.PREFIX.length)! 30 | .trim()! 31 | .split(/ +/)! 32 | .shift()! 33 | .toLowerCase(); 34 | 35 | const chat = new Chat(client, message, logger, limitter, contact); 36 | 37 | switch (command) { 38 | case "cg": 39 | case "create": 40 | case "creategame": 41 | emitter.emit("creategame", chat); 42 | break; 43 | case "sg": 44 | case "start": 45 | case "startgame": 46 | emitter.emit("startgame", chat); 47 | break; 48 | case "j": 49 | case "jg": 50 | case "join": 51 | case "joingame": 52 | emitter.emit("joingame", chat); 53 | break; 54 | case "i": 55 | case "ig": 56 | case "info": 57 | case "infogame": 58 | emitter.emit("infogame", chat); 59 | break; 60 | case "eg": 61 | case "end": 62 | case "endgame": 63 | emitter.emit("endgame", chat); 64 | break; 65 | 66 | case "l": 67 | case "lg": 68 | case "quit": 69 | case "leave": 70 | case "leavegame": 71 | emitter.emit("leavegame", chat); 72 | break; 73 | case "leaderboard": 74 | case "board": 75 | case "lb": 76 | emitter.emit("leaderboard", chat); 77 | break; 78 | case "p": 79 | case "play": 80 | emitter.emit("play", chat); 81 | break; 82 | case "s": 83 | case "say": 84 | emitter.emit("say", chat); 85 | break; 86 | case "c": 87 | case "cards": 88 | emitter.emit("cards", chat); 89 | break; 90 | case "d": 91 | case "pickup": 92 | case "newcard": 93 | case "draw": 94 | emitter.emit("draw", chat); 95 | break; 96 | case "k": 97 | case "kick": 98 | emitter.emit("kick", chat); 99 | break; 100 | case "b": 101 | case "ban": 102 | emitter.emit("ban", chat); 103 | break; 104 | case "h": 105 | case "help": 106 | emitter.emit("help", chat); 107 | break; 108 | 109 | default: { 110 | await chat.sendToCurrentPerson( 111 | command.length > 0 112 | ? `Tidak ada perintah yang bernama "${command}"` 113 | : botInfo, 114 | ); 115 | break; 116 | } 117 | } 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/utils/imageHandler.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import sharp from "sharp"; 4 | import NodeCache from "node-cache"; 5 | 6 | import { MessageMedia } from "whatsapp-web.js"; 7 | import type { allCard } from "../config/cards"; 8 | 9 | const imgCache = new NodeCache({ stdTTL: 60 * 60 * 24 }); 10 | const cardsDir = path.join(path.resolve(), "assets/images/cards"); 11 | 12 | /** 13 | * Image card builder, will create blank image with certain width and height 14 | * @param width Width of the image 15 | * @param height Height of the image 16 | * @returns Sharp instance with blank background 17 | */ 18 | const cardBuilder = (width: number, height: number) => 19 | sharp(path.join(cardsDir, "blank.png"), { 20 | create: { 21 | width, 22 | height, 23 | channels: 4, 24 | background: { r: 0, g: 0, b: 0, alpha: 0 }, 25 | }, 26 | }); 27 | 28 | /** 29 | * Util for convert a given card that will generate a base64 image URL 30 | * @param card Valid given card 31 | * @returns A string of base64 image URL 32 | */ 33 | export const getCardImage = (card: allCard) => { 34 | const cacheKeyName = `an-image-${card}`; 35 | if (imgCache.has(cacheKeyName)) 36 | return imgCache.get(cacheKeyName) as MessageMedia; 37 | 38 | const imgBuffer = fs.readFileSync(path.join(cardsDir, `${card}.png`)); 39 | 40 | const img = new MessageMedia("image/png", imgBuffer.toString("base64")); 41 | 42 | imgCache.set(cacheKeyName, img); 43 | return img; 44 | }; 45 | 46 | /** 47 | * Util for convert a given cards that will generate a front facing UNO card in base64 image URL 48 | * @param cards An array of valid given card 49 | * @returns A string of front facing UNO card in base64 image URL 50 | */ 51 | export const createCardsImageFront = async (cards: allCard[]) => { 52 | const cacheKeyName = `front-${cards.join("-")}`; 53 | if (imgCache.has(cacheKeyName)) 54 | return imgCache.get(cacheKeyName) as MessageMedia; 55 | 56 | const imgBuffer = await cardBuilder(cards.length * 95, 137) 57 | .composite( 58 | cards.map((card, idx) => ({ 59 | input: path.join(cardsDir, `${card}.png`), 60 | left: 95 * idx, 61 | top: 0, 62 | })), 63 | ) 64 | .toFormat("png") 65 | .toBuffer(); 66 | 67 | const img = new MessageMedia("image/png", imgBuffer.toString("base64")); 68 | 69 | imgCache.set(cacheKeyName, img, 60 * 20); 70 | return img; 71 | }; 72 | 73 | /** 74 | * Util for convert a how many cards are in number that will generate a back facing UNO card in base64 image URL 75 | * @param cardsLength How many cards are in number 76 | * @returns A string of front facing UNO card in base64 image URL 77 | */ 78 | export const createCardsImageBack = async (cardsLength: number) => { 79 | const cacheKeyName = `back${cardsLength}`; 80 | if (imgCache.has(cacheKeyName)) 81 | return imgCache.get(cacheKeyName) as MessageMedia; 82 | 83 | const imgBuffer = await cardBuilder(cardsLength * 95 - 10, 135) 84 | .composite( 85 | Array.from(new Array(cardsLength)).map((_, idx) => ({ 86 | input: path.join(cardsDir, "back.png"), 87 | left: 95 * idx, 88 | top: 0, 89 | })), 90 | ) 91 | .toFormat("png") 92 | .toBuffer(); 93 | 94 | const img = new MessageMedia("image/png", imgBuffer.toString("base64")); 95 | 96 | imgCache.set(cacheKeyName, img); 97 | return img; 98 | }; 99 | 100 | /** 101 | * Util for creating an images that coming from three different functions (getCardImage, createCardsImageFront, createCardsImageBack) 102 | * @param currentCard Valid given card 103 | * @param cards An array of valid given card 104 | * @returns An array that contains three results from the function mentioned before 105 | */ 106 | export const createAllCardImage = async ( 107 | currentCard: allCard, 108 | cards: allCard[], 109 | ) => { 110 | const currentCardImage = getCardImage(currentCard); 111 | const [frontCardsImage, backCardsImage] = await Promise.all([ 112 | createCardsImageFront(cards), 113 | createCardsImageBack(cards.length), 114 | ]); 115 | 116 | return [currentCardImage, frontCardsImage, backCardsImage]; 117 | }; 118 | -------------------------------------------------------------------------------- /src/controller/say.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession } from "../utils"; 2 | 3 | export default requiredJoinGameSession(async ({ chat, game }) => { 4 | const message = chat.args.join(" "); 5 | 6 | if (!game) { 7 | return await chat.replyToCurrentPerson( 8 | "Sebuah kesalahan, game tidak ditemukan!", 9 | ); 10 | } else if (game.players!.length < 1) { 11 | return await chat.replyToCurrentPerson( 12 | "Tidak ada lawan bicara yang bisa diajak berkomunikasi.", 13 | ); 14 | } 15 | 16 | const playerList = game.players.filter( 17 | (player) => player.playerId !== chat.user!.id, 18 | ); 19 | 20 | // Media handler 21 | const { hasQuotedMessage, quotedMessage, quotedMessageMedia } = 22 | await chat.hasQuotedMessageMedia(); 23 | 24 | if (hasQuotedMessage && quotedMessageMedia) { 25 | // If the quoted message is a gif 26 | if (quotedMessage.isGif) { 27 | await game.sendToSpecificPlayerList( 28 | { 29 | sendVideoAsGif: true, 30 | caption: 31 | message === "" 32 | ? `GIF dari ${chat.message.userName}` 33 | : `${chat.message.userName}: ${message}`, 34 | }, 35 | playerList, 36 | quotedMessageMedia, 37 | ); 38 | 39 | await chat.reactToCurrentPerson("👍"); 40 | 41 | return; 42 | } 43 | 44 | // Check if it's not an image 45 | if (!quotedMessageMedia.mimetype.startsWith("image/")) { 46 | await chat.replyToCurrentPerson( 47 | "Pesan yang bisa dikutip hanya berupa gambar, gif, dan sticker!", 48 | ); 49 | 50 | return; 51 | } 52 | 53 | // It's a sticker 54 | if ( 55 | quotedMessageMedia.mimetype === "image/webp" && 56 | quotedMessage.body === "" 57 | ) { 58 | await game.sendToSpecificPlayerList( 59 | { sendMediaAsSticker: true }, 60 | playerList, 61 | quotedMessageMedia, 62 | ); 63 | 64 | await game.sendToSpecificPlayerList( 65 | message === "" 66 | ? `Sticker dari ${chat.message.userName}` 67 | : `${chat.message.userName}: ${message}`, 68 | playerList, 69 | ); 70 | 71 | await chat.reactToCurrentPerson("👍"); 72 | 73 | return; 74 | } 75 | 76 | await game.sendToSpecificPlayerList( 77 | { 78 | caption: 79 | message === "" 80 | ? `Gambar dari ${chat.message.userName}` 81 | : `${chat.message.userName}: ${message}`, 82 | }, 83 | playerList, 84 | quotedMessageMedia, 85 | ); 86 | 87 | await chat.reactToCurrentPerson("👍"); 88 | 89 | return; 90 | } 91 | 92 | const { hasMedia, currentChat, currentMedia } = 93 | await chat.hasMediaInCurrentChat(); 94 | 95 | if (hasMedia && currentMedia) { 96 | // If the quoted message is a gif 97 | if (currentChat.isGif) { 98 | await game.sendToSpecificPlayerList( 99 | { 100 | sendVideoAsGif: true, 101 | caption: 102 | message === "" 103 | ? `GIF dari ${chat.message.userName}` 104 | : `${chat.message.userName}: ${message}`, 105 | }, 106 | playerList, 107 | currentMedia, 108 | ); 109 | 110 | await chat.reactToCurrentPerson("👍"); 111 | 112 | return; 113 | } 114 | 115 | // Check if it's not an image 116 | if (!currentMedia.mimetype.startsWith("image/")) { 117 | await chat.replyToCurrentPerson( 118 | "Pesan yang bisa dikutip hanya berupa gambar, gif, dan sticker!", 119 | ); 120 | 121 | return; 122 | } 123 | 124 | await game.sendToSpecificPlayerList( 125 | { 126 | caption: 127 | message === "" 128 | ? `Gambar dari ${chat.message.userName}` 129 | : `${chat.message.userName}: ${message}`, 130 | }, 131 | playerList, 132 | quotedMessageMedia, 133 | ); 134 | 135 | await chat.reactToCurrentPerson("👍"); 136 | 137 | return; 138 | } 139 | // End of media handler 140 | 141 | if (message === "") { 142 | await chat.replyToCurrentPerson("Pesan tidak boleh kosong!"); 143 | return; 144 | } 145 | 146 | await game.sendToSpecificPlayerList( 147 | `${chat.message.userName}: ${message}`, 148 | playerList, 149 | ); 150 | 151 | await chat.reactToCurrentPerson("👍"); 152 | }); 153 | -------------------------------------------------------------------------------- /src/utils/validator.ts: -------------------------------------------------------------------------------- 1 | import { Chat, Game, Card } from "../lib"; 2 | 3 | import { prisma } from "../handler/database"; 4 | 5 | /** 6 | * "requiredJoinGameSession" util callback controller type 7 | */ 8 | export type TypeReqJGS = (cb: { 9 | chat: Chat; 10 | game: Game; 11 | card: Card; 12 | }) => Promise; 13 | 14 | /** 15 | * Util for checking user is joining game session before accessing main controller 16 | * @param cb Callback controller 17 | * @returns void 18 | */ 19 | export const requiredJoinGameSession = 20 | (cb: TypeReqJGS) => async (chat: Chat) => { 21 | try { 22 | if (chat.isJoiningGame && chat.gameProperty?.gameID) { 23 | const gameData = await prisma.game.findUnique({ 24 | where: { 25 | gameID: chat.gameProperty.gameID, 26 | }, 27 | include: { 28 | allPlayers: true, 29 | bannedPlayers: true, 30 | cards: true, 31 | playerOrders: true, 32 | }, 33 | }); 34 | 35 | const game = new Game(gameData!, chat); 36 | 37 | const cardData = await prisma.userCard.findUnique({ 38 | where: { 39 | playerId: chat.user?.id, 40 | }, 41 | include: { 42 | cards: true, 43 | }, 44 | }); 45 | 46 | const card = new Card(cardData!, chat, game); 47 | 48 | return await cb({ chat, game, card }); 49 | } 50 | 51 | await chat.replyToCurrentPerson("Kamu belum masuk ke sesi game manapun!"); 52 | } catch (error) { 53 | chat.logger.error(error); 54 | } 55 | }; 56 | 57 | /** 58 | * "atLeastGameId" util callback not joining game and joining game 59 | */ 60 | export type commonCb = (cb: { chat: Chat; game: Game }) => Promise; 61 | 62 | /** 63 | * Util for grabbing game id from args and takes care if the game exists or not, either user is joining the game or not 64 | * @param cbNotJoiningGame Callback for not joining the game 65 | * @param cbJoiningGame Callback for the joining the game 66 | * @returns void 67 | */ 68 | export const atLeastGameID = 69 | (cbNotJoiningGame: commonCb, cbJoiningGame: commonCb) => 70 | async (chat: Chat) => { 71 | try { 72 | const gameID = chat.args[0]; 73 | 74 | if (!chat.isJoiningGame) { 75 | if (!gameID || gameID === "") { 76 | return await chat.replyToCurrentPerson( 77 | "Diperlukan parameter game id!", 78 | ); 79 | } else if (gameID.length < 11) { 80 | return await chat.replyToCurrentPerson( 81 | "Minimal panjang game id adalah 11 karakter!", 82 | ); 83 | } 84 | 85 | const searchedGame = await prisma.game.findUnique({ 86 | where: { 87 | gameID, 88 | }, 89 | include: { 90 | allPlayers: true, 91 | bannedPlayers: true, 92 | cards: true, 93 | playerOrders: true, 94 | }, 95 | }); 96 | 97 | if (!searchedGame) 98 | return await chat.replyToCurrentPerson("Game tidak ditemukan."); 99 | 100 | const game = new Game(searchedGame!, chat); 101 | 102 | return await cbNotJoiningGame({ 103 | chat, 104 | game, 105 | }); 106 | } 107 | 108 | const gameData = await prisma.game.findUnique({ 109 | where: { 110 | gameID: chat.gameProperty!.gameID!, 111 | }, 112 | include: { 113 | allPlayers: true, 114 | bannedPlayers: true, 115 | cards: true, 116 | playerOrders: true, 117 | }, 118 | }); 119 | const game = new Game(gameData!, chat); 120 | 121 | return await cbJoiningGame({ 122 | chat, 123 | game, 124 | }); 125 | } catch (error) { 126 | const timeReference = Date.now(); 127 | 128 | chat.logger.error(error); 129 | chat.logger.error({ timeReference }); 130 | 131 | await chat.sendToCurrentPerson( 132 | `Terjadi sebuah kesalahan internal. Laporkan kesalahan ini kepada administrator bot.\n\n\`\`\`timeReference\`\`\`: ${timeReference}.`, 133 | ); 134 | } 135 | }; 136 | 137 | /** 138 | * "isDMChat" util callback type 139 | */ 140 | export type isDMChatCb = (cb: Chat) => Promise; 141 | 142 | /** 143 | * Util for checking whether the chat is coming from DM or not 144 | * @param cb General callback that can be passed basic chat instance 145 | * @returns void 146 | */ 147 | export const isDMChat = (cb: isDMChatCb) => async (chat: Chat) => { 148 | if (chat.isDMChat) return await cb(chat); 149 | 150 | await chat.replyToCurrentPerson("Kirim pesan ini lewat DM WhatsApp!"); 151 | }; 152 | -------------------------------------------------------------------------------- /src/controller/startgame.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession, createAllCardImage } from "../utils"; 2 | 3 | import type { allCard } from "../config/cards"; 4 | 5 | import { prisma } from "../handler/database"; 6 | 7 | export default requiredJoinGameSession(async ({ chat, game }) => { 8 | if (game.NotFound) { 9 | return await chat.replyToCurrentPerson( 10 | "Sebuah kesalahan, game tidak ditemukan!", 11 | ); 12 | } else if (game.isGameCreator) { 13 | if (game.players?.length === 1) { 14 | return await chat.replyToCurrentPerson( 15 | "Minimal ada dua pemain yang tergabung!", 16 | ); 17 | } else if (game.state.PLAYING) { 18 | return await chat.replyToCurrentPerson("Game ini sedang dimainkan!"); 19 | } 20 | 21 | await game.startGame(); 22 | 23 | const [playersUserData, currentPlayerCard, currentPlayer] = 24 | await Promise.all([ 25 | game.getAllPlayerUserObject(), 26 | prisma.userCard.findUnique({ 27 | where: { 28 | playerId: game.currentPositionId!, 29 | }, 30 | include: { 31 | cards: true, 32 | }, 33 | }), 34 | game.getCurrentPlayerUserData(), 35 | ]); 36 | 37 | const playersOrder = game.playersOrderIds 38 | .map((playerId) => 39 | playersUserData.find((player) => player?.id === playerId), 40 | ) 41 | .map((player, idx) => `${idx + 1}. ${player?.username}`) 42 | .join("\n"); 43 | 44 | const playerList = game.players 45 | .filter((player) => player.playerId !== chat.user!.id) 46 | .filter((player) => player.playerId !== game.currentPositionId!); 47 | 48 | const [currentCardImage, frontCardsImage, backCardsImage] = 49 | await createAllCardImage( 50 | game.currentCard as allCard, 51 | currentPlayerCard?.cards.map((card) => card.cardName) as allCard[], 52 | ); 53 | 54 | if (game.currentPlayerIsAuthor) { 55 | await Promise.all([ 56 | // Admin as current player Side 57 | (async () => { 58 | if (currentPlayer) { 59 | await chat.replyToCurrentPerson( 60 | "Game berhasil dimulai! Sekarang giliran kamu untuk bermain", 61 | ); 62 | await chat.replyToCurrentPerson(`Urutan Bermain:\n${playersOrder}`); 63 | 64 | await chat.sendToCurrentPerson( 65 | { caption: `Kartu saat ini: ${game.currentCard}` }, 66 | currentCardImage, 67 | ); 68 | await chat.sendToCurrentPerson( 69 | { 70 | caption: `Kartu kamu: ${currentPlayerCard?.cards 71 | .map((card) => card.cardName) 72 | .join(", ")}.`, 73 | }, 74 | frontCardsImage, 75 | ); 76 | } 77 | })(), 78 | 79 | // Other player side 80 | (async () => { 81 | if (currentPlayer) { 82 | await game.sendToSpecificPlayerList( 83 | `${ 84 | chat.message.userName 85 | } telah memulai permainan! Sekarang giliran ${ 86 | game.currentPlayerIsAuthor ? "dia" : currentPlayer.username 87 | } untuk bermain`, 88 | playerList, 89 | ); 90 | await game.sendToSpecificPlayerList( 91 | `Urutan Bermain:\n${playersOrder}`, 92 | playerList, 93 | ); 94 | 95 | await game.sendToSpecificPlayerList( 96 | { caption: `Kartu saat ini: ${game.currentCard}` }, 97 | playerList, 98 | currentCardImage, 99 | ); 100 | await game.sendToSpecificPlayerList( 101 | { caption: `Kartu yang ${currentPlayer.username} miliki` }, 102 | playerList, 103 | backCardsImage, 104 | ); 105 | } 106 | })(), 107 | ]); 108 | 109 | return; 110 | } 111 | 112 | await Promise.all([ 113 | // Admin side but it isn't their turn 114 | (async () => { 115 | if (currentPlayer) { 116 | await chat.replyToCurrentPerson( 117 | `Game berhasil dimulai! Sekarang giliran ${currentPlayer.username} untuk bermain`, 118 | ); 119 | await chat.replyToCurrentPerson(`Urutan Bermain:\n${playersOrder}`); 120 | 121 | await chat.sendToCurrentPerson( 122 | { caption: `Kartu saat ini: ${game.currentCard}` }, 123 | currentCardImage, 124 | ); 125 | await chat.sendToCurrentPerson( 126 | { 127 | caption: `Kartu yang ${currentPlayer.username} miliki`, 128 | }, 129 | backCardsImage, 130 | ); 131 | } 132 | })(), 133 | 134 | // The person who got the first turn 135 | (async () => { 136 | if (currentPlayer) { 137 | await chat.sendToOtherPerson( 138 | currentPlayer.phoneNumber, 139 | "Game berhasil dimulai! Sekarang giliran kamu untuk bermain", 140 | ); 141 | await chat.sendToOtherPerson( 142 | currentPlayer.phoneNumber, 143 | `Urutan Bermain:\n${playersOrder}`, 144 | ); 145 | 146 | await chat.sendToOtherPerson( 147 | currentPlayer.phoneNumber, 148 | { caption: `Kartu saat ini: ${game.currentCard}` }, 149 | currentCardImage, 150 | ); 151 | await chat.sendToOtherPerson( 152 | currentPlayer.phoneNumber, 153 | { 154 | caption: `Kartu kamu: ${currentPlayerCard?.cards 155 | .map((card) => card.cardName) 156 | .join(", ")}.`, 157 | }, 158 | frontCardsImage, 159 | ); 160 | } 161 | })(), 162 | 163 | // The rest of the game player 164 | (async () => { 165 | if (currentPlayer) { 166 | await game.sendToSpecificPlayerList( 167 | `${chat.message.userName} telah memulai permainan! Sekarang giliran ${currentPlayer.username} untuk bermain`, 168 | playerList, 169 | ); 170 | await game.sendToSpecificPlayerList( 171 | `Urutan Bermain:\n${playersOrder}`, 172 | playerList, 173 | ); 174 | 175 | await game.sendToSpecificPlayerList( 176 | { caption: `Kartu saat ini: ${game.currentCard}` }, 177 | playerList, 178 | currentCardImage, 179 | ); 180 | await game.sendToSpecificPlayerList( 181 | { caption: `Kartu yang ${currentPlayer.username} miliki` }, 182 | playerList, 183 | backCardsImage, 184 | ); 185 | } 186 | })(), 187 | ]); 188 | 189 | chat.logger.info(`[DB] Game ${game.gameID} dimulai`); 190 | } else { 191 | await chat.replyToCurrentPerson( 192 | "Kamu bukanlah orang yang membuat sesi permainannya!", 193 | ); 194 | } 195 | }); 196 | -------------------------------------------------------------------------------- /src/controller/kick.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../handler/database"; 2 | import { createAllCardImage, requiredJoinGameSession } from "../utils"; 3 | import { env } from "../env"; 4 | 5 | import type { allCard } from "../config/cards"; 6 | 7 | // This function is almost the same like leavegame controller 8 | export default requiredJoinGameSession(async ({ chat, game }) => { 9 | if (!game.isGameCreator) 10 | return await chat.replyToCurrentPerson("Kamu bukan pembuat gamenya!"); 11 | 12 | const message = chat.args.join(" ").trim(); 13 | const players = await game.getAllPlayerUserObject(); 14 | 15 | const player = players.find((player) => player?.username === message); 16 | 17 | if (message === "") 18 | return await chat.replyToCurrentPerson( 19 | "Sebutkan siapa yang ingin di kick!", 20 | ); 21 | 22 | if (player) { 23 | const playerList = game.players 24 | .filter((gamePlayer) => gamePlayer.playerId !== player.id) 25 | .filter((player) => player.playerId !== chat.user!.id); 26 | 27 | const afterPlayerGetKicked = game.players.filter( 28 | (player) => player.playerId !== player.id, 29 | ); 30 | 31 | if (player.id === chat.user!.id) 32 | return await chat.replyToCurrentPerson( 33 | "Kamu tidak bisa mengkick dirimu sendiri. Jika ingin keluar dari game, gunakan perintah *leavegame*!", 34 | ); 35 | 36 | switch (true) { 37 | case game.state.PLAYING: { 38 | if (afterPlayerGetKicked.length < 2) { 39 | chat.replyToCurrentPerson( 40 | `Kamu tidak bisa kick pemain jika hanya ada dua orang. Kamu bisa menghentikan permainan atau keluar permainan dengan \`\`\`${env.PREFIX}eg\`\`\` atau \`\`\`${env.PREFIX}lg\`\`\`.`, 41 | ); 42 | 43 | return; 44 | } 45 | 46 | // Current game turn is the same player as the player that want to kick 47 | if (game.currentPositionId === player.id) { 48 | const nextPlayerId = game.getNextPosition(); 49 | 50 | const [nextPlayer, nextPlayerCards] = await Promise.all([ 51 | prisma.user.findUnique({ 52 | where: { id: nextPlayerId!.playerId }, 53 | }), 54 | prisma.userCard.findUnique({ 55 | where: { 56 | playerId: nextPlayerId!.playerId, 57 | }, 58 | include: { 59 | cards: true, 60 | }, 61 | }), 62 | ]); 63 | 64 | const [currentCardImage, frontCardsImage, backCardsImage] = 65 | await createAllCardImage( 66 | game.currentCard as allCard, 67 | nextPlayerCards?.cards.map((card) => card.cardName) as allCard[], 68 | ); 69 | 70 | const actualPlayerList = playerList.filter( 71 | (player) => player.playerId !== nextPlayerId!.playerId, 72 | ); 73 | 74 | await game.updatePosition(nextPlayer!.id); 75 | await game.removeUserFromArray(player.id); 76 | 77 | await Promise.all([ 78 | // Send message to the kicked player 79 | chat.sendToOtherPerson( 80 | player.phoneNumber, 81 | "Anda sudah dikeluarkan dari permainan, sekarang kamu tidak lagi bermain.", 82 | ), 83 | 84 | // Send message to game creator 85 | (async () => { 86 | await chat.replyToCurrentPerson( 87 | `Berhasil mengeluarkan ${ 88 | player.username 89 | } dari permainan, sekarang giliran ${ 90 | nextPlayer!.username 91 | } untuk bermain`, 92 | ); 93 | await chat.replyToCurrentPerson( 94 | { caption: `Kartu saat ini: ${game.currentCard}` }, 95 | currentCardImage, 96 | ); 97 | await chat.replyToCurrentPerson( 98 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 99 | backCardsImage, 100 | ); 101 | })(), 102 | 103 | // Send message to next player 104 | (async () => { 105 | await chat.sendToOtherPerson( 106 | nextPlayer!.phoneNumber, 107 | `${player.username} telah ditendang oleh ${chat.message.userName}. Sekarang giliran kamu untuk bermain`, 108 | ); 109 | await chat.sendToOtherPerson( 110 | nextPlayer!.phoneNumber, 111 | { caption: `Kartu saat ini: ${game.currentCard}` }, 112 | currentCardImage, 113 | ); 114 | await chat.sendToOtherPerson( 115 | nextPlayer!.phoneNumber, 116 | { 117 | caption: `Kartu kamu: ${nextPlayerCards?.cards 118 | .map((card) => card.cardName) 119 | .join(", ")}.`, 120 | }, 121 | frontCardsImage, 122 | ); 123 | })(), 124 | 125 | // Rest of the players 126 | (async () => { 127 | await game.sendToSpecificPlayerList( 128 | `${ 129 | player.username 130 | } sudah ditendang keluar dari permainan oleh ${ 131 | chat.message.userName 132 | }. Sekarang giliran ${nextPlayer!.username} untuk bermain`, 133 | actualPlayerList, 134 | ); 135 | await game.sendToSpecificPlayerList( 136 | { caption: `Kartu saat ini: ${game.currentCard}` }, 137 | actualPlayerList, 138 | currentCardImage, 139 | ); 140 | await game.sendToSpecificPlayerList( 141 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 142 | actualPlayerList, 143 | backCardsImage, 144 | ); 145 | })(), 146 | ]); 147 | 148 | return; 149 | } 150 | 151 | break; 152 | } 153 | 154 | case game.state.PLAYING && game.currentPositionId !== player!.id: 155 | case game.state.WAITING: 156 | default: { 157 | await game.removeUserFromArray(player.id); 158 | 159 | await Promise.all([ 160 | // Send message to the kicked player 161 | chat.sendToOtherPerson( 162 | player.phoneNumber, 163 | "Anda sudah dikeluarkan dari permainan, sekarang kamu tidak lagi bermain.", 164 | ), 165 | 166 | // Send message to game creator 167 | await chat.replyToCurrentPerson( 168 | `Berhasil mengeluarkan ${player.id} dari permainan.`, 169 | ), 170 | 171 | // Rest of the players 172 | game.sendToSpecificPlayerList( 173 | `${player.username} sudah ditendang keluar dari permainan oleh ${chat.message.userName}, sekarang dia tidak ada lagi dalam permainan.`, 174 | playerList, 175 | ), 176 | ]); 177 | 178 | break; 179 | } 180 | } 181 | } else { 182 | await chat.replyToCurrentPerson( 183 | `Tidak ada pemain yang bernama "${message}"`, 184 | ); 185 | } 186 | }); 187 | -------------------------------------------------------------------------------- /src/controller/ban.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from "../handler/database"; 2 | import { createAllCardImage, requiredJoinGameSession } from "../utils"; 3 | import { env } from "../env"; 4 | 5 | import type { allCard } from "../config/cards"; 6 | 7 | // Basically kick controller with extra add user to the banned list 8 | export default requiredJoinGameSession(async ({ chat, game }) => { 9 | if (!game.isGameCreator) 10 | return await chat.replyToCurrentPerson("Kamu bukan pembuat gamenya!"); 11 | 12 | const message = chat.args.join(" ").trim(); 13 | const players = await game.getAllPlayerUserObject(); 14 | 15 | const player = players.find((player) => player?.username === message); 16 | 17 | if (message === "") 18 | return await chat.replyToCurrentPerson("Sebutkan siapa yang ingin di ban!"); 19 | 20 | if (player) { 21 | const playerList = game.players 22 | .filter((gamePlayer) => gamePlayer.playerId !== player.id) 23 | .filter((player) => player.playerId !== chat.user!.id); 24 | 25 | const afterPlayerGetKicked = game.players.filter( 26 | (player) => player.playerId !== player.id, 27 | ); 28 | 29 | if (player.id === chat.user!.id) 30 | return await chat.replyToCurrentPerson( 31 | "Kamu tidak bisa ban dirimu sendiri. Jika ingin keluar dari game, gunakan perintah *leavegame*!", 32 | ); 33 | 34 | switch (true) { 35 | case game.state.PLAYING: { 36 | if (afterPlayerGetKicked.length < 2) { 37 | chat.replyToCurrentPerson( 38 | `Kamu tidak bisa ban pemain jika hanya ada dua orang. Kamu bisa menghentikan permainan atau keluar permainan dengan \`\`\`${env.PREFIX}eg\`\`\` atau \`\`\`${env.PREFIX}lg\`\`\`.`, 39 | ); 40 | 41 | return; 42 | } 43 | 44 | // Current game turn is the same player as the player that want to kick 45 | if (game.currentPositionId === player.id) { 46 | const nextPlayerId = game.getNextPosition(); 47 | 48 | const [nextPlayer, nextPlayerCards] = await Promise.all([ 49 | prisma.user.findUnique({ 50 | where: { id: nextPlayerId!.playerId }, 51 | }), 52 | prisma.userCard.findUnique({ 53 | where: { 54 | playerId: nextPlayerId!.playerId, 55 | }, 56 | include: { 57 | cards: true, 58 | }, 59 | }), 60 | ]); 61 | 62 | const [currentCardImage, frontCardsImage, backCardsImage] = 63 | await createAllCardImage( 64 | game.currentCard as allCard, 65 | nextPlayerCards?.cards.map((card) => card.cardName) as allCard[], 66 | ); 67 | 68 | const actualPlayerList = playerList.filter( 69 | (player) => player.playerId !== nextPlayerId!.playerId, 70 | ); 71 | 72 | await game.updatePosition(nextPlayer!.id); 73 | await game.removeUserFromArray(player.id); 74 | await game.addUserToBannedList(player.id); 75 | 76 | await Promise.all([ 77 | // Send message to the kicked player 78 | chat.sendToOtherPerson( 79 | player.phoneNumber, 80 | "Anda sudah di banned dari permainan, sekarang kamu tidak lagi bermain.", 81 | ), 82 | 83 | // Send message to game creator 84 | (async () => { 85 | await chat.replyToCurrentPerson( 86 | `Berhasil untuk ban ${ 87 | player.username 88 | } dari permainan, sekarang giliran ${ 89 | nextPlayer!.username 90 | } untuk bermain`, 91 | ); 92 | await chat.replyToCurrentPerson( 93 | { caption: `Kartu saat ini: ${game.currentCard}` }, 94 | currentCardImage, 95 | ); 96 | await chat.replyToCurrentPerson( 97 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 98 | backCardsImage, 99 | ); 100 | })(), 101 | 102 | // Send message to next player 103 | (async () => { 104 | await chat.sendToOtherPerson( 105 | nextPlayer!.phoneNumber, 106 | `${player.username} telah di banned oleh ${chat.message.userName}. Sekarang giliran kamu untuk bermain`, 107 | ); 108 | await chat.sendToOtherPerson( 109 | nextPlayer!.phoneNumber, 110 | { caption: `Kartu saat ini: ${game.currentCard}` }, 111 | currentCardImage, 112 | ); 113 | await chat.sendToOtherPerson( 114 | nextPlayer!.phoneNumber, 115 | { 116 | caption: `Kartu kamu: ${nextPlayerCards?.cards 117 | .map((card) => card.cardName) 118 | .join(", ")}.`, 119 | }, 120 | frontCardsImage, 121 | ); 122 | })(), 123 | 124 | // Rest of the players 125 | (async () => { 126 | await game.sendToSpecificPlayerList( 127 | `${player.username} sudah di banned dari permainan oleh ${ 128 | chat.message.userName 129 | }. Sekarang giliran ${nextPlayer!.username} untuk bermain`, 130 | actualPlayerList, 131 | ); 132 | await game.sendToSpecificPlayerList( 133 | { caption: `Kartu saat ini: ${game.currentCard}` }, 134 | actualPlayerList, 135 | currentCardImage, 136 | ); 137 | await game.sendToSpecificPlayerList( 138 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 139 | actualPlayerList, 140 | backCardsImage, 141 | ); 142 | })(), 143 | ]); 144 | 145 | return; 146 | } 147 | 148 | break; 149 | } 150 | 151 | case game.state.PLAYING && game.currentPositionId !== player!.id: 152 | case game.state.WAITING: 153 | default: { 154 | await game.removeUserFromArray(player.id); 155 | await game.addUserToBannedList(player.id); 156 | 157 | await Promise.all([ 158 | // Send message to the kicked player 159 | chat.sendToOtherPerson( 160 | player.phoneNumber, 161 | "Anda sudah di banned dari permainan, sekarang kamu tidak lagi bermain.", 162 | ), 163 | 164 | // Send message to game creator 165 | await chat.replyToCurrentPerson( 166 | `Berhasil ban ${player.id} dari permainan.`, 167 | ), 168 | 169 | // Rest of the players 170 | game.sendToSpecificPlayerList( 171 | `${player.username} sudah di banned permainan oleh ${chat.message.userName}, sekarang dia tidak ada lagi dalam permainan.`, 172 | playerList, 173 | ), 174 | ]); 175 | 176 | break; 177 | } 178 | } 179 | } else { 180 | await chat.replyToCurrentPerson( 181 | `Tidak ada pemain yang bernama "${message}"`, 182 | ); 183 | } 184 | }); 185 | -------------------------------------------------------------------------------- /src/lib/Chat.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | MessageSendOptions, 3 | Message, 4 | Client, 5 | Contact, 6 | MessageContent, 7 | MessageMedia, 8 | } from "whatsapp-web.js"; 9 | import { Logger } from "pino"; 10 | import pLimit from "p-limit"; 11 | 12 | import { env } from "../env"; 13 | import type { UserGameProperty, User } from "../handler/database"; 14 | 15 | /** 16 | * Interface for accessible Chat's message property 17 | */ 18 | export interface IMessage { 19 | /** 20 | * User number 21 | */ 22 | userNumber: string; 23 | 24 | /** 25 | * User username 26 | */ 27 | userName: string; 28 | 29 | /** 30 | * Incoming chat from property 31 | */ 32 | from: string; 33 | 34 | /** 35 | * Incoming chat specific message id 36 | */ 37 | id: string; 38 | } 39 | 40 | /** 41 | * Class for handling incoming chat and outcoming chat 42 | */ 43 | export class Chat { 44 | /** 45 | * Whatsapp client instance 46 | */ 47 | client: Client; 48 | 49 | /** 50 | * Pino logger instance 51 | */ 52 | logger: Logger; 53 | 54 | /** 55 | * Accessible message instance that contains information about incoming message 56 | */ 57 | message: IMessage; 58 | 59 | /** 60 | * Actual incoming message object 61 | */ 62 | private incomingMessage: Message; 63 | 64 | /** 65 | * Message limitter instance from p-limit 66 | */ 67 | private limitter: ReturnType; 68 | 69 | /** 70 | * Current chatter contact instance 71 | */ 72 | private contact: Contact; 73 | 74 | /** 75 | * Accessible user document by phone number 76 | */ 77 | user?: User; 78 | 79 | /** 80 | * Accessible user game property by phone number 81 | */ 82 | gameProperty?: UserGameProperty; 83 | 84 | /** 85 | * Args list from user command 86 | */ 87 | args: string[]; 88 | 89 | /** 90 | * Chat class constructor 91 | * @param client Open whatsaapp client instance 92 | * @param IncomingMessage Open whatsapp .onMessage message instance 93 | * @param logger Pino logger instance 94 | * @param limitter p-limit instance for limitting message 95 | * @param contact Current chatter contact instance 96 | */ 97 | constructor( 98 | client: Client, 99 | IncomingMessage: Message, 100 | logger: Logger, 101 | limitter: ReturnType, 102 | contact: Contact, 103 | ) { 104 | this.client = client; 105 | this.logger = logger; 106 | this.contact = contact; 107 | this.limitter = limitter; 108 | this.incomingMessage = IncomingMessage; 109 | 110 | this.message = { 111 | userNumber: contact.id._serialized, 112 | userName: contact.pushname, 113 | from: IncomingMessage.from, 114 | id: IncomingMessage.id.id, 115 | }; 116 | 117 | this.args = IncomingMessage.body 118 | .slice(env.PREFIX.length) 119 | .trim() 120 | .split(/ +/) 121 | .slice(1); 122 | } 123 | 124 | /** 125 | * Send text or image with caption to current person chatter 126 | * @param content The text that will sended 127 | * @param image Image that will sended in base64 data URL (Optional) 128 | */ 129 | async sendToCurrentPerson( 130 | content: MessageContent | MessageSendOptions, 131 | image?: MessageMedia, 132 | ) { 133 | await this.sendToOtherPerson(this.message.from, content, image); 134 | } 135 | 136 | /** 137 | * Reply current chatter using text or image with caption to current person chatter 138 | * @param content The text that will sended 139 | * @param image Image that will sended in base64 data URL (Optional) 140 | */ 141 | async replyToCurrentPerson( 142 | content: MessageContent | MessageSendOptions, 143 | image?: MessageMedia, 144 | ) { 145 | if (image) { 146 | await this.limitter( 147 | async () => 148 | await this.incomingMessage.reply( 149 | image, 150 | this.message.from, 151 | content as MessageSendOptions, 152 | ), 153 | ); 154 | } else { 155 | await this.limitter( 156 | async () => await this.incomingMessage.reply(content as MessageContent), 157 | ); 158 | } 159 | } 160 | 161 | /** 162 | * Send reaction to current person chatter 163 | * @param emoji Emoji that will sended 164 | */ 165 | async reactToCurrentPerson(emoji: string) { 166 | await this.limitter(async () => await this.incomingMessage.react(emoji)); 167 | } 168 | 169 | /** 170 | * Send text or image with caption to someone 171 | * @param content The text that will sended 172 | * @param image Image that will sended in base64 data URL (Optional) 173 | */ 174 | async sendToOtherPerson( 175 | to: string, 176 | content: MessageContent | MessageSendOptions, 177 | image?: MessageMedia, 178 | ) { 179 | if (image) { 180 | await this.limitter( 181 | async () => 182 | await this.client.sendMessage( 183 | to, 184 | image, 185 | content as MessageSendOptions, 186 | ), 187 | ); 188 | } else { 189 | await this.limitter( 190 | async () => 191 | await this.client.sendMessage(to, content as MessageContent), 192 | ); 193 | } 194 | } 195 | 196 | /** 197 | * Get current contact profile picture string 198 | */ 199 | async getContactProfilePicture() { 200 | return await this.contact.getProfilePicUrl(); 201 | } 202 | 203 | /** 204 | * Current chatter have quoted message that have media in it 205 | */ 206 | async hasQuotedMessageMedia() { 207 | const hasQuotedMessage = this.incomingMessage.hasQuotedMsg; 208 | 209 | if (hasQuotedMessage) { 210 | const quotedMessage = await this.incomingMessage.getQuotedMessage(); 211 | const quotedMessageMedia = await quotedMessage.downloadMedia(); 212 | 213 | return { 214 | quotedMessage, 215 | hasQuotedMessage, 216 | quotedMessageMedia, 217 | }; 218 | } 219 | 220 | return { 221 | hasQuotedMessage, 222 | }; 223 | } 224 | 225 | /** 226 | * Current chatter have message media in it 227 | */ 228 | async hasMediaInCurrentChat() { 229 | const hasMedia = this.incomingMessage.hasMedia; 230 | const currentChat = this.incomingMessage; 231 | 232 | if (hasMedia) { 233 | const currentMedia = await this.incomingMessage.downloadMedia(); 234 | 235 | return { 236 | hasMedia, 237 | currentChat, 238 | currentMedia, 239 | }; 240 | } 241 | 242 | return { hasMedia }; 243 | } 244 | 245 | /** 246 | * User property setter 247 | * @param user An user document by phone number 248 | */ 249 | setUserAndGameProperty(user: User, gameProperty: UserGameProperty) { 250 | this.user = user; 251 | this.gameProperty = gameProperty; 252 | } 253 | 254 | /** 255 | * Is current chatter sending message via DM chat 256 | */ 257 | get isDMChat() { 258 | return this.message.from.endsWith("@c.us"); 259 | } 260 | 261 | /** 262 | * Is current chatter sending message via Group chat 263 | */ 264 | get isGroupChat() { 265 | return this.message.from.endsWith("@g.us"); 266 | } 267 | 268 | /** 269 | * Is current chatter joinin a game session 270 | */ 271 | get isJoiningGame() { 272 | return this.gameProperty?.isJoiningGame; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/config/messages.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import { env } from "../env"; 5 | 6 | const packageInfo = JSON.parse( 7 | fs.readFileSync(path.resolve("package.json"), "utf-8"), 8 | ); 9 | 10 | /** 11 | * Main github repository for this project 12 | */ 13 | export const GITHUB_URL: string = packageInfo.repository.url 14 | .replace("git+", "") 15 | .replace(".git", ""); 16 | 17 | /** 18 | * Greeting template for all help message 19 | */ 20 | export const greeting = `Halo, saya adalah bot untuk bermain uno. 21 | Prefix: \`\`\`${env.PREFIX}\`\`\``; 22 | 23 | /** 24 | * Footer template for all help message 25 | */ 26 | export const footer = `Sumber Kode: ${GITHUB_URL} 27 | 28 | Dibuat oleh ${packageInfo.author} di bawah lisensi MIT.`; 29 | 30 | /** 31 | * General information about this bot 32 | */ 33 | export const botInfo = `${greeting} 34 | 35 | Untuk perintah lengkap ketik: 36 | \`\`\`${env.PREFIX}help\`\`\` 37 | 38 | ${footer}`; 39 | 40 | /** 41 | * Function for generate dynamic of all available commands name and serve other information 42 | * @param commands All valid commands available 43 | * @returns Help message string template 44 | */ 45 | export const helpTemplate = (commands: string[]) => `${greeting} 46 | 47 | *Daftar Perintah* 48 | ============ 49 | ${commands 50 | .sort((a, b) => a.localeCompare(b)) 51 | .map( 52 | (command, idx) => 53 | `- ${`\`\`\`${command}\`\`\``}${idx !== commands.length - 1 ? "\n" : ""}`, 54 | ) 55 | .join("")} 56 | 57 | *Disclaimer* 58 | ========= 59 | Bot ini menyimpan data *nomor telepon* serta *username* kamu untuk keperluan mekanisme permainan. 60 | 61 | Sebelum kamu bermain kamu telah *mengetahui* serta *menyetujui* bahwa kamu *mengizinkan* datamu untuk disimpan. 62 | 63 | Jika ingin *menghapus* data, silahkan *hubungi operator bot* yang bertanggung jawab. 64 | 65 | *Ikhtisar* 66 | ====== 67 | Bot ini adalah bot yang digunakan untuk bermain uno di whatsapp. Cara kerjanya dengan mengirimkan perintah lewat DM pribadi ke bot ini, tapi masih bisa digunakan di grup semisal untuk membuat permainan. 68 | 69 | Untuk membuat permainan caranya dengan menjalankan 70 | 71 | \`\`\`${env.PREFIX}creategame\`\`\` (atau \`\`\`${env.PREFIX}cg\`\`\`) 72 | 73 | dan akan membuat kode yang bisa diteruskan ke orang lain. 74 | 75 | Orang yang diberikan meneruskan kembali kode itu ke bot dan akan masuk ke sesi permainan sesuai dengan kode yang sudah diberikan sebelumnya. 76 | 77 | Setelah dirasa sudah cukup orang, permainan bisa dimulai menggunakan 78 | 79 | \`\`\`${env.PREFIX}startgame\`\`\` (atau \`\`\`${env.PREFIX}sg\`\`\`) 80 | 81 | kartu akan diberikan dan permainan dimulai. 82 | 83 | 84 | Untuk bermain, gunakan 85 | 86 | \`\`\`${env.PREFIX}play \`\`\` 87 | (atau \`\`\`${env.PREFIX}p \`\`\`) 88 | 89 | untuk menaruh kartu yang sesuai dengan apa yang ada di deck. 90 | 91 | Jika valid, kartu akan ditaruh dan giliran bermain akan beralih ke pemain selanjutnya. 92 | 93 | 94 | Jika kamu tidak memiliki kartu ambilah kartu baru dengan menggunakan 95 | 96 | \`\`\`${env.PREFIX}draw\`\`\` (atau \`\`\`${env.PREFIX}d\`\`\`) 97 | 98 | maka kartu baru akan diambil dan giliran bermain akan beralih ke pemain selanjutnya. 99 | 100 | Untuk berkomunikasi dengan pemain lain di game, gunakan 101 | 102 | \`\`\`${env.PREFIX}say \`\`\` 103 | 104 | 105 | Untuk melihat lebih jelas apa maksud dari perintah, gunakan 106 | 107 | \`\`\`${env.PREFIX}help \`\`\` 108 | 109 | 110 | ${footer}`; 111 | 112 | /** 113 | * Function for generate specific command can do 114 | * @param command Command name 115 | * @param explanation Explanation about what the command will do 116 | * @param alias List of all command alias available 117 | * @param messageExample Example of the command if triggered 118 | * @param param Parameter explanation (optional) 119 | * @returns Template string for replying specific command 120 | */ 121 | const replyBuilder = ( 122 | command: string, 123 | explanation: string, 124 | alias: string[], 125 | messageExample: string, 126 | param?: string, 127 | ) => `${greeting} 128 | 129 | ${command.charAt(0).toUpperCase() + command.slice(1)} 130 | ${Array.from({ length: command.length }).fill("=").join("")} 131 | ${explanation} 132 | 133 | Contoh penggunaan: 134 | \`\`\`${env.PREFIX}${command}${param ? ` ${param}` : ""}\`\`\` 135 | 136 | Alias: ${alias.map((a) => `\`\`\`${a}\`\`\``).join(", ")} 137 | 138 | Contoh balasan: 139 | ${messageExample} 140 | 141 | ${footer}`; 142 | 143 | /** 144 | * All replies string collection for help message 145 | */ 146 | export const replies = { 147 | ban: replyBuilder( 148 | "ban", 149 | "Perintah ini digunakan untuk menge-ban seseorang, semisal ada orang yang tidak dikenali masuk ke permainan.", 150 | ["b"], 151 | '"Berhasil menge-ban E. Sekarang dia tidak ada dalam permainan."', 152 | "", 153 | ), 154 | 155 | cards: replyBuilder( 156 | "cards", 157 | "Perintah ini digunakan untuk mengecek kartu yang ada pada saat kamu bermain.", 158 | ["c"], 159 | '"Kartu kamu: greenskip, yellow4, red6, blue1"', 160 | ), 161 | 162 | creategame: replyBuilder( 163 | "creategame", 164 | `Perintah ini digunakan untuk membuat permainan baru. 165 | 166 | Setelah kode berhasil dibuat, bot akan mengirimkan kode yang bisa diteruskan ke pemain lain agar bisa bergabung ke dalam permainan.`, 167 | ["cg", "create"], 168 | `"Game berhasil dibuat. 169 | 170 | Ajak teman kamu untuk bermain..."`, 171 | ), 172 | 173 | draw: replyBuilder( 174 | "draw", 175 | `Perintah ini digunakan untuk mengambil kartu baru pada saat giliranmu. 176 | 177 | Terkadang kamu tidak memiliki kartu yang pas pada saat bermain, perintah ini bertujuan untuk mengambil kartu baru.`, 178 | ["d", "pickup", "newcard"], 179 | '"Berhasil mengambil kartu baru, *red6*. Selanjutnya adalah giliran A untuk bermain"', 180 | ), 181 | 182 | endgame: replyBuilder( 183 | "endgame", 184 | `Perintah ini digunakan untuk menghentikan permainan yang belum/sedang berjalan. 185 | 186 | Perintah ini hanya bisa digunakan oleh orang yang membuat permainan.`, 187 | ["eg", "end"], 188 | '"A telah menghentikan permainan. Terimakasih sudah bermain!"', 189 | ), 190 | 191 | infogame: replyBuilder( 192 | "infogame", 193 | `Perintah ini digunakan untuk mengetahui informasi dari sebuah permainan. 194 | 195 | Jika kamu sudah memasuki sebuah permainan, tidak perlu memasukan id game, tetapi kalau belum diperlukan id game tersebut.`, 196 | ["i", "ig", "info"], 197 | '"Game ID: XXXXXX..."', 198 | "", 199 | ), 200 | 201 | joingame: replyBuilder( 202 | "joingame", 203 | `Perintah ini digunakan untuk masuk ke sebuah permainan. 204 | 205 | Diperlukan id dari game yang sudah dibuat, biasanya tidak perlu mengetikkan lagi karena sudah diberikan oleh pembuat gamenya langsung.`, 206 | ["j", "jg", "join"], 207 | '"Berhasil join ke game "XXXX", tunggu pembuat ruang game ini memulai permainannya!"', 208 | "", 209 | ), 210 | 211 | kick: replyBuilder( 212 | "kick", 213 | "Perintah ini digunakan untuk kick seseorang, semisal ada teman yang AFK pada saat permainan.", 214 | ["k"], 215 | '"Berhasil mengkick E. Sekarang dia tidak ada dalam permainan."', 216 | "", 217 | ), 218 | 219 | leaderboard: replyBuilder( 220 | "leaderboard", 221 | `Perintah ini digunakan untuk mengetahui siapa saja terampil dalam bermain. 222 | 223 | Akan terdapat list nama pemain, berapa permainan yang dimainkan, dan rata-rata permainan.`, 224 | ["board", "lb"], 225 | "Papan peringkat pemain saat ini", 226 | ), 227 | 228 | leavegame: replyBuilder( 229 | "leavegame", 230 | `Perintah ini digunakan untuk keluar dari sebuah permainan. 231 | 232 | Perintah ini bisa digunakan pada saat permainan atau saat menunggu.`, 233 | ["l", "lg", "quit", "leave", "leavegame"], 234 | '"Anda berhasil keluar dari game. Terimakasih telah bermain!"', 235 | ), 236 | 237 | play: replyBuilder( 238 | "play", 239 | `Perintah ini digunakan untuk mengeluarkan kartu dalam sebuah permainan. 240 | 241 | Jika kartu cocok akan ditaruh ke deck dan pemain selanjutnya akan mendapatkan giliran.`, 242 | ["p"], 243 | '"Berhasil mengeluarkan kartu *red9*, selanjutnya adalah giliran B untuk bermain"', 244 | "", 245 | ), 246 | 247 | say: replyBuilder( 248 | "say", 249 | `Perintah ini digunakan untuk mengirim sesuatu dalam sebuah permainan. 250 | 251 | Anda dapat mengirim gambar, GIF, dan stiker dengan keterangan. Untuk mengirim gambar dan stiker, berikan keterangan dan ikuti perintah yang sesuai. Jika ingin mengirim stiker, kirimkan stiker terlebih dahulu, kemudian balas dengan memberikan keterangan. Anda juga dapat menggunakan teknik balasan untuk gambar dan GIF. Selain itu, bisa mengirim teks biasa.`, 252 | ["s"], 253 | '"USERNAME: pesan disini"', 254 | "", 255 | ), 256 | 257 | startgame: replyBuilder( 258 | "startgame", 259 | `Perintah ini digunakan untuk memulai permainan yang belum berjalan. 260 | 261 | Perintah ini hanya bisa digunakan oleh orang yang membuat permainan.`, 262 | ["sg", "start"], 263 | '"Game berhasil dimulai! Sekarang giliran C untuk bermain"', 264 | ), 265 | }; 266 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */, 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | "tsBuildInfoFile": "./.tsbuildinfo" /* Specify the path to .tsbuildinfo incremental compilation file. */, 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | "lib": [ 16 | "es6" 17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 18 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 19 | // "experimentalDecorators": false /* Enable experimental support for TC39 stage 2 draft decorators. */, 20 | // "emitDecoratorMetadata": false /* Emit design-type metadata for decorated declarations in source files. */, 21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 24 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 27 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 28 | 29 | /* Modules */ 30 | "module": "commonjs" /* Specify what module code is generated. */, 31 | "rootDir": "./src" /* Specify the root folder within your source files. */, 32 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 33 | "baseUrl": "./src" /* Specify the base directory to resolve non-relative module names. */, 34 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 35 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 36 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 37 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 38 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 39 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 40 | // "resolveJsonModule": true, /* Enable importing .json files. */ 41 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 45 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 47 | 48 | /* Emit */ 49 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 50 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 51 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 52 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 53 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 54 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 55 | "removeComments": true /* Disable emitting comments. */, 56 | // "noEmit": true, /* Disable emitting files from a compilation. */ 57 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 58 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 59 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 60 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 63 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 64 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 65 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 66 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 67 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 68 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 69 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 70 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 71 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 72 | 73 | /* Interop Constraints */ 74 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 79 | 80 | /* Type Checking */ 81 | "strict": true /* Enable all strict type-checking options. */, 82 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 83 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 84 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 85 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 86 | "strictPropertyInitialization": false /* Check for class properties that are declared but not set in the constructor. */, 87 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 88 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 89 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 90 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 91 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 92 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 93 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 94 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 95 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 96 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 97 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 98 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 99 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 100 | 101 | /* Completeness */ 102 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 103 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 104 | }, 105 | "include": ["src/**/*.ts", "src/**/*.d.ts"], 106 | "typedocOptions": { 107 | "entryPoints": [ 108 | "./src/bot.ts", 109 | "./src/lib", 110 | "./src/utils", 111 | "./src/config/*.ts", 112 | "./src/handler/*.ts" 113 | ], 114 | "out": "docs", 115 | "excludeExternals": true 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Gambar WUNO bot](./assets/og.png) 2 | 3 |

4 |

WUNO (Whatsapp UNO) Bot

5 |

Bot whatsapp yang berguna untuk bermain UNO.

6 |

7 | 8 |
9 | 10 | [![ES Lint & Typing test](https://github.com/reacto11mecha/wuno-bot/actions/workflows/lint-typing.yml/badge.svg)](https://github.com/reacto11mecha/wuno-bot/actions/workflows/lint-typing.yml) [![Unit test](https://github.com/reacto11mecha/wuno-bot/actions/workflows/unit-test.yml/badge.svg)](https://github.com/reacto11mecha/wuno-bot/actions/workflows/unit-test.yml) 11 | 12 | > Project ini terinspirasi dari [Exium1/UnoBot](https://github.com/Exium1/UnoBot) dan [mjsalerno/UnoBot](https://github.com/mjsalerno/UnoBot) yang beberapa asset dan logika pemrograman juga terdapat dalam project ini. 13 | 14 | Bot ini adalah bot whatsapp yang memungkinkan pengguna untuk bermain UNO langsung di whatsapp. Dengan menjapri bot, mengirimkan kode permainan ke teman anda, dan memulai permainan kamu bisa bermain UNO seperti kamu bermain dengan teman langsung. 15 | 16 | Jika masih bingung atau ingin mengetahui bagaimana bot ini bekerja, tonton video penjelasan [ini](https://youtu.be/c43occbi7Fw) di youtube. 17 | 18 | ## Sebelum Menggunakan 19 | 20 | Bot ini tidak terafiliasi dan didukung oleh WhatsApp maupun UNO. Mohon pengertian jika menemukan masalah suatu saat nomor bot diblokir oleh pihak WhatsApp. 21 | 22 | Bot ini menyimpan data **nomor telepon** serta **username** pemain untuk keperluan mekanisme permainan. 23 | 24 | Jika kamu adalah orang yang menjalankan bot ini, kamu **wajib** bertanggung jawab atas data-data pemain yang tersimpan. **Jangan** melimpahkan data pribadi ke pihak yang tidak bertanggung jawab. Jika mereka ingin menghapus data pribadi mereka, kamu **wajib** menghapusnya. 25 | 26 | Jika kamu adalah pemain, kamu berarti telah **mengetahui** serta **menyetujui** bahwa kamu **mengizinkan** datamu untuk disimpan ke bot lawan bicara. Jika ingin **menghapus** data, silahkan **hubungi operator bot** yang bertanggung jawab. 27 | 28 | ### Pesan untuk Administrator 29 | 30 | Untuk menghindari kejadian yang tidak diinginkan, lebih baik menggunakan Database MySQL yang di host secara local. Kemudian, disarankan untuk tidak menghosting bot secara 24 jam, host bot secara local memang pada saat dibutuhkan. 31 | 32 | ### Pesan untuk Pemain 33 | 34 | Pilihlah bot dengan administrator yang bertanggung jawab dan dapat dipercaya. Lebih dari itu, pilihlah administrator yang mengerti dan teman dekatmu. 35 | 36 | ## Prerequisites 37 | 38 | Anda butuh 39 | 40 | - Node.js LTS dan NPM (atau Package Manager lainnya) 41 | - Database MySQL atau MariaDB 42 | - Akun whatsapp tak terpakai 43 | - Google chrome 44 | 45 | ## Pemakaian 46 | 47 | ### Cloning Dari Github 48 | 49 | Jalankan perintah ini Command Line. 50 | 51 | ```sh 52 | # HTTPS 53 | git clone https://github.com/reacto11mecha/wuno-bot.git 54 | 55 | # SSH 56 | git clone git@github.com:reacto11mecha/wuno-bot.git 57 | ``` 58 | 59 | ### Menginstall package 60 | 61 | Anda ke root directory project dan menginstall package yang diperlukan. 62 | 63 | ```sh 64 | npm install 65 | 66 | # atau menggunakan pnpm 67 | pnpm install 68 | ``` 69 | 70 | ### Buat file `.env` 71 | 72 | Pertama-tama, copy file `env.example` menjadi `.env` dan isikan value yang sesuai. 73 | 74 | Keterangan `.env`: 75 | 76 | - `DATABASE_URL`: URL Database MySQL yang akan dijadikan penyimpanan data (**WAJIB**) 77 | - `CHROME_PATH`: Path ke executable google chrome yang terinstall (**WAJIB**) 78 | - `PREFIX`: Prefix bot agar bisa dipanggil dan digunakan, default `U#` (Opsional) 79 | 80 | > Di perlukan google chrome supaya bisa menerima dan mengirim gif, sticker, dan gambar secara konsisten. Penjelasan lebih lanjut, cek dokumentasi [wwebjs](https://wwebjs.dev/guide/handling-attachments.html#caveat-for-sending-videos-and-gifs). 81 | 82 | ### Mengenerate dan push schema ke database 83 | 84 | Karena menggunakan database yang SQL-Based dan prisma, diperlukan untuk mengenerate dan push schema ke database. Di bawah ini adalah perintah-perintah yang harus dilaksanakan. 85 | 86 | Generate schema prisma: 87 | 88 | ```sh 89 | npm run db:generate 90 | 91 | # atau menggunakan pnpm 92 | pnpm db:generate 93 | ``` 94 | 95 | Push schema prisma ke database: 96 | 97 | ```sh 98 | npm run db:push 99 | 100 | # atau menggunakan pnpm 101 | pnpm db:push 102 | ``` 103 | 104 | ### Menjalankan Bot 105 | 106 | Sebelum menjalankan, terlebih dahulu mem-build kode typescript supaya bisa dijalankan di production mode. 107 | 108 | ```sh 109 | npm run build 110 | 111 | # atau menggunakan pnpm 112 | pnpm build 113 | ``` 114 | 115 | Selesai mem-build bot, **jangan lupa menjalankan database MySQL/MariaDB**. Jika sudah berjalan baru bisa menggunakan bot dengan mengetikkan 116 | 117 | ```sh 118 | npm start 119 | 120 | # atau menggunakan pnpm 121 | pnpm start 122 | ``` 123 | 124 | Jika baru pertama kali menjalankan, scan barcode di terminal untuk dihubungkan ke whatsapp di handphone. 125 | 126 | Jika ingin dijalankan seperti mode production menggunakan `pm2` bisa menjalankan perintah di bawah ini, jangan lupa autentikasi terlebih dahulu mengikuti langkah di atas karena lebih mudah. Jangan lupa untuk menginstall `pm2` secara global. 127 | 128 | ```sh 129 | pm2 start ecosystem.config.js 130 | ``` 131 | 132 | ### Penjelasan Permainan 133 | 134 | Bot ini adalah bot yang digunakan untuk bermain uno di whatsapp. Cara kerjanya dengan mengirimkan perintah lewat DM pribadi ke bot ini, tapi juga bisa digunakan di grup namun terbatas untuk membuat dan bergabung dalam permainan. 135 | 136 | Untuk membuat permainan caranya dengan menjalankan `U#creategame` (atau `U#cg`) dan akan membuat kode yang bisa diteruskan ke orang lain. 137 | 138 | Orang yang diberikan meneruskan kembali kode itu ke bot dan akan masuk ke sesi permainan sesuai dengan kode yang sudah diberikan sebelumnya. 139 | 140 | Setelah dirasa sudah cukup orang, permainan bisa dimulai menggunakan `U#startgame` (atau `U#sg`) kartu akan diberikan dan permainan dimulai. 141 | 142 | Untuk bermain, gunakan `U#play ` 143 | (atau `U#p `) untuk menaruh kartu yang sesuai dengan apa yang ada di deck. Jika valid, kartu akan ditaruh dan giliran bermain akan beralih ke pemain selanjutnya. 144 | 145 | Kartu di bot ini memiliki sebuah format. Kartu yang ini memiliki 4 warna yaitu warna merah, kuning hijau, dan biru yang di ikuti dengan nomor 0 sampai 9, misal `red0`. Kemudian kartu pilih warna `wild` juga tambah 4 `wilddraw4` yang bisa di sebutkan warnanya. Kemudian kartu spesial yang terdiri reverse (memutar giliran main), skip (melewati giliran main), dan `draw2` (tambah dua kartu) dengan format warna disebut terlebih dahulu baru jenis kartu spesial disebutkan. 146 | 147 | Jika kamu tidak memiliki kartu ambilah kartu baru dengan menggunakan `U#draw` (atau `U#d`) maka kartu baru akan diambil dan giliran bermain akan beralih ke pemain selanjutnya. 148 | 149 | Untuk berkomunikasi dengan pemain lain di game, gunakan `U#say `. 150 | 151 | Untuk melihat lebih jelas apa maksud dari perintah, gunakan `U#help `. 152 | 153 | ### Daftar Perintah 154 | 155 | Berikut daftar perintah yang sudah dibuat. Jika konfigurasi prefix diubah maka prefix akan mengikuti konfigurasi yang sudah ada. 156 | 157 | - `ban` 158 | 159 | Perintah ini digunakan untuk menge-ban seseorang, semisal ada orang yang tidak dikenali masuk ke permainan. 160 | 161 | Contoh penggunaan: 162 | 163 | _`U# ban `_ 164 | 165 | Alias: _`b`_ 166 | 167 | Contoh balasan: 168 | 169 | ``` 170 | Berhasil menge-ban E. Sekarang dia tidak ada dalam permainan. 171 | ``` 172 | 173 | - `cards` 174 | 175 | Perintah ini digunakan untuk mengecek kartu yang ada pada saat kamu bermain. 176 | 177 | Contoh penggunaan: 178 | 179 | _`U# cards`_ 180 | 181 | Alias: _`c`_ 182 | 183 | Contoh balasan: 184 | 185 | ``` 186 | Kartu kamu: greenskip, yellow4, red6, blue1 187 | ``` 188 | 189 | - `creategame` 190 | 191 | Perintah ini digunakan untuk membuat permainan baru. 192 | 193 | Setelah kode berhasil dibuat, bot akan mengirimkan kode yang bisa diteruskan ke pemain lain agar bisa bergabung ke dalam permainan. 194 | 195 | Contoh penggunaan: 196 | 197 | `U# creategame` 198 | 199 | Alias: _`cg`_, _`create`_ 200 | 201 | Contoh balasan: 202 | 203 | ``` 204 | Game berhasil dibuat. 205 | 206 | Ajak teman kamu untuk bermain... 207 | ``` 208 | 209 | - `draw` 210 | 211 | Perintah ini digunakan untuk mengambil kartu baru pada saat giliranmu. 212 | 213 | Terkadang kamu tidak memiliki kartu yang pas pada saat bermain, perintah ini bertujuan untuk mengambil kartu baru. ke dalam permainan. 214 | 215 | Contoh penggunaan: 216 | 217 | _`U# draw`_ 218 | 219 | Alias: _`d`_, _`pickup`_, _`newcard`_ 220 | 221 | Contoh balasan: 222 | 223 | ``` 224 | Berhasil mengambil kartu baru, red6. Selanjutnya adalah giliran A untuk bermain 225 | ``` 226 | 227 | - `endgame` 228 | 229 | Perintah ini digunakan untuk menghentikan permainan yang belum/sedang berjalan. 230 | 231 | Perintah ini hanya bisa digunakan oleh orang yang membuat permainan. 232 | 233 | Contoh penggunaan: 234 | 235 | _`U# endgame`_ 236 | 237 | Alias: _`eg`_, _`end`_ 238 | 239 | Contoh balasan: 240 | 241 | ``` 242 | A telah menghentikan permainan. Terimakasih sudah bermain! 243 | ``` 244 | 245 | - `infogame` 246 | 247 | Perintah ini digunakan untuk mengetahui informasi dari sebuah permainan. 248 | 249 | Jika kamu sudah memasuki sebuah permainan, tidak perlu memasukan id game, tetapi kalau belum diperlukan id game tersebut. 250 | 251 | Contoh penggunaan: 252 | 253 | _`U# infogame `_ 254 | 255 | Alias: _`i`_, _`ig`_, _`info`_ 256 | 257 | Contoh balasan: 258 | 259 | ``` 260 | A telah menghentikan permainan. Terimakasih sudah bermain! 261 | ``` 262 | 263 | - `joingame` 264 | 265 | Perintah ini digunakan untuk masuk ke sebuah permainan. 266 | 267 | Diperlukan id dari game yang sudah dibuat, biasanya tidak perlu mengetikkan lagi karena sudah diberikan oleh pembuat gamenya langsung. 268 | 269 | Contoh penggunaan: 270 | 271 | _`U# joingame `_ 272 | 273 | Alias: _`j`_, _`jg`_, _`join`_ 274 | 275 | Contoh balasan: 276 | 277 | ``` 278 | Berhasil join ke game "XXXX", tunggu pembuat ruang game ini memulai permainannya! 279 | ``` 280 | 281 | - `kick` 282 | 283 | Perintah ini digunakan untuk kick seseorang, semisal ada teman yang AFK pada saat permainan. 284 | 285 | Contoh penggunaan: 286 | 287 | _`U# kick `_ 288 | 289 | Alias: _`k`_ 290 | 291 | Contoh balasan: 292 | 293 | ``` 294 | Berhasil mengkick E. Sekarang dia tidak ada dalam permainan. 295 | ``` 296 | 297 | - `leaderboard` 298 | 299 | Perintah ini digunakan untuk mengetahui siapa saja terampil dalam bermain. 300 | 301 | Akan terdapat list nama pemain, berapa permainan yang dimainkan, dan rata-rata permainan. 302 | 303 | Contoh penggunaan: 304 | 305 | _`U# leaderboard`_ 306 | 307 | Alias: _`board`_, _`lb`_ 308 | 309 | Contoh balasan: 310 | Papan peringkat pemain saat ini 311 | 312 | - `leavegame` 313 | 314 | Perintah ini digunakan untuk keluar dari sebuah permainan. 315 | 316 | Perintah ini bisa digunakan pada saat permainan atau saat menunggu. 317 | 318 | Contoh penggunaan: 319 | 320 | _`U# leavegame`_ 321 | 322 | Alias: _`l`_, _`lg`_, _`quit`_, _`leave`_, _`leavegame`_ 323 | 324 | Contoh balasan: 325 | 326 | ``` 327 | Anda berhasil keluar dari game. Terimakasih telah bermain! 328 | ``` 329 | 330 | - `play` 331 | 332 | Perintah ini digunakan untuk mengeluarkan kartu dalam sebuah permainan. 333 | 334 | Jika kartu cocok akan ditaruh ke deck dan pemain selanjutnya akan mendapatkan giliran. 335 | 336 | Contoh penggunaan: 337 | 338 | _`U# play `_ 339 | 340 | Alias: _`p`_ 341 | 342 | Contoh balasan: 343 | 344 | ``` 345 | Berhasil mengeluarkan kartu *red9*, selanjutnya adalah giliran B untuk bermain 346 | ``` 347 | 348 | - `say` 349 | 350 | Perintah ini digunakan untuk mengatakan sesuatu dalam sebuah permainan. 351 | 352 | Kamu bisa mengirim gambar, gif, dan sticker dengan caption juga. Untuk mengirim gambar dan sticker kamu bisa mengisi caption dan diisikan perintah yang sesuai. Jika ingin mengirimkan sticker maka kamu harus mengirimkan sticker terlebih dahulu, lalu balas sticker dengan mengisikan caption. Kamu juga bisa melakukan teknik balas pada gambar maupun gif. Selain itu, kamu bisa mengirimkan text biasa. 353 | 354 | Contoh penggunaan: 355 | 356 | _`U# say `_ 357 | 358 | Alias: _`s`_ 359 | 360 | Contoh balasan: 361 | 362 | ``` 363 | USERNAME: pesan disini 364 | ``` 365 | 366 | - `startgame` 367 | 368 | Perintah ini digunakan untuk memulai permainan yang belum berjalan. 369 | 370 | Perintah ini hanya bisa digunakan oleh orang yang membuat permainan. 371 | 372 | Contoh penggunaan: 373 | 374 | _`U# startgame`_ 375 | 376 | Alias: _`sg`_, _`start`_ 377 | 378 | Contoh balasan: 379 | 380 | ``` 381 | Game berhasil dimulai! Sekarang giliran C untuk bermain 382 | ``` 383 | 384 | ### TO-DO 385 | 386 | Fitur yang kedepannya mungkin ditambahkan 387 | 388 | - [ ] Bisa mengeluarkan kartu banyak yang valid dalam sekali giliran 389 | - [ ] Support multi bahasa 390 | - [ ] Menerapkan disappear message ketika user berbicara dengan bot 391 | 392 | ### Lisensi 393 | 394 | Semua kode yang ada di repositori ini bernaung dibawah [MIT License](LICENSE). 395 | -------------------------------------------------------------------------------- /src/config/cards.ts: -------------------------------------------------------------------------------- 1 | import { random, weightedRandom } from "../utils"; 2 | 3 | /** 4 | * Typing for all possible UNO card color 5 | */ 6 | export type color = "red" | "green" | "blue" | "yellow"; 7 | 8 | /** 9 | * Typing for all possible UNO card number 10 | */ 11 | export type possibleNumber = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; 12 | 13 | /** 14 | * Typing for all possible UNO card 15 | */ 16 | export type allCard = 17 | | `${color}${possibleNumber}` 18 | | `wild${color}` 19 | | `wilddraw4${color}` 20 | | `${color}reverse` 21 | | `${color}skip` 22 | | `${color}draw2` 23 | | "wild" 24 | | "wilddraw4"; 25 | 26 | /** 27 | * Enum for single state card 28 | */ 29 | export const EGetCardState = { 30 | VALID_NORMAL: "VALID_NORMAL", 31 | VALID_WILD_PLUS4: "VALID_WILD_PLUS4", 32 | VALID_WILD: "VALID_WILD", 33 | VALID_SPECIAL: "VALID_SPECIAL", 34 | INVALID: "INVALID", 35 | } as const; 36 | 37 | /** 38 | * Typing for EGetCardState enum 39 | */ 40 | export type EGetCardStateType = keyof typeof EGetCardState; 41 | 42 | /** 43 | * Interface for return type of "getCardState" function 44 | */ 45 | export interface IGetCardState { 46 | state: EGetCardStateType; 47 | color?: color; 48 | number?: possibleNumber; 49 | type?: "draw2" | "reverse" | "skip"; 50 | } 51 | 52 | /** 53 | * List of all available cards 54 | */ 55 | export const cards: allCard[] = [ 56 | "red0", 57 | "red1", 58 | "red2", 59 | "red3", 60 | "red4", 61 | "red5", 62 | "red6", 63 | "red7", 64 | "red8", 65 | "red9", 66 | "wildred", 67 | "wilddraw4red", 68 | 69 | "green0", 70 | "green1", 71 | "green2", 72 | "green3", 73 | "green4", 74 | "green5", 75 | "green6", 76 | "green7", 77 | "green8", 78 | "green9", 79 | "wildgreen", 80 | "wilddraw4green", 81 | 82 | "blue0", 83 | "blue1", 84 | "blue2", 85 | "blue3", 86 | "blue4", 87 | "blue5", 88 | "blue6", 89 | "blue7", 90 | "blue8", 91 | "blue9", 92 | "wildblue", 93 | "wilddraw4blue", 94 | 95 | "yellow0", 96 | "yellow1", 97 | "yellow2", 98 | "yellow3", 99 | "yellow4", 100 | "yellow5", 101 | "yellow6", 102 | "yellow7", 103 | "yellow8", 104 | "yellow9", 105 | "wildyellow", 106 | "wilddraw4yellow", 107 | 108 | // Lucky system 109 | "wild", 110 | 111 | "redreverse", 112 | "redskip", 113 | "reddraw2", 114 | 115 | "greenreverse", 116 | "greenskip", 117 | "greendraw2", 118 | 119 | "bluereverse", 120 | "blueskip", 121 | "bluedraw2", 122 | 123 | "yellowreverse", 124 | "yellowskip", 125 | "yellowdraw2", 126 | 127 | "wilddraw4", 128 | ]; 129 | 130 | export const regexValidNormal = /^(red|green|blue|yellow)[0-9]$/; 131 | export const regexValidSpecial = 132 | /^(red|green|blue|yellow)(draw2|reverse|skip)$/; 133 | export const regexValidWildColorOnly = /^(wild)(red|green|blue|yellow)$/; 134 | export const regexValidWildColorPlus4Only = 135 | /^(wilddraw4)(red|green|blue|yellow)$/; 136 | 137 | const reducedByNumbers = Array.from({ length: 14 }).map((_, idx) => idx); 138 | const filteredWildColor = cards 139 | .filter((card) => !regexValidWildColorOnly.test(card)) 140 | .filter((card) => !regexValidWildColorPlus4Only.test(card)); 141 | const appropriateInitialCards = cards 142 | .filter((e) => !e.startsWith("wild")) 143 | .filter((e) => !e.endsWith("skip")) 144 | .filter((e) => !e.endsWith("draw2")) 145 | .filter((e) => !e.endsWith("reverse")); 146 | 147 | /** 148 | * Class that have contains collection for static function 149 | */ 150 | export class filterCardByGivenCard { 151 | static ValidNormal( 152 | actualCard: allCard, 153 | color: color, 154 | number: possibleNumber, 155 | ): allCard[] { 156 | const sameByColor = [...filteredWildColor] 157 | .filter((card) => card.includes(color)) 158 | .filter((card) => !card.includes("draw2")) 159 | .filter((card) => !card.includes("reverse")) 160 | .filter((card) => !card.includes("skip")); 161 | 162 | const sameByNumber = [...filteredWildColor] 163 | .filter((card) => card.includes(number as unknown as string)) 164 | .filter((card) => !card.includes("draw2")) 165 | .filter((card) => !card.includes("reverse")) 166 | .filter((card) => !card.includes("skip")); 167 | 168 | return [...new Set([...sameByColor, ...sameByNumber])].filter( 169 | (card) => !card.includes(actualCard), 170 | ); 171 | } 172 | 173 | static GetCardByColor(color: color): allCard[] { 174 | return [...filteredWildColor].filter((card) => card.includes(color)); 175 | } 176 | } 177 | 178 | const reusableGetCardByColor = (color: color) => { 179 | const filteredCard = filterCardByGivenCard.GetCardByColor(color); 180 | 181 | const idxCard = Math.floor(random() * filteredCard.length); 182 | const choosenCard = filteredCard[idxCard]; 183 | 184 | return choosenCard; 185 | }; 186 | 187 | enum givenCardCondition { 188 | ByGivenCard, 189 | ByMagicCard, 190 | ByRandomPick, 191 | } 192 | 193 | /** 194 | * Class that contains static function for picking card stuff 195 | */ 196 | export class CardPicker { 197 | /** 198 | * Pick a straight up random card 199 | * @returns A random card that doesn't biased by anything 200 | */ 201 | static pickRandomCard(): allCard { 202 | const idxReduced = Math.floor(random() * reducedByNumbers.length); 203 | const reducedNumber = reducedByNumbers[idxReduced]; 204 | 205 | const idxCard = Math.floor(random() * (cards.length - reducedNumber)); 206 | const card = filteredWildColor[idxCard]; 207 | 208 | if (!card) return CardPicker.pickRandomCard(); 209 | 210 | return card; 211 | } 212 | 213 | static pickByMagicCardBasedOnColor(color: color) { 214 | const cards = [ 215 | `${color}draw2`, 216 | `${color}reverse`, 217 | `${color}skip`, 218 | "wild", 219 | "wilddraw4", 220 | ] as allCard[]; 221 | 222 | const idxCard = Math.floor(random() * cards.length); 223 | const card = cards[idxCard]; 224 | 225 | if (!card) return CardPicker.pickRandomCard(); 226 | 227 | return card; 228 | } 229 | 230 | /** 231 | * Pick any card that appropriate as normal card 232 | * @returns Appropriate initial card 233 | */ 234 | static getInitialCard() { 235 | return appropriateInitialCards[ 236 | Math.floor(random() * appropriateInitialCards.length) 237 | ]; 238 | } 239 | 240 | /** 241 | * Card picker by given card 242 | * @param card A valid UNO card 243 | * @returns Biased card by given card, random card, or initial card in ratio 13:5:1 244 | */ 245 | static pickCardByGivenCard(card: allCard): allCard { 246 | const status = weightedRandom( 247 | [ 248 | givenCardCondition.ByGivenCard, 249 | givenCardCondition.ByMagicCard, 250 | givenCardCondition.ByRandomPick, 251 | ], 252 | [50, 20, 10], 253 | ); 254 | 255 | switch (status) { 256 | case givenCardCondition.ByGivenCard: { 257 | const state = CardPicker.getCardState(card); 258 | 259 | switch (state.state) { 260 | case EGetCardState.VALID_NORMAL: { 261 | const filteredCard = filterCardByGivenCard.ValidNormal( 262 | card, 263 | state.color!, 264 | state.number!, 265 | ); 266 | 267 | const idxCard = Math.floor(random() * filteredCard.length); 268 | const choosenCard = filteredCard[idxCard]; 269 | 270 | return choosenCard; 271 | } 272 | 273 | /* eslint-disable no-fallthrough */ 274 | 275 | case EGetCardState.VALID_WILD: 276 | case EGetCardState.VALID_SPECIAL: 277 | case EGetCardState.VALID_WILD_PLUS4: 278 | return reusableGetCardByColor(state.color!); 279 | } 280 | } 281 | 282 | case givenCardCondition.ByMagicCard: { 283 | const state = CardPicker.getCardState(card); 284 | 285 | return CardPicker.pickByMagicCardBasedOnColor(state.color!); 286 | } 287 | 288 | case givenCardCondition.ByRandomPick: 289 | default: 290 | return CardPicker.pickRandomCard(); 291 | } 292 | } 293 | 294 | /** 295 | * Get the state of the current card (normal card, wild card, etc.) 296 | * @param card Valid given card 297 | * @returns Object of the card state 298 | */ 299 | static getCardState(card: allCard): IGetCardState { 300 | const normalizeCard = card.trim().toLowerCase(); 301 | 302 | switch (true) { 303 | case regexValidNormal.test(normalizeCard): { 304 | const color = normalizeCard.match( 305 | regexValidNormal, 306 | )![1] as IGetCardState["color"]; 307 | const number = Number( 308 | normalizeCard.slice(color!.length), 309 | )! as IGetCardState["number"]; 310 | 311 | return { state: EGetCardState.VALID_NORMAL, color, number }; 312 | } 313 | 314 | case regexValidWildColorPlus4Only.test(normalizeCard): { 315 | const color = normalizeCard.match( 316 | regexValidWildColorPlus4Only, 317 | )![2] as IGetCardState["color"]; 318 | 319 | return { state: EGetCardState.VALID_WILD_PLUS4, color }; 320 | } 321 | 322 | case regexValidWildColorOnly.test(normalizeCard): { 323 | const color = normalizeCard.match( 324 | regexValidWildColorOnly, 325 | )![2] as IGetCardState["color"]; 326 | 327 | return { 328 | state: EGetCardState.VALID_WILD, 329 | color, 330 | }; 331 | } 332 | 333 | case regexValidSpecial.test(normalizeCard): { 334 | const color = normalizeCard.match( 335 | regexValidSpecial, 336 | )![1]! as IGetCardState["color"]; 337 | const type = normalizeCard.match( 338 | regexValidSpecial, 339 | )![2]! as IGetCardState["type"]; 340 | 341 | return { 342 | state: EGetCardState.VALID_SPECIAL, 343 | color, 344 | type, 345 | }; 346 | } 347 | 348 | default: { 349 | return { state: EGetCardState.INVALID }; 350 | } 351 | } 352 | } 353 | } 354 | 355 | /** 356 | * Enum that used for "getSwitchState" function 357 | */ 358 | export const switchState = { 359 | FIRSTCARD_IS_COLOR_OR_NUMBER_IS_SAME: "FIRSTCARD_IS_COLOR_OR_NUMBER_IS_SAME", 360 | FIRSTCARD_IS_WILD_OR_WILD4_IS_SAME_SECOND_COLOR: 361 | "FIRSTCARD_IS_WILD_OR_WILD4_IS_SAME_SECOND_COLOR", 362 | SECONDCARD_IS_VALIDSPECIAL_AND_SAME_COLOR_AS_FIRSTCARD: 363 | "SECONDCARD_IS_VALIDSPECIAL_AND_SAME_COLOR_AS_FIRSTCARD", 364 | FIRSTCARD_IS_NTYPE_AND_SECONDCARD_IS_NTYPE_TOO: 365 | "FIRSTCARD_IS_NTYPE_AND_SECONDCARD_IS_NTYPE_TOO", 366 | SECONDCARD_IS_WILD: "SECONDCARD_IS_WILD", 367 | SECONDCARD_IS_WILD4: "SECONDCARD_IS_WILD4", 368 | } as const; 369 | 370 | /** 371 | * A function that used to defining the state for switch case comparer 372 | * @param firstState Card state from the deck 373 | * @param secState Card state from the user given card 374 | * @returns An enum that indicate a certain valid condition 375 | */ 376 | export const getSwitchState = ( 377 | firstState: IGetCardState, 378 | secState: IGetCardState, 379 | ) => { 380 | if (secState.state === EGetCardState.VALID_WILD) 381 | return switchState.SECONDCARD_IS_WILD; 382 | else if (secState.state === EGetCardState.VALID_WILD_PLUS4) 383 | return switchState.SECONDCARD_IS_WILD4; 384 | else if ( 385 | (firstState?.color === secState?.color || 386 | firstState?.number === secState?.number) && 387 | secState.state !== EGetCardState.VALID_SPECIAL 388 | ) 389 | return switchState.FIRSTCARD_IS_COLOR_OR_NUMBER_IS_SAME; 390 | else if ( 391 | (firstState.state === EGetCardState.VALID_WILD || 392 | firstState.state === EGetCardState.VALID_WILD_PLUS4) && 393 | firstState.color === secState.color && 394 | !secState.type 395 | ) 396 | return switchState.FIRSTCARD_IS_WILD_OR_WILD4_IS_SAME_SECOND_COLOR; 397 | else if ( 398 | secState.state === EGetCardState.VALID_SPECIAL && 399 | secState.color === firstState.color 400 | ) 401 | return switchState.SECONDCARD_IS_VALIDSPECIAL_AND_SAME_COLOR_AS_FIRSTCARD; 402 | else if ( 403 | firstState.state === EGetCardState.VALID_SPECIAL && 404 | secState.state === EGetCardState.VALID_SPECIAL && 405 | firstState.type === secState.type 406 | ) 407 | return switchState.FIRSTCARD_IS_NTYPE_AND_SECONDCARD_IS_NTYPE_TOO; 408 | }; 409 | 410 | /** 411 | * Card comparer for the main logic of the bot 412 | * @param firstCard The card from the deck 413 | * @param secCard The user given card 414 | * @returns String that indicate is valid or not 415 | */ 416 | export const compareTwoCard = (firstCard: allCard, secCard: allCard) => { 417 | const firstState = CardPicker.getCardState(firstCard); 418 | const secState = CardPicker.getCardState(secCard); 419 | 420 | const switchStateCard = getSwitchState(firstState, secState); 421 | 422 | switch (switchStateCard) { 423 | /* eslint-disable no-fallthrough */ 424 | 425 | // Valid wilddraw4 from player 426 | case switchState.SECONDCARD_IS_WILD4: 427 | return "STACK_PLUS_4"; 428 | 429 | // Valid wild color only from player 430 | case switchState.SECONDCARD_IS_WILD: 431 | return "STACK_WILD"; 432 | 433 | case switchState.FIRSTCARD_IS_COLOR_OR_NUMBER_IS_SAME: 434 | case switchState.FIRSTCARD_IS_WILD_OR_WILD4_IS_SAME_SECOND_COLOR: 435 | return "STACK"; 436 | 437 | case switchState.FIRSTCARD_IS_NTYPE_AND_SECONDCARD_IS_NTYPE_TOO: 438 | case switchState.SECONDCARD_IS_VALIDSPECIAL_AND_SAME_COLOR_AS_FIRSTCARD: 439 | return `VALID_SPECIAL_${secState.type!.toUpperCase()}`; 440 | 441 | default: 442 | return "UNMATCH"; 443 | } 444 | }; 445 | -------------------------------------------------------------------------------- /test/core/cardComparer.test.ts: -------------------------------------------------------------------------------- 1 | import { compareTwoCard } from "../../src/config/cards"; 2 | import type { allCard } from "../../src/config/cards"; 3 | 4 | const allColor = [ 5 | { color: "red" }, 6 | { color: "green" }, 7 | { color: "blue" }, 8 | { color: "yellow" }, 9 | ]; 10 | const allSpecialCard = [ 11 | { type: "reverse" }, 12 | { type: "skip" }, 13 | { type: "draw2" }, 14 | ]; 15 | const allValidNumbers = Array.from({ length: 10 }).map((_, number) => ({ 16 | number, 17 | })); 18 | 19 | const allNormalColor = allValidNumbers.flatMap(({ number }) => 20 | allColor.flatMap(({ color }) => ({ 21 | card: `${color}${number}` as allCard, 22 | })), 23 | ); 24 | 25 | describe("Card comparer unit test [STACK | STACK WILD | STACK SPECIAL]", () => { 26 | describe("Stack card because the cards have same color", () => { 27 | test.each( 28 | // Create an array of deckCard 29 | // and givenCard with same color 30 | allColor.flatMap((colorItem) => 31 | allValidNumbers.flatMap((numberItem) => { 32 | const deckCard = `${colorItem.color}${numberItem.number}` as allCard; 33 | const givenCard = allValidNumbers.map( 34 | (secondNumberItem) => 35 | `${colorItem.color}${secondNumberItem.number}` as allCard, 36 | ); 37 | 38 | return givenCard.map((gc) => ({ deckCard, givenCard: gc })); 39 | }), 40 | ), 41 | )( 42 | "Should be succesfully stack $deckCard and $givenCard", 43 | ({ deckCard, givenCard }) => { 44 | const status = compareTwoCard(deckCard, givenCard); 45 | 46 | expect(status).toBe("STACK"); 47 | }, 48 | ); 49 | }); 50 | 51 | describe("Stack card because the cards have same number", () => { 52 | // Stack all card with the same number 53 | test.each( 54 | // Create an array of deckCard and givenCard 55 | // with different color but with the same number 56 | allValidNumbers.flatMap(({ number }) => 57 | allColor.flatMap(({ color }) => { 58 | const deckCard = `${color}${number}` as allCard; 59 | 60 | return allColor 61 | .filter((currentColor) => currentColor.color !== color) 62 | .map((currentColor) => ({ 63 | deckCard, 64 | givenCard: `${currentColor.color}${number}` as allCard, 65 | })); 66 | }), 67 | ), 68 | )( 69 | "Should be succesfully stack $deckCard and $givenCard", 70 | ({ deckCard, givenCard }) => { 71 | const status = compareTwoCard(deckCard, givenCard); 72 | 73 | expect(status).toBe("STACK"); 74 | }, 75 | ); 76 | }); 77 | 78 | // Stack wild plus 4 to the deck 79 | describe.each( 80 | // Create wilddraw4 with all color 81 | allColor.map(({ color }) => ({ 82 | wildPlus4: `wilddraw4${color}` as allCard, 83 | })), 84 | )("Test if plus 4 card could stack on normal color", ({ wildPlus4 }) => { 85 | test.each(allNormalColor)( 86 | "Plus 4 card could stack on $card", 87 | ({ card }) => { 88 | const status = compareTwoCard(card, wildPlus4); 89 | 90 | expect(status).toBe("STACK_PLUS_4"); 91 | }, 92 | ); 93 | }); 94 | 95 | // The plus 4 is already on the deck, player will stack their card 96 | describe.each( 97 | // Create wilddraw4 card with all color 98 | allColor.map(({ color }) => ({ 99 | wildDraw4: `wilddraw4${color}` as allCard, 100 | color, 101 | })), 102 | )( 103 | "Test if normal coloured card can stack normally on specific wilddraw4 on the deck", 104 | ({ wildDraw4, color }) => { 105 | test.each(allNormalColor.filter(({ card }) => card.includes(color)))( 106 | `$card could be stacked on ${wildDraw4}`, 107 | ({ card }) => { 108 | const status = compareTwoCard(wildDraw4, card); 109 | 110 | expect(status).toBe("STACK"); 111 | }, 112 | ); 113 | }, 114 | ); 115 | 116 | // Stack wild color to the deck, it'll bypass all normal coloured card 117 | describe.each( 118 | // Create wild card with all color 119 | allColor.map(({ color }) => ({ wild: `wild${color}` as allCard })), 120 | )("Test if wild card can bypass all normal coloured card", ({ wild }) => { 121 | test.each(allNormalColor)( 122 | `${wild} card could stack on $card`, 123 | ({ card }) => { 124 | const status = compareTwoCard(card, wild); 125 | 126 | expect(status).toBe("STACK_WILD"); 127 | }, 128 | ); 129 | }); 130 | 131 | // Specific coloured wild card already on the deck, 132 | // player want to stack the card with normal card 133 | describe.each( 134 | // Create wild card with all color 135 | allColor.map(({ color }) => ({ wild: `wild${color}` as allCard, color })), 136 | )( 137 | "Test if normal coloured card can stack normally on specific wild on the deck", 138 | ({ wild, color }) => { 139 | test.each(allNormalColor.filter(({ card }) => card.includes(color)))( 140 | `$card could be stacked on ${wild}`, 141 | ({ card }) => { 142 | const status = compareTwoCard(wild, card); 143 | 144 | expect(status).toBe("STACK"); 145 | }, 146 | ); 147 | }, 148 | ); 149 | 150 | // Stack skip card to the deck 151 | describe.each( 152 | // Create all skip card with specific color 153 | allColor.map(({ color }) => ({ skipCard: `${color}skip`, color })), 154 | )("Test all skip card with same card color", ({ skipCard, color }) => { 155 | test.each(allNormalColor.filter(({ card }) => card.includes(color)))( 156 | `${skipCard} could be stacked on $card`, 157 | ({ card }) => { 158 | const status = compareTwoCard(card as allCard, skipCard as allCard); 159 | 160 | expect(status).toBe("VALID_SPECIAL_SKIP"); 161 | }, 162 | ); 163 | }); 164 | 165 | // Stack reverse card to the deck 166 | describe.each( 167 | // Create all reverse card with specific color 168 | allColor.map(({ color }) => ({ reverseCard: `${color}reverse`, color })), 169 | )("Test all reverse card with same card color", ({ reverseCard, color }) => { 170 | test.each(allNormalColor.filter(({ card }) => card.includes(color)))( 171 | `${reverseCard} could be stacked on $card`, 172 | ({ card }) => { 173 | const status = compareTwoCard(card as allCard, reverseCard as allCard); 174 | 175 | expect(status).toBe("VALID_SPECIAL_REVERSE"); 176 | }, 177 | ); 178 | }); 179 | 180 | // Stack plus two card to the deck 181 | describe.each( 182 | // Create all draw2 card with specific color 183 | allColor.map(({ color }) => ({ draw2: `${color}draw2`, color })), 184 | )("Test all draw2 card with same card color", ({ draw2, color }) => { 185 | test.each(allNormalColor.filter(({ card }) => card.includes(color)))( 186 | `${draw2} could be stacked on $card`, 187 | ({ card }) => { 188 | const status = compareTwoCard(card as allCard, draw2 as allCard); 189 | 190 | expect(status).toBe("VALID_SPECIAL_DRAW2"); 191 | }, 192 | ); 193 | }); 194 | 195 | describe("Stack same special card type but with different color", () => { 196 | // Stack same special card type to the deck 197 | test.each( 198 | allColor.flatMap(({ color }) => 199 | allColor 200 | .filter((type) => type.color !== color) 201 | .flatMap((opposite) => 202 | allSpecialCard.map((special) => { 203 | const deckCard = `${opposite.color}${special.type}` as allCard; 204 | const givenCard = `${color}${special.type}` as allCard; 205 | 206 | return { 207 | deckCard, 208 | givenCard, 209 | expected: 210 | special.type === "reverse" 211 | ? "VALID_SPECIAL_REVERSE" 212 | : special.type === "skip" 213 | ? "VALID_SPECIAL_SKIP" 214 | : special.type === "draw2" 215 | ? "VALID_SPECIAL_DRAW2" 216 | : null, 217 | }; 218 | }), 219 | ), 220 | ), 221 | )( 222 | "$deckCard and $givenCard can be stacked ($expected)", 223 | ({ deckCard, givenCard, expected }) => { 224 | const state = compareTwoCard(deckCard, givenCard); 225 | 226 | expect(state).toBe(expected); 227 | }, 228 | ); 229 | }); 230 | 231 | describe("Stack special card to plus 4 card and still doing the special card thing", () => { 232 | test.each( 233 | allColor.flatMap(({ color }) => 234 | allSpecialCard.map((special) => { 235 | const deckCard = `wilddraw4${color}` as allCard; 236 | const givenCard = `${color}${special.type}` as allCard; 237 | 238 | return { 239 | deckCard, 240 | givenCard, 241 | expected: 242 | special.type === "reverse" 243 | ? "VALID_SPECIAL_REVERSE" 244 | : special.type === "skip" 245 | ? "VALID_SPECIAL_SKIP" 246 | : special.type === "draw2" 247 | ? "VALID_SPECIAL_DRAW2" 248 | : null, 249 | }; 250 | }), 251 | ), 252 | )( 253 | "Can stack $givenCard to $deckCard ($expected)", 254 | ({ deckCard, givenCard, expected }) => { 255 | const state = compareTwoCard(deckCard, givenCard); 256 | 257 | expect(state).toBe(expected); 258 | }, 259 | ); 260 | }); 261 | 262 | describe("Stack wild card to plus 4 card and still valid wild", () => { 263 | test.each( 264 | allColor.flatMap(({ color }) => 265 | allColor.map((given) => ({ 266 | deckCard: `wilddraw4${color}` as allCard, 267 | givenCard: `wild${given.color}` as allCard, 268 | })), 269 | ), 270 | )("Can stack $givenCard to $deckCard", ({ deckCard, givenCard }) => { 271 | const state = compareTwoCard(deckCard, givenCard); 272 | 273 | expect(state).toBe("STACK_WILD"); 274 | }); 275 | }); 276 | }); 277 | 278 | describe("Card comparer unit test [UNMATCH]", () => { 279 | const fnTest = ({ 280 | deckCard, 281 | givenCard, 282 | }: { 283 | deckCard: allCard; 284 | givenCard: allCard; 285 | }) => { 286 | const status = compareTwoCard(deckCard, givenCard); 287 | 288 | expect(status).toBe("UNMATCH"); 289 | }; 290 | 291 | describe("Normal card compared to normal card but all card are unmatch", () => { 292 | test.each( 293 | allColor.flatMap((type) => 294 | allColor 295 | .filter(({ color }) => color !== type.color) 296 | .flatMap((opposite) => 297 | allValidNumbers.flatMap((validNumberDeck) => 298 | allValidNumbers 299 | .filter((given) => given.number !== validNumberDeck.number) 300 | .flatMap((validNumberGiven) => ({ 301 | deckCard: `${type.color}${validNumberDeck.number}` as allCard, 302 | givenCard: 303 | `${opposite.color}${validNumberGiven.number}` as allCard, 304 | })), 305 | ), 306 | ), 307 | ), 308 | )("$deckCard can't be stacked by $givenCard", fnTest); 309 | }); 310 | 311 | describe("Stack plus 4 compared to normal card but all card are unmatch", () => { 312 | test.each( 313 | allColor.flatMap((type) => 314 | allColor 315 | .filter(({ color }) => color !== type.color) 316 | .flatMap((opposite) => { 317 | const deckCard = `wildddraw4${type.color}` as allCard; 318 | 319 | return allValidNumbers.map(({ number }) => ({ 320 | deckCard, 321 | givenCard: `${opposite.color}${number}` as allCard, 322 | })); 323 | }), 324 | ), 325 | )("$deckCard can't be stacked by $givenCard", fnTest); 326 | }); 327 | 328 | describe("Stack wild compared to normal card but all card are unmatch", () => { 329 | test.each( 330 | allColor.flatMap((type) => 331 | allColor 332 | .filter(({ color }) => color !== type.color) 333 | .flatMap((opposite) => { 334 | const deckCard = `wild${opposite.color}` as allCard; 335 | 336 | return allValidNumbers.map(({ number }) => ({ 337 | deckCard, 338 | givenCard: `${type.color}${number}` as allCard, 339 | })); 340 | }), 341 | ), 342 | )("Can't stack $givenCard to $deckCard", fnTest); 343 | }); 344 | 345 | describe("Can't stack different special card", () => { 346 | it.each( 347 | allSpecialCard 348 | .flatMap((special) => 349 | allSpecialCard 350 | .filter(({ type }) => type !== special.type) 351 | .flatMap((oppositeType) => 352 | allColor.flatMap((color) => 353 | allColor 354 | .filter((type) => type.color !== color.color) 355 | .flatMap((oppositeColor) => { 356 | const deckCard = 357 | `${oppositeColor.color}${oppositeType.type}` as allCard; 358 | const givenCard = 359 | `${color.color}${special.type}` as allCard; 360 | 361 | return { 362 | deckCard, 363 | givenCard, 364 | }; 365 | }), 366 | ), 367 | ), 368 | ) 369 | .flat(4), 370 | )( 371 | "Can't stack special $deckCard compared to special card $givenCard", 372 | fnTest, 373 | ); 374 | }); 375 | 376 | describe("Can't stack special card to plus 4 card if it isn't the same color", () => { 377 | test.each( 378 | allColor.flatMap(({ color }) => { 379 | const deckCard = `wilddraw4${color}` as allCard; 380 | 381 | return allColor 382 | .filter((given) => given.color !== color) 383 | .flatMap((given) => 384 | allSpecialCard.flatMap(({ type }) => ({ 385 | deckCard, 386 | givenCard: `${given.color}${type}` as allCard, 387 | })), 388 | ); 389 | }), 390 | )( 391 | "Can't stack plus 4 $deckCard compared to special card $givenCard", 392 | fnTest, 393 | ); 394 | }); 395 | }); 396 | -------------------------------------------------------------------------------- /src/controller/leavegame.ts: -------------------------------------------------------------------------------- 1 | import { requiredJoinGameSession, createAllCardImage } from "../utils"; 2 | 3 | import { prisma } from "../handler/database"; 4 | 5 | import type { allCard } from "../config/cards"; 6 | 7 | export default requiredJoinGameSession(async ({ chat, game }) => { 8 | switch (true) { 9 | case game.state.PLAYING: { 10 | const creator = await game.getCreatorUser(); 11 | 12 | const copiedPlayers = [...game.players]; 13 | const afterPlayerWantToLeave = copiedPlayers.filter( 14 | (player) => player.playerId !== chat.user!.id, 15 | ); 16 | 17 | if (afterPlayerWantToLeave.length < 2) { 18 | await game.endGame(); 19 | 20 | const sendToOpposite = afterPlayerWantToLeave[0]; 21 | const oppositePlayer = await prisma.user.findUnique({ 22 | where: { 23 | id: sendToOpposite.playerId, 24 | }, 25 | }); 26 | 27 | await Promise.all([ 28 | chat.replyToCurrentPerson( 29 | "Anda berhasil keluar dari permainan, tetapi karena pemain kurang dari dua orang maka game otomatis dihentikan. Terimakasih sudah bermain!", 30 | ), 31 | chat.sendToOtherPerson( 32 | oppositePlayer!.phoneNumber, 33 | `Pemain ${chat.message.userName} sudah keluar dari permainan, tetapi karena pemain kurang dari dua orang maka game otomatis dihentikan. Terimakasih sudah bermain!`, 34 | ), 35 | ]); 36 | 37 | return; 38 | } 39 | 40 | // Typeguard 41 | if (creator) { 42 | // Current chatter is the creator and 43 | // want to leave, but it isn't their turn 44 | if (creator.id === chat.user!.id && !game.currentPlayerIsAuthor) { 45 | // Because the creator always at the index 0 46 | // So pick the next game creator from copiedPlayers with index 1 47 | const nextAuthorId = copiedPlayers[1]; 48 | const nextAuthor = await prisma.user.findUnique({ 49 | where: { id: nextAuthorId.playerId }, 50 | }); 51 | 52 | const playerList = afterPlayerWantToLeave.filter( 53 | (player) => player.playerId !== nextAuthorId.playerId, 54 | ); 55 | 56 | await game.removeUserFromArray(chat.user!.id); 57 | await game.setCreatorId(nextAuthor!.id); 58 | 59 | await Promise.all([ 60 | chat.replyToCurrentPerson( 61 | `Anda berhasil keluar dari permainan, sekarang host jatuh ke tangan ${ 62 | nextAuthor!.username 63 | } dan permainan masih tetap berjalan. Terima kasih sudah bermain!`, 64 | ), 65 | chat.sendToOtherPerson( 66 | nextAuthor!.phoneNumber, 67 | `${chat.message.userName} sudah keluar dari permainan, saat ini anda adalah hostnya. Lanjutkan permainannya!`, 68 | ), 69 | game.sendToSpecificPlayerList( 70 | `${ 71 | chat.message.userName 72 | } sudah keluar dari permainan, sekarang host jatuh ke tangan ${ 73 | nextAuthor!.username 74 | }. Lanjutkan permainannya!`, 75 | playerList, 76 | ), 77 | ]); 78 | 79 | // Current chatter is the creator and 80 | // want to leave, but it's their turn 81 | } else if (creator.id === chat.user!.id && game.currentPlayerIsAuthor) { 82 | const nextAuthorId = copiedPlayers[1]; 83 | const nextPlayerTurnId = game.getNextPosition(); 84 | 85 | const playerList = afterPlayerWantToLeave 86 | .filter((player) => player.playerId !== nextAuthorId.playerId) 87 | .filter((player) => player.playerId !== nextPlayerTurnId!.playerId); 88 | 89 | const [nextAuthor, nextPlayer, nextPlayerCards] = await Promise.all([ 90 | prisma.user.findUnique({ 91 | where: { id: nextAuthorId.playerId }, 92 | }), 93 | prisma.user.findUnique({ 94 | where: { id: nextPlayerTurnId!.playerId }, 95 | }), 96 | prisma.userCard.findUnique({ 97 | where: { 98 | playerId: nextPlayerTurnId!.playerId, 99 | }, 100 | include: { 101 | cards: true, 102 | }, 103 | }), 104 | ]); 105 | 106 | const [currentCardImage, frontCardsImage, backCardsImage] = 107 | await createAllCardImage( 108 | game.currentCard as allCard, 109 | nextPlayerCards?.cards.map((card) => card.cardName) as allCard[], 110 | ); 111 | 112 | await game.setCreatorId(nextAuthor!.id); 113 | await game.updatePosition(nextPlayer!.id); 114 | await game.removeUserFromArray(creator.id); 115 | 116 | await Promise.all([ 117 | chat.replyToCurrentPerson( 118 | `Anda berhasil keluar dari permainan, sekarang host jatuh ke tangan ${ 119 | nextAuthor!.username 120 | } dan giliran main ke ${ 121 | nextPlayer!.username 122 | }. Terima kasih sudah bermain!`, 123 | ), 124 | (async () => { 125 | // If the next author is also the next player 126 | if (nextAuthor!.id === nextPlayer!.id) { 127 | await chat.sendToOtherPerson( 128 | nextPlayer!.phoneNumber, 129 | `${chat.message.userName} sudah keluar dari permainan. Sekarang posisi bermain dan host jatuh ke tangan anda.`, 130 | ); 131 | await chat.sendToOtherPerson( 132 | nextPlayer!.phoneNumber, 133 | { caption: `Kartu saat ini: ${game.currentCard}` }, 134 | currentCardImage, 135 | ); 136 | await chat.sendToOtherPerson( 137 | nextPlayer!.phoneNumber, 138 | { 139 | caption: `Kartu kamu: ${nextPlayerCards?.cards 140 | .map((card) => card.cardName) 141 | .join(", ")}.`, 142 | }, 143 | frontCardsImage, 144 | ); 145 | } else { 146 | await Promise.all([ 147 | // Send message to next author 148 | (async () => { 149 | await chat.sendToOtherPerson( 150 | nextAuthor!.phoneNumber, 151 | `${ 152 | chat.message.userName 153 | } sudah keluar dari permainan. Saat ini host permainan ada di tangan anda. Waktunya giliran ${ 154 | nextPlayer!.username 155 | } untuk bermain`, 156 | ); 157 | await chat.sendToOtherPerson( 158 | nextAuthor!.phoneNumber, 159 | { caption: `Kartu saat ini: ${game.currentCard}` }, 160 | currentCardImage, 161 | ); 162 | await chat.sendToOtherPerson( 163 | nextAuthor!.phoneNumber, 164 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 165 | backCardsImage, 166 | ); 167 | })(), 168 | 169 | // Send message to next player 170 | (async () => { 171 | await chat.sendToOtherPerson( 172 | nextPlayer!.phoneNumber, 173 | `${ 174 | chat.message.userName 175 | } sudah keluar dari permainan. Sekarang posisi bermain dan host jatuh ke tangan ${ 176 | nextAuthor!.username 177 | }. Sekarang adalah giliranmu untuk bermain`, 178 | ); 179 | await chat.sendToOtherPerson( 180 | nextPlayer!.phoneNumber, 181 | { caption: `Kartu saat ini: ${game.currentCard}` }, 182 | currentCardImage, 183 | ); 184 | await chat.sendToOtherPerson( 185 | nextPlayer!.phoneNumber, 186 | { 187 | caption: `Kartu kamu: ${nextPlayerCards?.cards 188 | .map((card) => card.cardName) 189 | .join(", ")}.`, 190 | }, 191 | frontCardsImage, 192 | ); 193 | })(), 194 | ]); 195 | } 196 | })(), 197 | 198 | // Rest of the players 199 | (async () => { 200 | const initialText = 201 | nextAuthor!.id === nextPlayer!.id 202 | ? `${ 203 | chat.message.userName 204 | } sudah meninggalkan permainan. Giliran host dan bermain jatuh ke tangan *${ 205 | nextPlayer!.username 206 | }*.` 207 | : `${ 208 | chat.message.userName 209 | } sudah meninggalkan permainan. Host permainan jatuh ke tangan *${ 210 | nextAuthor!.username 211 | }*. Giliran *${nextPlayer!.username}* untuk bermain.`; 212 | 213 | await game.sendToSpecificPlayerList(initialText, playerList); 214 | await game.sendToSpecificPlayerList( 215 | { caption: `Kartu saat ini: ${game.currentCard}` }, 216 | playerList, 217 | currentCardImage, 218 | ); 219 | await game.sendToSpecificPlayerList( 220 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 221 | playerList, 222 | backCardsImage, 223 | ); 224 | })(), 225 | ]); 226 | 227 | // Current player isn't the author 228 | } else if (!game.isGameCreator) { 229 | // It is current chat turn 230 | if (game.isCurrentChatTurn) { 231 | const nextPlayerTurnId = game.getNextPosition(); 232 | 233 | const playerList = afterPlayerWantToLeave.filter( 234 | (player) => player.playerId !== nextPlayerTurnId!.playerId, 235 | ); 236 | 237 | const [nextPlayer, nextPlayerCards] = await Promise.all([ 238 | prisma.user.findUnique({ 239 | where: { id: nextPlayerTurnId!.playerId }, 240 | }), 241 | prisma.userCard.findUnique({ 242 | where: { 243 | playerId: nextPlayerTurnId!.playerId, 244 | }, 245 | include: { 246 | cards: true, 247 | }, 248 | }), 249 | ]); 250 | 251 | const [currentCardImage, frontCardsImage, backCardsImage] = 252 | await createAllCardImage( 253 | game.currentCard as allCard, 254 | nextPlayerCards?.cards.map( 255 | (card) => card.cardName, 256 | ) as allCard[], 257 | ); 258 | 259 | await Promise.all([ 260 | chat.replyToCurrentPerson( 261 | `Anda berhasil keluar dari permainan. Sekarang giliran ${ 262 | nextPlayer!.username 263 | } untuk bermain. Terimakasih sudah bermain!`, 264 | ), 265 | 266 | // Send message to next player 267 | (async () => { 268 | await chat.sendToOtherPerson( 269 | nextPlayer!.phoneNumber, 270 | `${chat.message.userName} telah keluar dari game, selanjutnya adalah giliran kamu untuk bermain`, 271 | ); 272 | await chat.sendToOtherPerson( 273 | nextPlayer!.phoneNumber, 274 | { caption: `Kartu saat ini: ${game.currentCard}` }, 275 | currentCardImage, 276 | ); 277 | await chat.sendToOtherPerson( 278 | nextPlayer!.phoneNumber, 279 | { 280 | caption: `Kartu kamu: ${nextPlayerCards?.cards 281 | .map((card) => card.cardName) 282 | .join(", ")}.`, 283 | }, 284 | frontCardsImage, 285 | ); 286 | })(), 287 | 288 | // Send to the rest player 289 | (async () => { 290 | await game.sendToSpecificPlayerList( 291 | `${ 292 | chat.message.userName 293 | } sudah keluar dari permainan. Sekarang giliran ${ 294 | nextPlayer!.username 295 | } untuk bermain`, 296 | playerList, 297 | ); 298 | await game.sendToSpecificPlayerList( 299 | { caption: `Kartu saat ini: ${game.currentCard}` }, 300 | playerList, 301 | currentCardImage, 302 | ); 303 | await game.sendToSpecificPlayerList( 304 | { caption: `Kartu yang ${nextPlayer!.username} miliki` }, 305 | playerList, 306 | backCardsImage, 307 | ); 308 | })(), 309 | ]); 310 | 311 | return; 312 | } 313 | 314 | // It isn't current chat turn 315 | await game.removeUserFromArray(chat.user!.id); 316 | 317 | await Promise.all([ 318 | chat.replyToCurrentPerson( 319 | "Anda berhasil keluar dari game. Terimakasih telah bermain!", 320 | ), 321 | game.sendToSpecificPlayerList( 322 | `${chat.message.userName} telah keluar dari game`, 323 | afterPlayerWantToLeave, 324 | ), 325 | ]); 326 | } 327 | } 328 | 329 | break; 330 | } 331 | 332 | // The default action will waiting or playing, not ended 333 | case game.state.WAITING: 334 | default: { 335 | const creator = await game.getCreatorUser(); 336 | const copiedPlayers = [...game.players]; 337 | 338 | // Typeguard 339 | if (creator) { 340 | // All player basically gone 341 | if (copiedPlayers.length < 2) { 342 | await game.endGame(); 343 | 344 | await chat.replyToCurrentPerson( 345 | "Anda berhasil keluar dari game, tetapi karena hanya anda saja yang berada otomatis game dihentikan. Terimakasih sudah bermain!", 346 | ); 347 | } else if (copiedPlayers.length > 1) { 348 | // Current chatter is the creator that want to leave 349 | if (creator.id === chat.user!.id) { 350 | // Because the creator always at the index 0 351 | // So pick the next game creator from copiedPlayers with index 1 352 | const nextPlayerId = copiedPlayers[1]; 353 | const nextPlayer = await prisma.user.findUnique({ 354 | where: { id: nextPlayerId.playerId }, 355 | }); 356 | 357 | const playersList = copiedPlayers 358 | .filter((player) => player.playerId !== creator.id) 359 | .filter((player) => player.playerId === nextPlayerId.playerId); 360 | 361 | await game.setCreatorId(nextPlayerId.playerId); 362 | await game.removeUserFromArray(creator.id); 363 | 364 | await Promise.all([ 365 | chat.replyToCurrentPerson( 366 | `Anda berhasil keluar dari permainan! Posisi host jatuh ke tangan ${nextPlayer?.username}. Terimakasih sudah bergabung!`, 367 | ), 368 | chat.sendToOtherPerson( 369 | nextPlayer!.phoneNumber, 370 | `${creator.username} telah keluar dari permainan dan kamu adalah host untuk permainan saat ini.`, 371 | ), 372 | game.sendToSpecificPlayerList( 373 | `${creator.username} telah keluar dari permainan dan host berpindah tangan ke ${nextPlayer?.username} untuk permainan saat ini.`, 374 | playersList, 375 | ), 376 | ]); 377 | 378 | return; 379 | } 380 | 381 | // Current chatter isn't the creator and want to leave 382 | const playersList = copiedPlayers.filter( 383 | (player) => player.playerId !== chat.user!.id, 384 | ); 385 | 386 | await game.removeUserFromArray(chat.user!.id); 387 | 388 | await Promise.all([ 389 | chat.replyToCurrentPerson( 390 | "Anda berhasil keluar dari permainan. Terimakasih telah bermain!", 391 | ), 392 | game.sendToSpecificPlayerList( 393 | `${chat.message.userName} telah keluar dari permainan`, 394 | playersList, 395 | ), 396 | ]); 397 | } 398 | } 399 | 400 | break; 401 | } 402 | } 403 | }); 404 | -------------------------------------------------------------------------------- /src/lib/Game.ts: -------------------------------------------------------------------------------- 1 | import { Chat } from "./Chat"; 2 | 3 | import { calcElapsedTime, random } from "../utils"; 4 | 5 | import { prisma, FullGameType, User, Player } from "../handler/database"; 6 | 7 | import type { allCard } from "../config/cards"; 8 | import { CardPicker } from "../config/cards"; 9 | import { 10 | MessageContent, 11 | MessageMedia, 12 | MessageSendOptions, 13 | } from "whatsapp-web.js"; 14 | 15 | /** 16 | * Class for handling user game event 17 | */ 18 | export class Game { 19 | /** 20 | * Game document by specific user 21 | */ 22 | private game: FullGameType; 23 | 24 | /** 25 | * Chat message instance 26 | */ 27 | private chat: Chat; 28 | 29 | /** 30 | * Game class constructor 31 | * @param gameData Game document by specific user 32 | * @param chat Chat message instance 33 | */ 34 | constructor(gameData: FullGameType, chat: Chat) { 35 | this.game = gameData; 36 | this.chat = chat; 37 | } 38 | 39 | /** 40 | * Abstract function that used for checking specific player is it turn or not 41 | * @param user The specific player 42 | * @returns Boolean that indicate is player turn or not 43 | */ 44 | private _isPlayerTurn(user: User) { 45 | return this.game.currentPlayerId === user.id; 46 | } 47 | 48 | /** 49 | * Function for starting current game 50 | */ 51 | async startGame() { 52 | const shuffledPlayers = this.players 53 | .sort(() => random() - 0.5) 54 | .map((player) => player.playerId); 55 | const currentPlayer = shuffledPlayers[0]; 56 | const startCard = CardPicker.getInitialCard(); 57 | 58 | await prisma.$transaction([ 59 | prisma.game.update({ 60 | where: { 61 | id: this.game.id, 62 | }, 63 | data: { 64 | status: "PLAYING", 65 | started_at: new Date(), 66 | currentCard: startCard, 67 | currentPlayerId: currentPlayer, 68 | }, 69 | }), 70 | 71 | ...shuffledPlayers.map((playerId, idx) => 72 | prisma.playerOrder.create({ 73 | data: { 74 | gameId: this.game.id, 75 | playerId, 76 | playerOrder: idx + 1, 77 | }, 78 | }), 79 | ), 80 | ]); 81 | 82 | const userCards = await prisma.$transaction( 83 | shuffledPlayers.map((playerId) => 84 | prisma.userCard.create({ 85 | data: { 86 | gameId: this.game.id, 87 | playerId, 88 | }, 89 | }), 90 | ), 91 | ); 92 | 93 | await prisma.$transaction( 94 | userCards 95 | .map((user) => Array.from({ length: 6 }).map(() => user)) 96 | .flat() 97 | .map((userCard) => 98 | prisma.card.create({ 99 | data: { 100 | cardName: CardPicker.pickCardByGivenCard(startCard), 101 | cardId: userCard.id, 102 | }, 103 | }), 104 | ), 105 | ); 106 | 107 | const updatedGameState = await prisma.game.findUnique({ 108 | where: { 109 | id: this.game.id, 110 | }, 111 | include: { 112 | allPlayers: true, 113 | bannedPlayers: true, 114 | cards: true, 115 | playerOrders: true, 116 | }, 117 | }); 118 | 119 | this.game = updatedGameState!; 120 | } 121 | 122 | /** 123 | * Function for joining current game 124 | */ 125 | async joinGame() { 126 | const id = this.chat.user!.id; 127 | 128 | const [, updatedGameState] = await prisma.$transaction([ 129 | prisma.userGameProperty.update({ 130 | where: { 131 | userId: id, 132 | }, 133 | data: { 134 | isJoiningGame: true, 135 | gameID: this.game.gameID, 136 | }, 137 | }), 138 | prisma.game.update({ 139 | where: { 140 | id: this.game.id, 141 | }, 142 | data: { 143 | allPlayers: { 144 | create: { 145 | playerId: id, 146 | }, 147 | }, 148 | }, 149 | include: { 150 | allPlayers: true, 151 | bannedPlayers: true, 152 | cards: true, 153 | playerOrders: true, 154 | }, 155 | }), 156 | ]); 157 | 158 | this.game = updatedGameState; 159 | } 160 | 161 | /** 162 | * Function for end current game 163 | */ 164 | async endGame() { 165 | const [, updatedGameState] = await prisma.$transaction([ 166 | prisma.userCard.deleteMany({ 167 | where: { 168 | gameId: this.game.id, 169 | }, 170 | }), 171 | prisma.game.update({ 172 | where: { 173 | id: this.game.id, 174 | }, 175 | data: { 176 | ended_at: new Date(), 177 | status: "ENDED", 178 | playerOrders: { 179 | deleteMany: {}, 180 | }, 181 | allPlayers: { 182 | deleteMany: {}, 183 | }, 184 | bannedPlayers: { 185 | deleteMany: {}, 186 | }, 187 | }, 188 | include: { 189 | allPlayers: true, 190 | bannedPlayers: true, 191 | cards: true, 192 | playerOrders: true, 193 | }, 194 | }), 195 | ...this.players.map((player) => 196 | prisma.userGameProperty.update({ 197 | where: { 198 | userId: player.playerId, 199 | }, 200 | data: { 201 | isJoiningGame: false, 202 | gameID: null, 203 | }, 204 | }), 205 | ), 206 | ]); 207 | 208 | this.game = updatedGameState; 209 | 210 | this.chat.logger.info(`[DB] Game ${this.game.gameID} selesai`); 211 | } 212 | 213 | /** 214 | * Function for updating user gameProperty for not joining game session anymore 215 | * @param id Specific user id 216 | */ 217 | async leaveGameForUser(id: number) { 218 | await prisma.userGameProperty.update({ 219 | where: { 220 | userId: id, 221 | }, 222 | data: { 223 | isJoiningGame: false, 224 | }, 225 | }); 226 | } 227 | 228 | /** 229 | * Function for removing user from player array (leaving, get kicked) 230 | * @param id Specific user id 231 | */ 232 | async removeUserFromArray(id: number) { 233 | const playerOrdersExist = this.game.playerOrders.length > 0; 234 | const cardsExist = this.game.cards.length > 0; 235 | 236 | const [updatedGameState] = await prisma.$transaction([ 237 | prisma.game.update({ 238 | where: { 239 | id: this.game.id, 240 | }, 241 | data: { 242 | allPlayers: { 243 | delete: { 244 | playerId: id, 245 | }, 246 | }, 247 | playerOrders: playerOrdersExist 248 | ? { 249 | delete: { 250 | playerId: id, 251 | }, 252 | } 253 | : {}, 254 | cards: cardsExist 255 | ? { 256 | delete: { 257 | playerId: id, 258 | }, 259 | } 260 | : {}, 261 | }, 262 | include: { 263 | allPlayers: true, 264 | bannedPlayers: true, 265 | cards: true, 266 | playerOrders: true, 267 | }, 268 | }), 269 | prisma.userGameProperty.update({ 270 | where: { 271 | userId: id, 272 | }, 273 | data: { 274 | isJoiningGame: false, 275 | gameID: null, 276 | }, 277 | }), 278 | ]); 279 | 280 | this.game = updatedGameState; 281 | } 282 | 283 | /** 284 | * Function for adding user on banned player array 285 | * @param id Specific user id 286 | */ 287 | async addUserToBannedList(id: number) { 288 | const updatedGameState = await prisma.game.update({ 289 | where: { 290 | id: this.game.id, 291 | }, 292 | data: { 293 | bannedPlayers: { 294 | create: { 295 | playerId: id, 296 | }, 297 | }, 298 | }, 299 | include: { 300 | allPlayers: true, 301 | bannedPlayers: true, 302 | cards: true, 303 | playerOrders: true, 304 | }, 305 | }); 306 | 307 | this.game = updatedGameState; 308 | } 309 | 310 | /** 311 | * Function for updating game current position 312 | * @param position User specific id 313 | */ 314 | async updatePosition(position: number) { 315 | const updatedGameState = await prisma.game.update({ 316 | where: { 317 | id: this.game.id, 318 | }, 319 | data: { 320 | currentPlayerId: position, 321 | }, 322 | include: { 323 | allPlayers: true, 324 | bannedPlayers: true, 325 | cards: true, 326 | playerOrders: true, 327 | }, 328 | }); 329 | 330 | this.game = updatedGameState; 331 | } 332 | 333 | /** 334 | * Function for updating game current card and current position 335 | * @param card Valid given card 336 | * @param position User specific id 337 | */ 338 | async updateCardAndPosition(card: allCard, position: number) { 339 | const updatedGameState = await prisma.game.update({ 340 | where: { 341 | id: this.game.id, 342 | }, 343 | data: { 344 | currentCard: card, 345 | currentPlayerId: position, 346 | }, 347 | include: { 348 | allPlayers: true, 349 | bannedPlayers: true, 350 | cards: true, 351 | playerOrders: true, 352 | }, 353 | }); 354 | 355 | this.game = updatedGameState; 356 | } 357 | 358 | /** 359 | * Function for reversing players order (uno reverse card) 360 | */ 361 | async reversePlayersOrder() { 362 | if (this.game.playerOrders) { 363 | const reversedList = this.game.playerOrders 364 | .reverse() 365 | .map((player, idx) => ({ ...player, playerOrder: idx + 1 })); 366 | 367 | await prisma.$transaction( 368 | reversedList.map((list) => 369 | prisma.game.update({ 370 | where: { 371 | id: this.game.id, 372 | }, 373 | data: { 374 | playerOrders: { 375 | update: { 376 | where: { 377 | playerId: list.playerId, 378 | }, 379 | data: { 380 | playerOrder: list.playerOrder, 381 | }, 382 | }, 383 | }, 384 | }, 385 | }), 386 | ), 387 | ); 388 | 389 | const updatedGameState = await prisma.game.findUnique({ 390 | where: { 391 | id: this.game.id, 392 | }, 393 | include: { 394 | allPlayers: true, 395 | bannedPlayers: true, 396 | cards: true, 397 | playerOrders: true, 398 | }, 399 | }); 400 | 401 | this.game = updatedGameState!; 402 | } 403 | } 404 | 405 | /** 406 | * Send message or message with image caption to desired players list 407 | * @param message Text that will sended 408 | * @param players Desired player list 409 | * @param image Image that will send (optional) 410 | */ 411 | async sendToSpecificPlayerList( 412 | message: MessageContent | MessageSendOptions, 413 | players: Player[], 414 | image?: MessageMedia, 415 | ) { 416 | if (players.length > 0) { 417 | const users = await Promise.all( 418 | players.map((player) => 419 | prisma.user.findUnique({ 420 | where: { id: player.playerId }, 421 | }), 422 | ), 423 | ); 424 | 425 | await Promise.all( 426 | users.map((user) => { 427 | if (user) 428 | return this.chat.sendToOtherPerson( 429 | user.phoneNumber, 430 | message, 431 | image, 432 | ); 433 | }), 434 | ); 435 | } 436 | } 437 | 438 | /** 439 | * Function for set game creator 440 | */ 441 | async setCreatorId(id: number) { 442 | const updatedGameState = await prisma.game.update({ 443 | where: { 444 | id: this.game.id, 445 | }, 446 | data: { 447 | gameCreatorId: id, 448 | }, 449 | include: { 450 | allPlayers: true, 451 | bannedPlayers: true, 452 | cards: true, 453 | playerOrders: true, 454 | }, 455 | }); 456 | 457 | this.game = updatedGameState; 458 | } 459 | 460 | /** 461 | * Function for set game winner 462 | */ 463 | async setWinner(id: number) { 464 | const updatedGame = await prisma.game.update({ 465 | where: { 466 | id: this.game.id, 467 | }, 468 | data: { 469 | winnerId: id, 470 | }, 471 | include: { 472 | allPlayers: true, 473 | bannedPlayers: true, 474 | cards: true, 475 | playerOrders: true, 476 | }, 477 | }); 478 | 479 | this.game = updatedGame; 480 | } 481 | 482 | /** 483 | * Get current player user document 484 | */ 485 | async getCurrentPlayerUserData() { 486 | const currentGame = await prisma.game.findUnique({ 487 | where: { 488 | id: this.game.id, 489 | }, 490 | }); 491 | 492 | if (!currentGame?.currentPlayerId) return null; 493 | 494 | return await prisma.user.findUnique({ 495 | where: { 496 | id: currentGame.currentPlayerId, 497 | }, 498 | }); 499 | } 500 | 501 | /** 502 | * Get user document that created this game 503 | */ 504 | async getCreatorUser() { 505 | return await prisma.user.findUnique({ 506 | where: { 507 | id: this.game.gameCreatorId, 508 | }, 509 | }); 510 | } 511 | 512 | /** 513 | * Get all player user document 514 | */ 515 | async getAllPlayerUserObject() { 516 | return await Promise.all( 517 | this.players.map((player) => 518 | prisma.user.findUnique({ where: { id: player.playerId } }), 519 | ), 520 | ); 521 | } 522 | 523 | /** 524 | * Function that will retrieve user next position 525 | * @param increment What is N-Position next player (default 1) 526 | * @returns User specific id document 527 | */ 528 | getNextPosition(increment = 1) { 529 | if (isNaN(increment) || increment < 1) throw new Error("Invalid increment"); 530 | 531 | if (this.game.playerOrders.length > 0 && this.game.currentPlayerId) { 532 | const playersOrder = this.game.playerOrders.sort( 533 | (a, b) => a.playerOrder - b.playerOrder, 534 | ); 535 | const currentPlayer = this.game.currentPlayerId; 536 | 537 | const currentIndex = playersOrder.findIndex( 538 | (player) => player.playerId === currentPlayer, 539 | ); 540 | 541 | const nextPlayerID = 542 | playersOrder[(currentIndex + increment) % playersOrder.length]; 543 | 544 | return this.players.find( 545 | (player) => player.playerId === nextPlayerID.playerId, 546 | ); 547 | } 548 | } 549 | 550 | /** 551 | * Function for retrieve elapsed since game started and ended 552 | * @returns Human readable elapsed time 553 | */ 554 | getElapsedTime() { 555 | return calcElapsedTime(this.game.started_at!, this.game.ended_at!); 556 | } 557 | 558 | /** 559 | * Function for retrieve if given player was already banned 560 | * @param id Specific user id 561 | * @returns True or false boolean 562 | */ 563 | isPlayerGotBanned(id: number) { 564 | return !!this.game.bannedPlayers?.find((player) => player.playerId === id); 565 | } 566 | 567 | /** 568 | * Get list of all players order id 569 | */ 570 | get playersOrderIds() { 571 | return this.game.playerOrders.map((player) => player.playerId); 572 | } 573 | 574 | /** 575 | * Get this game session unique id 576 | */ 577 | get uid() { 578 | return this.game.id; 579 | } 580 | 581 | /** 582 | * Get this game session human readable id 583 | */ 584 | get gameID() { 585 | return this.game.gameID; 586 | } 587 | 588 | /** 589 | * Get this game current position id 590 | */ 591 | get currentPositionId() { 592 | return this.game.currentPlayerId; 593 | } 594 | 595 | /** 596 | * Get this game a time when it's created 597 | */ 598 | get created_at() { 599 | return this.game.created_at; 600 | } 601 | 602 | /** 603 | * Get this game current state 604 | */ 605 | get state() { 606 | return { 607 | WAITING: this.game.status === "WAITING", 608 | PLAYING: this.game.status === "PLAYING", 609 | ENDED: this.game.status === "ENDED", 610 | }; 611 | } 612 | 613 | /** 614 | * Get if this game is not found 615 | */ 616 | get NotFound() { 617 | return !this.game; 618 | } 619 | 620 | /** 621 | * Get this game human readable status 622 | */ 623 | get translatedStatus() { 624 | switch (this.game.status) { 625 | case "WAITING": 626 | return "Menunggu Pemain"; 627 | case "PLAYING": 628 | return "Sedang Bermain"; 629 | case "ENDED": 630 | return "Selesai Bermain"; 631 | default: 632 | return "N/A"; 633 | } 634 | } 635 | 636 | /** 637 | * Get all players of this game 638 | */ 639 | get players() { 640 | return this.game.allPlayers; 641 | } 642 | 643 | /** 644 | * Get current card of this game 645 | */ 646 | get currentCard() { 647 | return this.game.currentCard; 648 | } 649 | 650 | /** 651 | * Get if current chatter is game creator or not 652 | */ 653 | get isGameCreator() { 654 | return this.chat.user!.id === this.game.gameCreatorId; 655 | } 656 | 657 | /** 658 | * Get if current player is an author of this game 659 | */ 660 | get currentPlayerIsAuthor() { 661 | return this.game.gameCreatorId === this.game.currentPlayerId; 662 | } 663 | 664 | /** 665 | * Get this game winner player id if there is a winner 666 | */ 667 | get winner() { 668 | return this.game.winnerId; 669 | } 670 | 671 | /** 672 | * Get if current chatter is it turn to play 673 | */ 674 | get isCurrentChatTurn() { 675 | return this._isPlayerTurn(this.chat.user!); 676 | } 677 | } 678 | --------------------------------------------------------------------------------