diff --git a/.gitignore b/.gitignore index 08b35db..fa10f37 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ build/ .eslintcache # Misc -.DS_Store \ No newline at end of file +.DS_Store + +.env diff --git a/src/apis/p2d/index.ts b/src/apis/p2d/index.ts index 11ef9da..594af28 100644 --- a/src/apis/p2d/index.ts +++ b/src/apis/p2d/index.ts @@ -2,7 +2,8 @@ import { Logger } from "@cordisjs/logger"; import Router from "@koa/router"; import { Context } from "../index.js"; import { UserService } from "../../services/p2d/user.js"; -import { PlayerCustomizeSetting } from "../../database/index.js"; +import { PlayerCustomizeSetting, PlayerRivalData } from "../../types/index.js"; +import { RivalPatch, RivalPostOrDelete } from "../../types/api.js"; const logger = new Logger('p2d-api'); const router = new Router({ @@ -21,7 +22,7 @@ const router = new Router({ const pdata = await ctx.resolve(UserService).getPDataDecoded(token); ctx.status = pdata ? 200 : 404; - ctx.body = pdata ? pdata : {}; + ctx.body = pdata ? { pdata } : {}; }) .get('/customize/:token', async (ctx: Context) => { @@ -41,6 +42,147 @@ const router = new Router({ }; }) + .get('/playlog/:token', async (ctx: Context) => { + let { skip, limit } = ctx.query; + const { token } = ctx.params; + + if (skip instanceof Array) { + [skip] = skip; + } + + if (limit instanceof Array) { + [limit] = limit; + } + + const result = await ctx.resolve(UserService) + .getPlayLogs(token, skip ? parseInt(skip) : undefined, limit ? parseInt(limit) : undefined); + + ctx.status = 200; + ctx.body = { + playLogs: result, + }; + }) + + .get('/rival/:token', async (ctx: Context) => { + const { token } = ctx.params; + const result = await ctx.resolve(UserService) + .getPlayerRivalData(token); + + if (!result.enabled) { + ctx.status = 200; + ctx.body = { + enabled: false, + spRivals: [], + dpRivals: [], + }; + + return; + } + + const getRivalPlayer = async (tk: string) => { + const pdata = await ctx.resolve(UserService) + .getPDataDecoded(tk); + + return pdata.player; + }; + + const [spRivals, dpRivals] = await Promise.all([ + await Promise.all(result.sp.map(getRivalPlayer)), + await Promise.all(result.dp.map(getRivalPlayer)), + ]); + + ctx.status = 200; + ctx.body = { + enabled: true, + spRivals, dpRivals, + }; + }) + + .patch('/rival/:token', async (ctx: Context) => { + const { token } = ctx.params; + const { enabled } = ctx.request.body as RivalPatch; + + await ctx.resolve(UserService) + .upsertPlayerRivalData({ + _id: token, + enabled, + } as PlayerRivalData); + + ctx.status = 200; + ctx.body = { + success: true, + }; + }) + + .post('/rival/:token', async (ctx: Context) => { + const { token } = ctx.params; + const { infinitas_id, type } = ctx.request.body as RivalPostOrDelete; + + if (!infinitas_id) { + ctx.status = 400; + ctx.body = { error: 'infinitas_id is undefined' }; + return; + } + + const [existing, { player: target }] = await Promise.all([ + ctx.resolve(UserService).getPlayerRivalData(token), + ctx.resolve(UserService).findPlayerByInfasId(infinitas_id), + ]); + + if (!target) { + ctx.status = 404; + ctx.body = { error: 'rival target not found' }; + return; + } + + if (target == token) { + ctx.status = 400; + ctx.body = { error: 'ni rival ni zi ji?' }; + return; + } + + const targetArray = type ? existing.dp : existing.sp; + if (targetArray.includes(target)) { + ctx.status = 400; + ctx.body = { error: 'rival target already exists' }; + return; + } + + targetArray.push(target); + + await ctx.resolve(UserService) + .upsertPlayerRivalData(existing); + + ctx.status = 200; + ctx.body = { + success: true, + }; + }) + + .delete('/rival/:token', async (ctx: Context) => { + const { token } = ctx.params; + const { infinitas_id, type } = ctx.request.body as RivalPostOrDelete; + + const [existing, { player: target }] = await Promise.all([ + ctx.resolve(UserService).getPlayerRivalData(token), + ctx.resolve(UserService).findPlayerByInfasId(infinitas_id), + ]); + + if (type) { + existing.dp = existing.dp.filter(v => v != target) + } else { + existing.sp = existing.sp.filter(v => v != target) + } + + await ctx.resolve(UserService) + .upsertPlayerRivalData(existing); + + ctx.status = 200; + ctx.body = { + success: true, + }; + }) + .put('/customize/:token', async (ctx: Context) => { const { token } = ctx.params; diff --git a/src/controllers/p2d/music.ts b/src/controllers/p2d/music.ts index 1f57a90..2f571e9 100644 --- a/src/controllers/p2d/music.ts +++ b/src/controllers/p2d/music.ts @@ -1,13 +1,86 @@ -import { PlayerPlayLog } from "../../database/index.js"; +import { PlayerPlayLog } from "../../types/index.js"; import { eacnet } from "../../decorators/eacnet.js"; import { UserService } from "../../services/p2d/user.js"; import { Context } from "../../types.js"; import { v } from "../../utils/kxml-value.js"; import { MUSIC_LIST } from "./index.js"; +import { Binary } from "mongodb"; export class Music { userService: UserService; + @eacnet('p2d') + async getMusicGhost(ctx: Context) { + // playstyle is actually chart diff, game is sending wrong request + // TODO: check game code and maybe patch it later + const data = ctx.body as { + music_id: number, + note_id: number, + playstyle: number, + request_ghost: { + kind: number, + pref_id: number, + grade_id: number, + infinitas_id?: string, + }[] | { + kind: number, + pref_id: number, + grade_id: number, + infinitas_id?: string, + }; + }; + + const { music_id, note_id } = data; + const request_ghost = 'kind' in data.request_ghost ? [data.request_ghost] : data.request_ghost; + + const play_style = Math.floor(note_id / 5); + const diff = note_id % 5; + + const ghost = await Promise.all(request_ghost.map(async req => { + if (req.kind == 2) { + const { player, djname } = await this.userService.findPlayerByInfasId(req.infinitas_id); + + if (!player) { + return undefined; + } + + const mdata = await this.userService.getMusicData(player, music_id, play_style); + if (mdata.best_score_clock[diff] === -1) { + return undefined; + } + + const playlog = await this.userService.getPlayLog(player, mdata.best_score_clock[diff]); + + return { + kind: v.s32(2), + ex_score: v.s32(playlog.score), + exist: v.bool(true), + ghost_data: v.bin(Buffer.from(playlog.ghost.buffer)), + dj_name: v.str(djname), + best_option: { + valid: v.bool(1), + arrange_0: v.s32(playlog.arrange_0), + arrange_1: v.s32(playlog.arrange_1), + assist: v.s32(playlog.assist), + flip: v.s32(playlog.flip), + } + } + } + + return undefined; + })); + + return { + status: v.s32(0), + error: v.s32(0), + result: { + music_id: v.s32(music_id), + note_id: v.s32(note_id), + ghost: ghost.filter(v => v), + }, + }; + } + @eacnet('p2d') async reportMusicResult(ctx: Context) { const result = ctx.body as PlayerPlayLog; @@ -27,8 +100,10 @@ export class Music { if (result.miss_count < musicData.miss_count[diff] || musicData.miss_count[diff] == -1) musicData.miss_count[diff] = result.miss_count; - if (result.score > musicData.score[diff]) + if (result.score > musicData.score[diff]) { musicData.score[diff] = result.score; + musicData.best_score_clock[diff] = result.clock; + } if (result.clear_flag >= 2) musicData.clear_num[diff]++; @@ -38,6 +113,9 @@ export class Music { this.userService.upsertMusicData(musicData); })(); + // @ts-expect-error ghost is Buffer now, convert it to binary + result.ghost = new Binary(result.ghost); + await Promise.all([ this.userService.addPlayLog(result), updateTask, diff --git a/src/controllers/p2d/user.ts b/src/controllers/p2d/user.ts index ab14028..9987ede 100644 --- a/src/controllers/p2d/user.ts +++ b/src/controllers/p2d/user.ts @@ -1,9 +1,10 @@ import { eacnet } from "../../decorators/eacnet.js"; import { Context } from "../../types.js"; import { v } from "../../utils/kxml-value.js"; -import { ITEM_LIST } from "./index.js"; import { fromToken } from "../../utils/laochan-id.js"; import { UserService } from "../../services/p2d/user.js"; +import { sha256 } from "../../utils/sha256.js"; +import { toKBinXml } from "../../utils/kbinxml.js"; export class User { userService: UserService; @@ -38,13 +39,37 @@ export class User { } @eacnet('p2d') - async consumeItem() { + async consumeItem(ctx: Context) { + const { item_id } = ctx.body; + const { items_count } = await this.userService.getCustomizeSetting(ctx.token); + + let free_count = 0; + let not_free_count = 0; + + if (item_id === 'I1000000') { + not_free_count = items_count.infinitas_ticket; + free_count = items_count.infinitas_ticket_free; + } else if (item_id === 'I1000001') { + not_free_count = items_count.ldisc; + free_count = 0; + } + + free_count--; + if (free_count < 0) { + free_count = 0; + not_free_count--; + } + + if (not_free_count < 0) { + not_free_count = 0; + } + return { status: v.s32(0), error: v.s32(0), result: { - free_count: v.s32(1000), - not_free_count: v.s32(1000), + free_count: v.s32(free_count), + not_free_count: v.s32(not_free_count), } }; } @@ -86,7 +111,9 @@ export class User { } @eacnet('p2d') - async getPointList() { + async getPointList(ctx: Context) { + const customize = await this.userService.getCustomizeSetting(ctx.token); + return { status: v.s32(0), error: v.s32(0), @@ -94,7 +121,7 @@ export class User { point_count: v.s32(1), point: [{ point_id: v.str('P0100000'), - point_num: v.u32(114514), + point_num: v.u32(customize.items_count.bit), }] } } @@ -125,6 +152,11 @@ export class User { @eacnet('p2d') async savePlayData(ctx: Context) { const { pdata, check_sum } = ctx.body as { pdata: Buffer, check_sum: string }; + const localChecksum = sha256(pdata); + + if (localChecksum != check_sum) { + ctx.logger.error('pdata checksum unmatch - excepted %s, actual %s', check_sum, localChecksum); + } await this.userService.upsertPDataBinary(ctx.token, pdata, check_sum); @@ -160,6 +192,11 @@ export class User { @eacnet('p2d') async registPlayer(ctx: Context) { const { pdata, check_sum } = ctx.body as { pdata: Buffer, check_sum: string }; + const localChecksum = sha256(pdata); + + if (localChecksum != check_sum) { + ctx.logger.error('pdata checksum unmatch - excepted %s, actual %s', check_sum, localChecksum); + } await this.userService.upsertPDataBinary(ctx.token, pdata, check_sum); @@ -212,11 +249,73 @@ export class User { } @eacnet('p2d') - async getRivalInfo() { + async getRivalInfo(ctx: Context) { + const rivalData = await this.userService.getPlayerRivalData(ctx.token); + if (!rivalData.enabled) { + return { + status: v.s32(0), + error: v.s32(0), + result: {}, + } + } + + const getRivalDetail = async (id: string, playStyle: number) => { + const [pdata, musicDatas] = await Promise.all([ + this.userService.getPDataDecoded(id), + this.userService.getMusicDatas(id, playStyle), + ]); + + return { + infinitas_id: v.str(pdata.player.infinitas_id), + dj_name: v.str(pdata.player.djname), + shop_name: v.str('Laochan Eacnet'), + pref_id: v.s32(pdata.player.pref_id), + grade_id_sp: v.s32(pdata.player.grade_id_sp), + grade_id_dp: v.s32(pdata.player.grade_id_dp), + music_data: { + music: musicDatas.map(data => ({ + music_id: v.s32(data.music_id), + score: v.s32(data.score), + clear_flag: v.s32(data.clear_flag), + })), + }, + challenges: {}, + comment: { + status_comment: v.str(''), + rival_challenge_sweeping_victory: v.str(''), + rival_challenge_narrow_victory: v.str(''), + } + } + } + + const [spRival, dpRival] = await Promise.all([ + Promise.all(rivalData.sp.map(id => getRivalDetail(id, 0))), + Promise.all(rivalData.dp.map(id => getRivalDetail(id, 1))), + ]); + + const rivalInfo = { + sp: { + rival_num: v.s32(rivalData.sp.length), + rival: spRival, + }, + dp: { + rival_num: v.s32(rivalData.dp.length), + rival: dpRival, + }, + }; + + const rival_binary = Buffer.from(toKBinXml('rival_info', rivalInfo).data); + const rival_checksum = sha256(rival_binary); + return { status: v.s32(0), error: v.s32(0), - result: {}, + result: { + check_sum: v.str(rival_checksum), + size: v.s32(rival_binary.length), + rival_data: v.bin(rival_binary), + rest_size: v.s32(0), + }, } } @@ -230,18 +329,26 @@ export class User { } @eacnet('p2d') - async getItemList() { + async getItemList(ctx: Context) { + const { items_count } = await this.userService.getCustomizeSetting(ctx.token); + return { status: v.s32(0), error: v.s32(0), result: { item_list: { - item_num: v.s32(ITEM_LIST.length), - item: ITEM_LIST.map(id => ({ - item_id: v.str(id), - not_free_count: v.s32(1000), - free_count: v.s32(1000), - })) + item_num: v.s32(2), + item: [{ + // infinitas ticket + item_id: v.str('I1000000'), + not_free_count: v.s32(items_count.infinitas_ticket), + free_count: v.s32(items_count.infinitas_ticket_free), + }, { + // ldisc + item_id: v.str('I1000001'), + not_free_count: v.s32(items_count.ldisc), + free_count: v.s32(0), + }] } } }; diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..d38911b --- /dev/null +++ b/src/database.ts @@ -0,0 +1,48 @@ +import { Logger } from "@cordisjs/logger"; +import { MongoClient } from "mongodb"; +import config from "./utils/config.js"; +import { PlayerPlayData } from "./types/index.js"; +import { fromKBinXml } from "./utils/kbinxml.js"; +import { Pdata } from "./types/pdata.js"; + +interface DatabaseMeta { + version: number; +} + +export async function initMongoDb() { + const logger = new Logger('database'); + + logger.info('connecting to mongodb...'); + const client = await MongoClient.connect(config.mongoUrl); + const db = client.db(config.dbName); + const metaCol = db.collection('__metadata'); + let meta: DatabaseMeta = await metaCol.findOne({}); + meta ??= { version: 0 }; + + // version 1: add infas id and djname to playdata collection + if (meta.version < 1) { + logger.info('upgrading database to ver 1'); + const playDataCol = db.collection('player_play_data'); + + const cursor = playDataCol.find({}); + const tasks = []; + for await (const player of cursor) { + const { pdata } = fromKBinXml(player.pdata.buffer) as { pdata: Pdata }; + + tasks.push(playDataCol.updateOne({ _id: player._id }, { + $set: { + djname: pdata.player.djname, + infinitas_id: pdata.player.infinitas_id, + } + })); + } + + await Promise.all(tasks); + + logger.info('upgraded database to ver 1, effected %d', tasks.length); + meta.version = 1; + } + + await metaCol.updateOne({}, { $set: meta }, { upsert: true }); + return db; +} diff --git a/src/main.ts b/src/main.ts index 84c1862..bd9cd68 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,8 +10,9 @@ import { DefaultService } from './controllers/default.js'; import { Logger } from '@cordisjs/logger'; import { eacnet } from './middlewares/eacnet.js'; import config from './utils/config.js'; -import { MongoClient, Db } from 'mongodb'; +import { Db } from 'mongodb'; import { apiRoutes } from './apis/index.js'; +import { initMongoDb } from './database.js'; function tryResolve(token: InjectionToken): T | undefined { if (!container.isRegistered(token)) return undefined; @@ -34,14 +35,9 @@ async function main(): Promise { logger.info('initialization...'); - { - logger.info('connecting to mongodb...'); - const client = await MongoClient.connect(config.mongoUrl); - - container.register(Db, { - useValue: client.db(config.dbName), - }); - } + container.register(Db, { + useValue: await initMongoDb(), + }); logger.info('creating koa app...'); diff --git a/src/services/p2d/user.ts b/src/services/p2d/user.ts index 539af98..22282c6 100644 --- a/src/services/p2d/user.ts +++ b/src/services/p2d/user.ts @@ -1,7 +1,7 @@ import { Binary, Db } from "mongodb"; import { inject, injectable } from "tsyringe"; -import { PlayerPlayData, PlayerMusicData, PlayerPlayLog, PlayerCourseLog, PlayerCustomizeSetting } from "../../database/index.js"; -import { Pdata } from "../../database/pdata.js"; +import { PlayerPlayData, PlayerMusicData, PlayerPlayLog, PlayerCourseLog, PlayerCustomizeSetting, PlayerRivalData } from "../../types/index.js"; +import { Pdata } from "../../types/pdata.js"; import { fromKBinXml } from "../../utils/kbinxml.js"; @injectable() @@ -34,6 +34,10 @@ export class UserService { return this.db.collection('player_customize_setting'); } + get rivalDataCol() { + return this.db.collection('player_rival_data'); + } + addCourseLog(coruseLog: PlayerCourseLog) { return this.courseLogCol.insertOne(coruseLog); } @@ -42,6 +46,20 @@ export class UserService { return this.playLogCol.insertOne(playLog); } + getPlayLogs(player: string, skip = 0, limit = 10) { + return this.playLogCol.find({ player }, { + sort: { player: 1, clock: -1 }, + skip, limit, + }).toArray(); + } + + getCourseLogs(player: string, skip = 0, limit = 10) { + return this.courseLogCol.find({ player }, { + sort: { player: 1, _id: -1 }, + skip, limit, + }).toArray(); + } + upsertCustomizeSetting(customizeSetting: PlayerCustomizeSetting) { return this.customizeSettingCol.updateOne({ _id: customizeSetting._id }, { $set: customizeSetting @@ -50,11 +68,25 @@ export class UserService { async getCustomizeSetting(player: string): Promise { const result = await this.customizeSettingCol.findOne({ _id: player }); - if (result) + if (result) { + result.items_count ??= { + bit: 15000, + ldisc: 5, + infinitas_ticket: 50, + infinitas_ticket_free: 9, + }; + return result; + } return { _id: player, + items_count: { + bit: 15000, + ldisc: 5, + infinitas_ticket: 50, + infinitas_ticket_free: 9, + }, customize: [ { item_category: 2, item_id: 'I1100000' }, { item_category: 3, item_id: 'I1200000' }, @@ -90,6 +122,19 @@ export class UserService { return undefined; } + async findPlayerByInfasId(infinitas_id: string): Promise<{ player: string, djname: string } | undefined> { + const result = await this.playDataCol.findOne({ infinitas_id }, { + projection: { + _id: 1, djname: 1, + } + }); + + if (result) + return { player: result._id, djname: result.djname }; + + return undefined; + } + async getPDataBinary(player: string): Promise<{ pdata: Buffer, check_sum: string } | undefined> { const result = await this.playDataCol.findOne({ _id: player }); @@ -106,13 +151,18 @@ export class UserService { const binary = await this.getPDataBinary(player); if (!binary) return undefined; - return fromKBinXml(binary.pdata); + return fromKBinXml(binary.pdata).pdata; } upsertPDataBinary(player: string, pdata: Buffer, check_sum: string) { + const unpacked = fromKBinXml(pdata) as { pdata: Pdata }; + const { djname, infinitas_id } = unpacked.pdata.player; + return this.playDataCol .updateOne({ _id: player }, { $set: { + djname, infinitas_id, + pdata: new Binary(pdata), check_sum, } @@ -125,6 +175,12 @@ export class UserService { }).toArray(); } + getPlayLog(player: string, clock: number): Promise { + return this.playLogCol.findOne({ + player, clock, + }); + } + async getMusicData(player: string, music_id: number, play_style: number): Promise { const result = await this.musicDataCol.findOne({ player, @@ -132,8 +188,10 @@ export class UserService { play_style, }); - if (result) + if (result) { + result.best_score_clock ??= [-1, -1, -1, -1, -1]; return result; + } return { player, @@ -145,6 +203,7 @@ export class UserService { miss_count: [-1, -1, -1, -1, -1], play_num: [0, 0, 0, 0, 0], clear_num: [0, 0, 0, 0, 0], + best_score_clock: [-1, -1, -1, -1, -1], }; } @@ -159,4 +218,32 @@ export class UserService { upsert: true }); } + + async getPlayerRivalData(player: string): Promise { + const result = await this.rivalDataCol.findOne({ _id: player }); + + if (result) { + if (!result.dp) result.dp = []; + if (!result.sp) result.sp = []; + + return result; + } + + return { + _id: player, + enabled: false, + sp: [], + dp: [], + } + } + + upsertPlayerRivalData(rivalData: PlayerRivalData) { + return this.rivalDataCol.updateOne({ + _id: rivalData._id + }, { + $set: rivalData + }, { + upsert: true, + }); + } } diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..66285cc --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,9 @@ +export interface RivalPatch { + enabled: boolean, +}; + +export interface RivalPostOrDelete { + infinitas_id: string; + type: number; +}; + diff --git a/src/database/index.ts b/src/types/index.ts similarity index 82% rename from src/database/index.ts rename to src/types/index.ts index df047f1..6c8b238 100644 --- a/src/database/index.ts +++ b/src/types/index.ts @@ -6,6 +6,16 @@ export interface PlayerPlayData { pdata: Binary; // sha256 to ^ check_sum: string; + + djname: string; + infinitas_id: string; +} + +export interface PlayerRivalData { + _id: string; + enabled: boolean; + sp: string[]; + dp: string[]; } export interface PlayerMusicData { @@ -19,6 +29,9 @@ export interface PlayerMusicData { miss_count: number[]; play_num: number[]; clear_num: number[]; + + // for getting ghost data; + best_score_clock: number[]; } export interface PlayerPlayLog { @@ -49,7 +62,7 @@ export interface PlayerPlayLog { arrange_1: number, assist: number, flip: number, - ghost: Buffer, + ghost: Binary, }; export interface PlayerCourseLog { @@ -79,6 +92,12 @@ export interface CourseResult { export interface PlayerCustomizeSetting { _id: string; + items_count: { + bit: number; + ldisc: number; + infinitas_ticket: number; + infinitas_ticket_free: number; + }, customize: { item_id: string, item_category: number, diff --git a/src/database/pdata.ts b/src/types/pdata.ts similarity index 100% rename from src/database/pdata.ts rename to src/types/pdata.ts diff --git a/src/utils/kbinxml.ts b/src/utils/kbinxml.ts index bd9f48b..ae3a8f9 100644 --- a/src/utils/kbinxml.ts +++ b/src/utils/kbinxml.ts @@ -97,7 +97,7 @@ function toObject(node: Record | unknown[] | string): any { return obj; } -export function fromKBinXml(kbinxml: Buffer) { +export function fromKBinXml(kbinxml: Uint8Array) { const xml = to_xml(kbinxml).data; const parsedXml = parser.parse(xml); return toObject(parsedXml);