import { Firestore, DocumentReference, Timestamp, FieldValue } from "firebase-admin/firestore"; import * as resolvers from "../resolvers"; import * as common from "@shinonomekazan/scrap-conan-online"; import * as types from "../types"; import { createRoomId, createPlayId, eraseUndefined } from "../utils"; export interface StorePlayParams { gameCode: string; password?: string; roomId: string; ownerUserId: string; contentUrl?: string; ticketCode: string; onlyOnePlay: boolean; } export async function withinGamePlayTransaction( firestore: Firestore, playId: string, updateFunction: (t: types.TransactionContext) => Promise ) { return firestore.runTransaction(async (transaction) => { const docRef = firestore.collection("/plays").doc(playId) as DocumentReference; const docSnapshot = await transaction.get(docRef); return updateFunction({ transaction, docRef, docSnapshot }); }); } /** * play作成・参加時にtoken情報も保存する * @param firestore * @param playId ルームID * @param userId ユーザーID * @param token スクチケAPIで生成されたトークン * @param updateFunction t:playsのドキュメント情報, d:userTokensのドキュメント情報 */ export async function withinGamePlayAndStoreTokenTransaction( firestore: Firestore, playId: string, userId: string, token: string, updateFunction: ( t: types.TransactionContext, d: types.TransactionContext ) => Promise ) { return firestore.runTransaction(async (transaction) => { const playDocRef = firestore.collection("/plays").doc(playId) as DocumentReference; const playDocSnapShot = await transaction.get(playDocRef); const userTokenDocRef = firestore .collection("/userTokens") .doc(token) as DocumentReference; const userTokensDocSnapshot = await transaction.get(userTokenDocRef); return updateFunction( { transaction: transaction, docRef: playDocRef, docSnapshot: playDocSnapShot, }, { transaction: transaction, docRef: userTokenDocRef, docSnapshot: userTokensDocSnapshot, } ); }); } export async function storePlay(firestore: Firestore, params: StorePlayParams) { const game = await resolvers.Game.resolve(firestore, params.gameCode); const roomId = params.roomId; const ownerUserId = params.ownerUserId; const ticketCode = params.ticketCode; const now = Timestamp.now(); if (game == null) { throw new Error("Invalid game"); } const userTokensDocParams: types.StoreUserToken = { userId: params.ownerUserId, createdPlayIds: [roomId], createdAt: now, }; const result = await withinGamePlayAndStoreTokenTransaction( firestore, roomId, ownerUserId, ticketCode, async (t, d) => { // トークンがすでに保存されているか確認する if (d.docSnapshot.exists && process.env.TICKET_VERIFY === "ON") { const token = d.docSnapshot.data() as types.StoreUserToken; // すでにトークンと別のユーザーIDが紐づいていた場合はエラーを返す if (ownerUserId !== token?.userId) { throw new types.DuplicateTokenError(); } if (token.createdPlayIds != null) { if (params.onlyOnePlay && token.createdPlayIds.length > 0) { // createdPlayIdにplayIdがあればエラーとする throw new types.UsedTokenError(); } const createdPlayIds = token.createdPlayIds.concat([roomId]); t.transaction.update(d.docRef, { createdPlayIds }); } else { t.transaction.update(d.docRef, { createdPlayIds: [roomId] }); } } else { t.transaction.set(d.docRef, userTokensDocParams); } if (t.docSnapshot.exists) { throw new types.DuplicateRoomError(); } t.transaction.set( t.docRef, eraseUndefined({ players: game.characters.map((character) => ({ characterId: character.id, playerId: null, playerName: character.name, })), gameCode: params.gameCode, state: "standby", ownerUserId: params.ownerUserId, contentUrl: params.contentUrl, createdAt: now, } as common.GamePlay) ); return params.roomId; } ); return result; } export async function deleteCharacter(firestore: Firestore, playId: string, characterId: string, playerId: string) { const result = await withinGamePlayTransaction(firestore, playId, async (t) => { if (!t.docSnapshot.exists) { throw new Error("Invalid play"); } const play = t.docSnapshot.data()!; const targetPlayer = play?.players.find((player) => player.characterId === characterId); if (targetPlayer == null) { return false; } if (targetPlayer.playerId !== playerId) { return false; } targetPlayer.playerId = null; t.transaction.update(t.docRef, { players: play?.players, }); return true; }); return result; } export async function storePlayer( firestore: Firestore, playId: string, characterId: string, playerId: string, ticketCode: string, playerName?: string ) { const userTokensDocParams: types.StoreUserToken = { userId: playerId, createdPlayIds: [], createdAt: Timestamp.now(), }; const result = await withinGamePlayAndStoreTokenTransaction( firestore, playId, playerId, ticketCode, async (t, d) => { if (!t.docSnapshot.exists) { throw new Error("Invalid play"); } const play = t.docSnapshot.data()!; // トークンがすでに保存されているか確認する if (d.docSnapshot.exists && process.env.TICKET_VERIFY === "ON") { const token = d.docSnapshot.data(); // すでにトークンと別のユーザーIDが紐づいていた場合はエラーを返す if (playerId !== token?.userId) { throw new types.DuplicateTokenError(); } } else { t.transaction.set(d.docRef, userTokensDocParams); } const targetPlayer = play?.players.find((player) => player.characterId === characterId); if (targetPlayer == null) { throw new Error("Invalid character"); } targetPlayer.playerId = playerId; if (playerName != null) { if (!targetPlayer.playerName) { targetPlayer.initialPlayerName = playerName; } targetPlayer.playerName = playerName; } t.transaction.update(t.docRef, { players: play?.players, }); return play.players; } ); return result; } export async function storeGameState(firestore: Firestore, playId: string, gameState: string) { const result = await withinGamePlayTransaction(firestore, playId, async (t) => { const docRef = firestore.collection(`/plays/${playId}/properties`).doc("state"); t.transaction.set(docRef, { gameState, }); }); return result; } export interface TryStartPlayParams { firestore: Firestore; maxInstanceCount: number; playId: string; timeLimitM: number; initialArgs?: string; prevState?: common.GamePlayState; } export async function tryStartPlay(params: TryStartPlayParams) { const akashicProperty = await resolvers.properties.Akashic.resolve(params.firestore); const currentInstanceCount = akashicProperty != null && akashicProperty.instanceCount != null ? akashicProperty.instanceCount : 0; if (currentInstanceCount >= params.maxInstanceCount) { throw new types.PlayCapacityIsFullError(); } const now = Timestamp.now(); const expireAt = Timestamp.fromMillis(now.toMillis() + params.timeLimitM * 60 * 1000); const play = await resolvers.Play.resolve(params.firestore, params.playId); if (play == null) { throw new Error("Invalid play"); } const prevState = params.prevState ?? "standby"; if (play.state !== prevState) { return false; } await params.firestore .collection("/plays") .doc(params.playId) .update( eraseUndefined({ state: "playing", initialArgs: params.initialArgs, expireAt, }) ); return true; } export function storeAkashicPlayId(firestore: Firestore, playId: string, akashicPlayId: string) { return withinGamePlayTransaction(firestore, playId, async (t) => { if (!t.docSnapshot.exists) { throw new Error("Invalid play"); } const play = t.docSnapshot.data()!; const playIds = play.playIds ?? []; playIds.push(akashicPlayId); await t.transaction.update(t.docRef, { akashicPlayId, playIds, }); }); } export function tryStorePlayId(firestore: Firestore, ownerUserId: string) { return firestore.runTransaction(async (transaction) => { const roomId = createRoomId(); const playId = createPlayId(roomId); const docRef = firestore.collection("/playIds").doc(playId); const docSnapshot = await transaction.get(docRef); if (docSnapshot.exists) throw new Error("既に存在しているroomIdです"); const playIdDocument: types.PlayIdDocument = { createdAt: Timestamp.now(), playId, roomId, uid: ownerUserId, }; await transaction.set(docRef, playIdDocument); return playIdDocument; }); } /** * 有効期限が切れたPlayの一覧を取得する * バッチ処理用を想定 * @param firestore * @param now 現在時刻 */ export async function listPlaysExpired(firestore: Firestore, now: Date): Promise { const snapshot = await firestore.collection("/plays").where("expireAt", "<", now).get(); let playsExpired: types.akashicPlayWithId[] = []; snapshot.forEach((doc) => { const play = doc.data() as common.GamePlay; playsExpired.push({ akashicPlayId: play.akashicPlayId!, id: doc.id, }); }); return playsExpired; } export function terminatePlay(firestore: Firestore, playId: string) { return firestore.collection("plays").doc(playId).update({ state: "terminated", expireAt: FieldValue.delete(), }); }