├── .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 | 
2 |
3 |
4 |
WUNO (Whatsapp UNO) Bot
5 | Bot whatsapp yang berguna untuk bermain UNO.
6 |
7 |
8 |
9 |
10 | [](https://github.com/reacto11mecha/wuno-bot/actions/workflows/lint-typing.yml) [](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 |
--------------------------------------------------------------------------------