From 5af00fc4add91b6c82804759526f8aea23b19e8d Mon Sep 17 00:00:00 2001 From: Suhyun Park Date: Fri, 12 Jul 2024 22:14:50 +0900 Subject: [PATCH] feat: append record --- .../migration.sql | 8 ++ prisma/schema.prisma | 2 +- src/api/auth/login.ts | 8 +- src/api/index.ts | 2 + src/api/record/append.ts | 23 ++++++ src/api/record/index.ts | 9 ++ src/model/game/general.ts | 57 +++++++++++++ src/model/gameType/general.ts | 82 +++++++++++++++++++ src/model/gameType/yakuman.ts | 19 +++++ src/model/user/general.ts | 10 +++ src/types/i18n.ts | 5 ++ src/utils/guard.ts | 52 ++++++++++++ src/utils/jwt.ts | 8 +- 13 files changed, 272 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20240712122158_uma_precision/migration.sql create mode 100644 src/api/record/append.ts create mode 100644 src/api/record/index.ts create mode 100644 src/model/game/general.ts create mode 100644 src/model/gameType/general.ts create mode 100644 src/model/gameType/yakuman.ts create mode 100644 src/model/user/general.ts create mode 100644 src/types/i18n.ts create mode 100644 src/utils/guard.ts diff --git a/prisma/migrations/20240712122158_uma_precision/migration.sql b/prisma/migrations/20240712122158_uma_precision/migration.sql new file mode 100644 index 0000000..be49b74 --- /dev/null +++ b/prisma/migrations/20240712122158_uma_precision/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to alter the column `uma` on the `UserScore` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(10,1)`. + +*/ +-- AlterTable +ALTER TABLE `UserScore` MODIFY `uma` DECIMAL(10, 1) NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4852eda..d0c2d73 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,7 +70,7 @@ model UserScore { gameId Int initialSeat Wind score Int - uma Decimal + uma Decimal @db.Decimal(10, 1) rank Int stars Int createdAt DateTime @default(now()) diff --git a/src/api/auth/login.ts b/src/api/auth/login.ts index 60b7460..ae965eb 100644 --- a/src/api/auth/login.ts +++ b/src/api/auth/login.ts @@ -1,6 +1,6 @@ import { RequestHandler } from "express"; import { Record, String } from "runtypes"; -import db from "src/database/db"; +import { findUserByLoginId } from "src/model/user/general"; import { safeHash } from "src/utils/hash"; import { UserToJWT } from "src/utils/jwt"; @@ -12,11 +12,7 @@ const Body = Record({ const login: RequestHandler = async (req, res) => { const { loginId, password } = Body.check(req.body); - const user = await db.user.findUnique({ - where: { - loginId, - }, - }); + const user = await findUserByLoginId(loginId); if (!user) { res.status(401).send({ diff --git a/src/api/index.ts b/src/api/index.ts index eb22193..3f5b31c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import authMiddleware from "src/middlewares/auth"; import auth from "./auth"; +import record from "./record"; const router = Router(); router.use(authMiddleware); @@ -10,5 +11,6 @@ router.get("/hello", (req, res) => ); router.use("/auth", auth); +router.use("/record", record); export default router; diff --git a/src/api/record/append.ts b/src/api/record/append.ts new file mode 100644 index 0000000..bda10c7 --- /dev/null +++ b/src/api/record/append.ts @@ -0,0 +1,23 @@ +import { RequestHandler } from "express"; +import db from "src/database/db"; +import { gameInputToGameDataCreateInput } from "src/model/game/general"; +import { GameInputGuard } from "src/utils/guard"; + +const Body = GameInputGuard; + +const append: RequestHandler = async (req, res) => { + const body = Body.check(req.body); + + if (!req.user) { + res.sendStatus(401); + return; + } + + await db.gameRecord.create({ + data: gameInputToGameDataCreateInput(body), + }); + + res.sendStatus(200); +}; + +export default append; diff --git a/src/api/record/index.ts b/src/api/record/index.ts new file mode 100644 index 0000000..4ac38f4 --- /dev/null +++ b/src/api/record/index.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { wrap } from "src/utils/asyncWrapper"; +import append from "./append"; + +const router = Router(); + +router.put("/append", wrap(append)); + +export default router; diff --git a/src/model/game/general.ts b/src/model/game/general.ts new file mode 100644 index 0000000..86abc11 --- /dev/null +++ b/src/model/game/general.ts @@ -0,0 +1,57 @@ +import { Prisma, Wind } from "@prisma/client"; +import { Static } from "runtypes"; +import { GameInputGuard } from "src/utils/guard"; +import { gameTypes } from "../gameType/general"; + +const windRank: Record = { + [Wind.EAST]: 0, + [Wind.SOUTH]: 1, + [Wind.WEST]: 2, + [Wind.NORTH]: 3, +}; + +export const gameInputToGameDataCreateInput = ( + gameInput: Static +): Prisma.GameRecordCreateInput => { + const { gameType, userScores } = gameInput; + userScores.sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + return windRank[a.initialSeat] - windRank[b.initialSeat]; + }); + + const { uma, starsPerYakuman } = gameTypes[gameType]; + + return { + gameType, + userScores: { + create: userScores.map( + ({ score, userId, initialSeat, yakuman: yakumanArray }, i) => { + const stars = yakumanArray + .flatMap((x) => x) + .reduce((acc, x) => acc + starsPerYakuman[x], 0); + return { + score, + user: { + connect: { userId }, + }, + rank: i + 1, + initialSeat, + stars, + uma: uma(score, i + 1), + userScoreYakumanRecords: { + create: yakumanArray.map((yakumans) => ({ + starCount: yakumans.reduce( + (acc, x) => acc + starsPerYakuman[x], + 0 + ), + userScoreYakuman: { + create: yakumans.map((yakuman) => ({ yakuman })), + }, + })), + }, + }; + } + ), + }, + }; +}; diff --git a/src/model/gameType/general.ts b/src/model/gameType/general.ts new file mode 100644 index 0000000..2315496 --- /dev/null +++ b/src/model/gameType/general.ts @@ -0,0 +1,82 @@ +import { GameType, Wind, Yakuman } from "@prisma/client"; +import { LocalizedString } from "src/types/i18n"; +import { YAKUMAN_STARS_DEFAULT } from "./yakuman"; + +const RANK_UMA_HANCHAN_4P = [30, 10, -10, -30]; +const RANK_UMA_HANCHAN_3P = [30, 0, -30]; + +export interface GameTypeDetails { + players: number; + totalScore: number; + winds: Wind[]; + starsPerYakuman: Record; + uma: (score: number, rank: number) => number; + displayName: LocalizedString; +} + +export const gameTypes: Record = { + P4_HANCHAN: { + players: 4, + totalScore: 100000, + winds: [Wind.EAST, Wind.SOUTH, Wind.WEST, Wind.NORTH], + starsPerYakuman: YAKUMAN_STARS_DEFAULT, + uma: (score, rank) => { + const scoreUma = (score - 25000) / 1000; + const rankUma = RANK_UMA_HANCHAN_4P[rank - 1]; + return scoreUma + rankUma; + }, + displayName: { + ko: "4인 반장전", + en: "4-player Hanchan", + ja: "四人半荘戦", + }, + }, + P3_HANCHAN: { + players: 3, + totalScore: 105000, + winds: [Wind.EAST, Wind.SOUTH, Wind.WEST], + starsPerYakuman: YAKUMAN_STARS_DEFAULT, + uma: (score, rank) => { + const scoreUma = (score - 35000) / 1000; + const rankUma = RANK_UMA_HANCHAN_3P[rank - 1]; + return scoreUma + rankUma; + }, + displayName: { + ko: "3인 반장전", + en: "3-player Hanchan", + ja: "三人半荘戦", + }, + }, + P4_TOUPUUSEN: { + players: 4, + totalScore: 100000, + winds: [Wind.EAST, Wind.SOUTH, Wind.WEST, Wind.NORTH], + starsPerYakuman: YAKUMAN_STARS_DEFAULT, + uma: (score, rank) => { + const scoreUma = (score - 25000) / 1000; + const rankUma = RANK_UMA_HANCHAN_4P[rank - 1] / 2; + return scoreUma + rankUma; + }, + displayName: { + ko: "4인 통풍전", + en: "4-player Toupusen", + ja: "四人東風戦", + }, + }, + P3_TOUPUUSEN: { + players: 3, + totalScore: 105000, + winds: [Wind.EAST, Wind.SOUTH, Wind.WEST], + starsPerYakuman: YAKUMAN_STARS_DEFAULT, + uma: (score, rank) => { + const scoreUma = (score - 35000) / 1000; + const rankUma = RANK_UMA_HANCHAN_3P[rank - 1] / 2; + return scoreUma + rankUma; + }, + displayName: { + ko: "3인 통풍전", + en: "3-player Toupusen", + ja: "三人東風戦", + }, + }, +}; diff --git a/src/model/gameType/yakuman.ts b/src/model/gameType/yakuman.ts new file mode 100644 index 0000000..2da6382 --- /dev/null +++ b/src/model/gameType/yakuman.ts @@ -0,0 +1,19 @@ +import { Yakuman } from "@prisma/client"; + +export const YAKUMAN_STARS_DEFAULT: Record = { + TENHOU: 1, + CHIIHOU: 1, + SUUANKOU: 1, + SUUANKOU_TANKI: 2, + KOKUSHI_MUSOU: 1, + KOKUSHI_MUSOU_13MEN: 2, + CHUUREN_POUTOU: 1, + CHUUREN_POUTOU_JUNSEI: 2, + RYUUIISOU: 1, + TSUUIISOU: 1, + CHINROUTOU: 1, + DAISANGEN: 1, + SHOUSUUSHII: 1, + DAISUUSHII: 2, + SUUKANTSU: 1, +}; diff --git a/src/model/user/general.ts b/src/model/user/general.ts new file mode 100644 index 0000000..9ff12ae --- /dev/null +++ b/src/model/user/general.ts @@ -0,0 +1,10 @@ +import db from "src/database/db"; + +export const findUserByLoginId = async (loginId: string) => { + const user = await db.user.findUnique({ + where: { + loginId, + }, + }); + return user; +}; diff --git a/src/types/i18n.ts b/src/types/i18n.ts new file mode 100644 index 0000000..b47d007 --- /dev/null +++ b/src/types/i18n.ts @@ -0,0 +1,5 @@ +export interface LocalizedString { + ko: string; + en: string; + ja: string; +} diff --git a/src/utils/guard.ts b/src/utils/guard.ts new file mode 100644 index 0000000..de11f43 --- /dev/null +++ b/src/utils/guard.ts @@ -0,0 +1,52 @@ +import { GameType, Wind, Yakuman } from "@prisma/client"; +import { + Array as ArrayType, + Number as NumberType, + Record as RecordType, + String as StringType, +} from "runtypes"; +import { gameTypes } from "src/model/gameType/general"; + +export const GameTypeGuard = StringType.withConstraint((x) => + Object.keys(GameType).includes(x) +); + +export const WindGuard = StringType.withConstraint((x) => + Object.keys(Wind).includes(x) +); + +export const YakumanGuard = StringType.withConstraint((x) => + Object.keys(Yakuman).includes(x) +); + +export const Integer = NumberType.withConstraint((x) => Number.isInteger(x)); + +export const GameInputGuard = RecordType({ + gameType: GameTypeGuard, + userScores: ArrayType( + RecordType({ + userId: Integer, + initialSeat: WindGuard, + score: Integer, + yakuman: ArrayType(ArrayType(YakumanGuard)), + }) + ), +}).withConstraint(({ gameType, userScores }) => { + const { players, totalScore, winds } = gameTypes[gameType]; + if (userScores.length !== players) { + return false; + } + const sum = userScores.reduce((acc, { score }) => acc + score, 0); + if (sum !== totalScore) { + return false; + } + const playerWinds = new Set(); + userScores.forEach(({ initialSeat }) => playerWinds.add(initialSeat)); + if (playerWinds.size !== players) { + return false; + } + if (Array.from(playerWinds).some((wind) => !winds.includes(wind))) { + return false; + } + return true; +}); diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index 5018fa6..cdb2130 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,7 +1,7 @@ import { User } from "@prisma/client"; import jwt from "jsonwebtoken"; import { Number, Record, String } from "runtypes"; -import db from "src/database/db"; +import { findUserByLoginId } from "src/model/user/general"; import config from "../config"; const TOKEN_VERSION = 1; @@ -41,11 +41,7 @@ export async function JWTToUser(token: string): Promise { throw new CredentialError("Token has expired"); } - const user = await db.user.findUnique({ - where: { - loginId, - }, - }); + const user = await findUserByLoginId(loginId); if (!user) throw new CredentialError("Invalid credentials."); return user;