import * as validators from "express-validator"; import { App } from "../App"; import { Context } from "../Context"; import * as fw from "../fw"; import * as stores from "../stores"; import * as resolvers from "../resolvers"; import * as modules from "../modules"; import BaseController from "./BaseController"; import * as types from "../types"; import { AkashicError, MessageEventParams } from "../akashic"; import * as scrapTicketApi from "@shinonomekazan/scrapticket-api"; interface CreatePlayParams { idToken: string; gameCode: string; roomId: string; ticketCode: string; contentCode?: string; contentVersion?: string; } interface DeleteCharacterParams { id: string; idToken: string; characterId: string; } interface SaveStateParams { id: string; idToken: string; ticketCode: string; state: string; } interface SelectPlayerParams { id: string; idToken: string; characterId: string; ticketCode: string; characterName?: string; } interface StartPlayParams { id: string; idToken: string; } interface PausePlayParams { id: string; idToken: string; } interface ResumePlayParams { id: string; idToken: string; } interface PlayPlayParams extends StartPlayParams { ticketCode: string; } interface GetPlayParams { id: string; } export class PlaysController extends BaseController { constructor(app: App) { super(app); this.validators.post = [ fw.params.InstantValidator( [ validators.body("gameCode").isString().notEmpty(), validators.body("idToken").isString().notEmpty(), validators.body("roomId").isString().isLength({ min: 2, max: 24 }).notEmpty(), validators.body("ticketCode").isString().notEmpty(), validators.body("contentCode").optional().isString().notEmpty(), validators.body("contentVersion").optional().isString().notEmpty(), ], (context) => ({ gameCode: context.req.body.gameCode, idToken: context.req.body.idToken, roomId: context.req.body.roomId, ticketCode: context.req.body.ticketCode, contentCode: context.req.body.contentCode, contentVersion: context.req.body.contentVersion, } as CreatePlayParams) ), ]; this.validators.put = [ fw.params.InstantValidator( [ validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty(), validators.body("characterId").isString().notEmpty(), validators.body("characterName").optional().isString().isLength({ max: 4, min: 1 }), validators.body("ticketCode").isString().notEmpty(), ], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, characterId: context.req.body.characterId, characterName: context.req.body.characterName, ticketCode: context.req.body.ticketCode, } as SelectPlayerParams) ), ]; this.validators.get = [ fw.params.InstantValidator( [validators.param("id").isString().notEmpty()], (context) => ({ id: context.req.params.id as string, } as GetPlayParams) ), ]; } register(basePath: string) { const router = super.register(basePath); this.registerRoute(router, "POST", "/:id/start", this.start, [ fw.params.InstantValidator( [validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty()], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, } as StartPlayParams) ), ]); this.registerRoute(router, "DELETE", "/:id/characters/:characterId", this.deleteCharacter, [ fw.params.InstantValidator( [ validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty(), validators.body("characterId").isString().notEmpty(), ], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, characterId: context.req.body.characterId, } as DeleteCharacterParams) ), ]); this.registerRoute(router, "POST", "/:id/play", this.play, [ fw.params.InstantValidator( [ validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty(), validators.body("ticketCode").isString().notEmpty(), ], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, ticketCode: context.req.body.ticketCode, } as PlayPlayParams) ), ]); this.registerRoute(router, "POST", "/:id/pause", this.pause, [ fw.params.InstantValidator( [validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty()], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, } as PausePlayParams) ), ]); this.registerRoute(router, "POST", "/:id/resume", this.resume, [ fw.params.InstantValidator( [validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty()], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, } as ResumePlayParams) ), ]); this.registerRoute(router, "PUT", "/:id/properties/state", this.saveState, [ fw.params.InstantValidator( [ validators.param("id").isString().notEmpty(), validators.body("idToken").isString().notEmpty(), validators.body("ticketCode").isString().notEmpty(), validators.body("state").isString().notEmpty(), ], (context) => ({ id: context.req.params.id as string, idToken: context.req.body.idToken, ticketCode: context.req.body.ticketCode, state: context.req.body.state, } as SaveStateParams) ), ]); return router; } async get(context: Context) { const p = context.params as GetPlayParams; const id = p.id as string; const play = await resolvers.Play.resolve(this.app.firestore, id); if (play == null) { const playId = await resolvers.PlayId.resolve(this.app.firestore, id); if (playId == null) { throw new fw.types.NotFound("play not found"); } throw new types.PlayNotStartedError(); } const game = await resolvers.Game.resolve(this.app.firestore, play.gameCode); if (game == null) { throw new fw.types.InternalServerError("invalid play data"); } const url = play.contentUrl ?? (await this.app.akashicClient.resolveContentUrl(game.contentCode)); if (url == null) { throw new fw.types.InternalServerError("Invalid game code"); } return { play, game, content: { code: game.contentCode, url, }, }; } async play(context: Context) { const p = context.params as PlayPlayParams; const verifyResult = await this.app.auth.verifyIdToken(p.idToken); const id = context.params.id as string; const userId = verifyResult.uid; const play = await resolvers.Play.resolve(this.app.firestore, id); if (play == null) { throw new fw.types.NotFound("play not found"); } const game = await resolvers.Game.resolve(this.app.firestore, play.gameCode); if (game == null) { throw new fw.types.InternalServerError("invalid play data"); } const url = play.contentUrl ?? (await this.app.akashicClient.resolveContentUrl(game.contentCode)); if (url == null) { throw new fw.types.InternalServerError("Invalid game code"); } const isPlayer = play.players.some((player) => player.playerId === userId); if (isPlayer !== true) { throw new types.OtherPlayerPlayError(); } const state = play.state; if (state === "terminated") { throw new types.TerminatedPlayError(); } if (play.akashicPlayId == null) { return { play, game, content: { code: game.contentCode, url, }, }; } const ticketVerifyResult = process.env.TICKET_VERIFY !== "ON" ? await Promise.resolve(true) : await scrapTicketApi.verify(p.ticketCode, this.app.scrapConfig).catch((err) => { if (err.errorCode === 40051) { throw new types.InvalidTicketFormatError(); } else { throw new fw.types.InternalServerError(err.message); } }); if (ticketVerifyResult !== true) { throw new types.InvalidTicketError(); } // この時点でuserTokenは保存されている前提 const targetUserToken = await resolvers.UserTokens.resolve(this.app.firestore, p.ticketCode); if (targetUserToken == null) { throw new fw.types.NotFound("ticketcode not found"); } if (targetUserToken.userId !== userId && process.env.TICKET_VERIFY === "ON") { throw new types.DuplicateTokenError(); } try { const playToken = await this.app.akashicClient.createPlayToken(play.akashicPlayId, verifyResult.uid); return { play, game, content: { code: game.contentCode, url, }, playToken, }; } catch (error) { const akashicError = error as AkashicError; if (akashicError.parsedResponse?.meta.status === 409) { throw new types.InvalidPlayStatusnError(); } throw error; } } async post(context: Context) { const p = context.params as CreatePlayParams; const verifyResult = await this.app.auth.verifyIdToken(p.idToken); const ownerUserId = verifyResult.uid; const game = await resolvers.Game.resolve(this.app.firestore, p.gameCode); if (game == null) { throw new fw.types.NotFound("game not found"); } let contentUrl = resolvers.contents.resolveUrl(game.contentCode); if (p.contentCode != null && p.contentVersion != null) { contentUrl = resolvers.contents.resolveUrlByCodeAndVersion(p.contentCode, p.contentVersion); } const playId = await resolvers.PlayId.resolve(this.app.firestore, p.roomId); if (playId == null || playId.uid !== ownerUserId) { throw new fw.types.NotFound("play not found"); } const ticketVerifyResult = process.env.TICKET_VERIFY !== "ON" ? await Promise.resolve(true) : await scrapTicketApi.verify(p.ticketCode, this.app.scrapConfig).catch((err) => { if (err.errorCode === 40051) { throw new types.InvalidTicketFormatError(); } else { throw new fw.types.InternalServerError(err.message); } }); if (ticketVerifyResult !== true) { throw new types.InvalidTicketError(); } await stores.storePlay(this.app.firestore, { ownerUserId, roomId: p.roomId, gameCode: p.gameCode, contentUrl, ticketCode: p.ticketCode, onlyOnePlay: this.app.scrapConfig.restrictions.onlyOnePlay === true, }); if (playId == null) { throw new types.DuplicateRoomError(); } return { id: p.roomId, }; } async put(context: Context) { const p = context.params as SelectPlayerParams; const verifyResult = await this.app.auth.verifyIdToken(p.idToken); const ticketVerifyResult = process.env.TICKET_VERIFY !== "ON" ? await Promise.resolve(true) : await scrapTicketApi.verify(p.ticketCode, this.app.scrapConfig).catch((err) => { if (err.errorCode === 40051) { throw new types.InvalidTicketFormatError(); } else { throw new fw.types.InternalServerError(err.message); } }); if (ticketVerifyResult !== true) { throw new types.InvalidTicketError(); } const result = await stores.storePlayer( this.app.firestore, p.id, p.characterId, verifyResult.uid, p.ticketCode, p.characterName ); return { players: result, }; } async deleteCharacter(context: Context) { const p = context.params as DeleteCharacterParams; const verifyResult = await this.app.auth.verifyIdToken(p.idToken); return stores.deleteCharacter(this.app.firestore, p.id, p.characterId, verifyResult.uid); } async start(context: Context) { const p = context.params as StartPlayParams; const play = await resolvers.Play.resolve(this.app.firestore, p.id); if (play == null) { throw new fw.types.NotFound("play not found"); } const contentUrl = play.contentUrl ?? resolvers.contents.resolveUrl(play.gameCode); if (contentUrl == null) { throw new fw.types.BadRequest("Invalid content url"); } const hasPlayerInPreparation = play.players.some((player: any) => player.playerId == null); if (hasPlayerInPreparation) { throw new fw.types.BadRequest("Player in preparation detected"); } const game = await resolvers.Game.resolve(this.app.firestore, play.gameCode); if (game == null) { throw new fw.types.InternalServerError("invalid play data"); } if ( await stores.tryStartPlay({ firestore: this.app.firestore, maxInstanceCount: this.app.akashicConfig.maxInstanceCount, playId: p.id, timeLimitM: this.app.playConfig.timeLimitM, }) ) { const initialEvent: MessageEventParams = { type: "Message", values: { userId: ":akashic", event: { type: "start", parameters: { players: play.players, }, }, }, }; const akashicPlay = await this.app.akashicClient.createPlay( game.contentCode, contentUrl, this.app.akashicConfig.handlerUrl, undefined, initialEvent ); await stores.storeAkashicPlayId(this.app.firestore, p.id, akashicPlay.playId); } return modules.convertPlayToPlayResponse(p.id, play); } async pause(context: Context) { const p = context.params as PausePlayParams; const play = await resolvers.Play.resolve(this.app.firestore, p.id); const verifyResult = await this.app.auth.verifyIdToken(p.idToken); if (play == null) { throw new fw.types.NotFound("play not found"); } if (play.akashicPlayId == null || play.state === "standby") { throw new fw.types.BadRequest("Invalid play status"); } if (play.state === "terminated") { throw new fw.types.BadRequest("すでに中断されています。"); } if (play.state !== "playing") { throw new fw.types.InternalServerError(`不明なプレー状態です: ${play.state}`); } const hasPlayer = play.players.some((player) => player.playerId === verifyResult.uid); if (hasPlayer !== true) { throw new fw.types.Forbidden("プレイヤーではないため操作できません"); } await this.app.akashicClient.stopInstance(play.akashicPlayId); await stores.terminatePlay(this.app.firestore, p.id); return { result: "ok", }; } async resume(context: Context) { const p = context.params as ResumePlayParams; const play = await resolvers.Play.resolve(this.app.firestore, p.id); const verifyResult = await this.app.auth.verifyIdToken(p.idToken); if (play == null) { throw new fw.types.NotFound("play not found"); } if (play.akashicPlayId == null || play.state === "standby") { throw new fw.types.BadRequest("Invalid play status"); } if (play.state === "playing") { throw new types.AlreadyPlayResumed(); } if (play.state !== "terminated") { throw new fw.types.InternalServerError(`不明なプレー状態です: ${play.state}`); } const hasPlayer = play.players.some((player) => player.playerId === verifyResult.uid); if (hasPlayer !== true) { throw new fw.types.Forbidden("プレイヤーではないため操作できません"); } const game = await resolvers.Game.resolve(this.app.firestore, play.gameCode); if (game == null) { throw new fw.types.InternalServerError("invalid play data"); } const contentUrl = play.contentUrl ?? resolvers.contents.resolveUrl(play.gameCode); if (contentUrl == null) { throw new fw.types.BadRequest("Invalid content url"); } const state = await resolvers.Play.resolveState(this.app.firestore, p.id); if ( await stores.tryStartPlay({ firestore: this.app.firestore, maxInstanceCount: this.app.akashicConfig.maxInstanceCount, playId: p.id, timeLimitM: this.app.playConfig.timeLimitM, initialArgs: state?.gameState, prevState: "terminated", }) ) { const parsedGameState = state?.gameState == null ? undefined : JSON.parse(state.gameState); const initialEvent: MessageEventParams = { type: "Message", values: { userId: ":akashic", event: { type: "start", parameters: { players: play.players, }, }, }, }; const akashicPlay = await this.app.akashicClient.createPlay( game.contentCode, contentUrl, this.app.akashicConfig.handlerUrl, parsedGameState == null ? undefined : { snapshot: { state: parsedGameState, }, }, initialEvent ); await stores.storeAkashicPlayId(this.app.firestore, p.id, akashicPlay.playId); } return modules.convertPlayToPlayResponse(p.id, play); } async saveState(context: Context) { const p = context.params as SaveStateParams; await this.app.auth.verifyIdToken(p.idToken); const ticketVerifyResult = process.env.TICKET_VERIFY !== "ON" ? await Promise.resolve(true) : await scrapTicketApi.verify(p.ticketCode, this.app.scrapConfig).catch((err) => { if (err.errorCode === 40051) { throw new types.InvalidTicketFormatError(); } else { throw new fw.types.InternalServerError(err.message); } }); if (ticketVerifyResult !== true) { throw new types.InvalidTicketError(); } await stores.storeGameState(this.app.firestore, p.id, p.state); return { result: "ok", }; } }