import type { Client } from './client' import { type Api, type UserItem, type UserMusicDetail } from './typings/api' import { MusicDifficultyID } from './typings/api/base/MusicDifficultyID' import { PlayComboFlagID } from './typings/api/base/PlayComboFlagID' import { PlaySyncFlagID } from './typings/api/base/PlaySyncFlagID' import { CardItem } from './typings/api/GetUserCardApi' import { UserItemKind } from './typings/api/base/UserItem' import { Playlog } from './typings/api/UploadUserPlaylogListApi' import { LogoutType } from './typings/api/UserLogoutApi' import { toLocalDateString, toLocalDateTimeString } from './utils/date' import { CharacterSet, convertAchievementToScoreRank, ScoreEntry, ScoreSet, ItemSet, unpackPresent, Mission } from './containers' import { AwaitableEventEmitter, AwaitableEventMap } from './utils/event' type RequestAllApi = | 'GetUserItemApi' | 'GetUserCardApi' | 'GetUserCourseApi' | 'GetUserMusicApi' | 'GetUserFavoriteItemApi' | 'GetUserMapApi' | 'GetUserLoginBonusApi' export interface Present { [UserItemKind.Plate]: ItemSet [UserItemKind.Title]: ItemSet [UserItemKind.Icon]: ItemSet [UserItemKind.Music]: ItemSet [UserItemKind.Character]: ItemSet [UserItemKind.Partner]: ItemSet [UserItemKind.Frame]: ItemSet [UserItemKind.Ticket]: ItemSet // NOTE: 若 Present 的 itemId 经解析后指向 UserItemKind.Present, UserItemKind.MusicMas, UserItemKind.MusicRem, UserItemKind.MusicSrg 则会直接忽略此礼物。 } export interface SaveEventMap extends AwaitableEventMap { progress: [ProgressEvent] } export interface SaveProgress { loaded: number total: number } interface CacheMap { root?: { get data(): NonNullable get card(): NonNullable get character(): NonNullable get item(): NonNullable get course(): NonNullable get charge(): NonNullable get favorite(): NonNullable get ghost(): NonNullable get map(): NonNullable get loginBonus(): NonNullable get region(): NonNullable get recommendRateMusic(): NonNullable get recommendSelectMusic(): NonNullable< CacheMap['root.recommendSelectMusic'] > get option(): NonNullable get extend(): NonNullable get rating(): NonNullable get score(): NonNullable get activity(): NonNullable get mission(): NonNullable } 'root.item'?: { get [UserItemKind.Plate](): NonNullable get [UserItemKind.Title](): NonNullable get [UserItemKind.Partner](): NonNullable get [UserItemKind.Icon](): NonNullable get [UserItemKind.Present](): NonNullable get [UserItemKind.Frame](): NonNullable get [UserItemKind.Ticket](): NonNullable get [UserItemKind.Music](): NonNullable get [UserItemKind.MusicMas](): NonNullable get [UserItemKind.MusicRem](): NonNullable // get musicSrg(): NonNullable } 'root.item.present'?: Promise 'root.favorite'?: { get icon(): NonNullable get plate(): NonNullable get title(): NonNullable get character(): NonNullable get frame(): NonNullable get music(): NonNullable get rival(): NonNullable } 'root.data'?: Promise< Api['GetUserDataApi'][1]['userData'] & { banState: number } > 'root.card'?: Promise<{ card: NonNullable serialId: NonNullable }> 'root.map'?: Promise> // TODO: Watch Modification 'root.character'?: Promise 'root.item.plate'?: Promise 'root.item.title'?: Promise 'root.item.partner'?: Promise 'root.item.icon'?: Promise 'root.item.frame'?: Promise 'root.item.ticket'?: Promise 'root.item.music'?: Promise 'root.item.musicMas'?: Promise 'root.item.musicRem'?: Promise // 'root.item.musicSrg'?: Promise< // NonNullable // > 'root.ghost'?: Promise 'root.favorite.icon'?: Promise< Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList'] > 'root.favorite.plate'?: Promise< Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList'] > 'root.favorite.title'?: Promise< Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList'] > 'root.favorite.character'?: Promise< Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList'] > 'root.favorite.frame'?: Promise< Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList'] > 'root.favorite.music'?: Promise< NonNullable > 'root.favorite.rival'?: Promise< NonNullable > 'root.loginBonus'?: Promise< NonNullable > 'root.course'?: Promise< NonNullable > 'root.charge'?: Promise< NonNullable > 'root.score'?: Promise 'root.score.reset'?: () => void 'root.rating'?: Promise 'root.region'?: Promise< NonNullable > 'root.recommendRateMusic'?: Promise< Api['GetUserRecommendRateMusicApi'][1]['userRecommendRateMusicIdList'] > 'root.recommendSelectMusic'?: Promise< Api['GetUserRecommendSelectMusicApi'][1]['userRecommendSelectionMusicIdList'] > 'root.option'?: Promise 'root.extend'?: Promise 'root.activity'?: Promise 'root.mission'?: Promise } export type PlaylogInit = Partial & { musicId: number level: MusicDifficultyID achievement: number tapCriticalPerfect: number tapPerfect: number tapGreat: number tapGood: number tapMiss: number holdCriticalPerfect: number holdPerfect: number holdGreat: number holdGood: number holdMiss: number slideCriticalPerfect: number slidePerfect: number slideGreat: number slideGood: number slideMiss: number touchCriticalPerfect: number touchPerfect: number touchGreat: number touchGood: number touchMiss: number breakCriticalPerfect: number breakPerfect: number breakGreat: number breakGood: number breakMiss: number fastCount: number lateCount: number isCriticalDisp: boolean comboStatus: PlayComboFlagID syncStatus: PlaySyncFlagID maxCombo: number } export function calculateDeluxscore(playlog: PlaylogInit) { const tapDx = playlog.tapCriticalPerfect * 3 + playlog.tapPerfect * 2 + playlog.tapGreat const holdDx = playlog.holdCriticalPerfect * 3 + playlog.holdPerfect * 2 + playlog.holdGreat const slideDx = playlog.slideCriticalPerfect * 3 + playlog.slidePerfect * 2 + playlog.slideGreat const touchDx = playlog.touchCriticalPerfect * 3 + playlog.touchPerfect * 2 + playlog.touchGreat const breakDx = playlog.breakCriticalPerfect * 3 + playlog.breakPerfect * 2 + playlog.breakGreat return tapDx + holdDx + slideDx + touchDx + breakDx } export function splitPresent(data: UserItem[]): Present { const present: Map = new Map([ [UserItemKind.Plate, []], [UserItemKind.Title, []], [UserItemKind.Icon, []], [UserItemKind.Music, []], [UserItemKind.Character, []], [UserItemKind.Partner, []], [UserItemKind.Frame, []], [UserItemKind.Ticket, []] ]) for (const item of data) { if (item.itemKind === UserItemKind.Present && item.isValid) { const [kind, id] = unpackPresent(item.itemId) if (present.has(kind)) { present.get(kind)?.push({ itemKind: kind, itemId: id, stock: item.stock, isValid: item.isValid }) } } } return { [UserItemKind.Plate]: new ItemSet( present.get(UserItemKind.Plate) ?? [], UserItemKind.Plate, true ), [UserItemKind.Title]: new ItemSet( present.get(UserItemKind.Title) ?? [], UserItemKind.Title, true ), [UserItemKind.Icon]: new ItemSet( present.get(UserItemKind.Icon) ?? [], UserItemKind.Icon, true ), [UserItemKind.Music]: new ItemSet( present.get(UserItemKind.Music) ?? [], UserItemKind.Music, true ), [UserItemKind.Character]: new ItemSet( present.get(UserItemKind.Character) ?? [], UserItemKind.Character, true ), [UserItemKind.Partner]: new ItemSet( present.get(UserItemKind.Partner) ?? [], UserItemKind.Partner, true ), [UserItemKind.Frame]: new ItemSet( present.get(UserItemKind.Frame) ?? [], UserItemKind.Frame, true ), [UserItemKind.Ticket]: new ItemSet( present.get(UserItemKind.Ticket) ?? [], UserItemKind.Ticket, true ) } } export class User { private _cache: CacheMap = {} constructor( public id: number, public loginId: bigint | null, public dateTime: Date | null, public client: Client, public isLogin: boolean, public loginDateTime: Date, public token: string = '' ) { } private _useCache( key: T, fn: () => NonNullable ) { if (this._cache[key]) { return this._cache[key] } const result = fn() this._cache[key] = result return result } private _queryCache( key: T ): CacheMap[T] | undefined { return this._cache[key] } get data() { // 被 sbga 大粪代码气笑了 const requestAll = async ( apiName: T, data: Api[T][0] ): Promise => { const result: Api[T][1][] = [] let index = 0n while (true) { const resp = (await this.client.request( apiName, Object.assign({}, data, { nextIndex: index === 0n ? BigInt( (data as { nextIndex?: number | bigint }).nextIndex ?? 0n ) : index }), { userId: this.id } )) as Api[T][1] & { nextIndex: number | bigint } result.push(resp) index = BigInt(resp.nextIndex) if (index === 0n) break } return result } const getItem = async (kind: UserItemKind) => { return ( await requestAll('GetUserItemApi', { userId: this.id, nextIndex: BigInt(kind) * 10000000000n, maxCount: 100 }) ) .map(v => v.userItemList ?? []) .flat() } const getFavorite = async ( kind: number ): Promise => { return ( await this.client.request( 'GetUserFavoriteApi', { userId: this.id, itemKind: kind }, { userId: this.id } ) ).userFavorite.itemIdList } const getFavoriteItem = async (kind: UserItemKind) => { return ( await requestAll('GetUserFavoriteItemApi', { userId: this.id, kind, isAllFavoriteItem: false, nextIndex: 0n, maxCount: 100 }) ) .map(v => v.userFavoriteItemList ?? []) .flat() } /* eslint-disable-next-line @typescript-eslint/no-this-alias */ const self = this return this._useCache('root', () => ({ get data() { return self._useCache('root.data', async () => { const res = await self.client.request( 'GetUserDataApi', { userId: self.id }, { userId: self.id } ) if (res === null) throw new Error('User data is null') const result = { ...res.userData, banState: res.banState } const obj = result as unknown as Record delete obj['friendCode'] delete obj['nameplateId'] delete obj['cmLastEmoneyBrand'] delete obj['trophyId'] delete obj['cmLastEmoneyCredit'] return result }) }, get card() { return self._useCache( 'root.card', async () => ( await requestAll('GetUserCardApi', { userId: self.id, nextIndex: 0, maxCount: 10000 }) ) .map(v => [v.userCardList ?? [], v.serialIdList ?? []] as const) .reduce((previous, current) => { if (Array.isArray(previous)) { return { card: [...previous[0], ...current[0]], serialId: [...previous[1], ...current[1]] } as unknown as readonly [CardItem[], unknown[]] } else { const p = previous as unknown as { card: CardItem[] serialId: unknown[] } p.card.push(...current[0]) p.serialId.push(...current[1]) return previous } }) as unknown as { card: CardItem[] serialId: unknown[] } ) }, get character() { return self._useCache( 'root.character', async () => new CharacterSet( ( await self.client.request( 'GetUserCharacterApi', { userId: self.id }, { userId: self.id } ) ).userCharacterList ?? [] ) // TODO: Character Instance ) }, get item() { return self._useCache('root.item', () => ({ get [UserItemKind.Plate]() { return self._useCache( 'root.item.plate', async () => new ItemSet( await getItem(UserItemKind.Plate), UserItemKind.Plate, false ) ) }, get [UserItemKind.Title]() { return self._useCache( 'root.item.title', async () => new ItemSet( await getItem(UserItemKind.Title), UserItemKind.Title, false ) ) }, get [UserItemKind.Partner]() { return self._useCache( 'root.item.partner', async () => new ItemSet( await getItem(UserItemKind.Partner), UserItemKind.Partner, false ) ) }, get [UserItemKind.Icon]() { return self._useCache( 'root.item.icon', async () => new ItemSet( await getItem(UserItemKind.Icon), UserItemKind.Icon, false ) ) }, get [UserItemKind.Present]() { return self._useCache('root.item.present', async () => splitPresent(await getItem(UserItemKind.Present)) ) }, get [UserItemKind.Frame]() { return self._useCache( 'root.item.frame', async () => new ItemSet( await getItem(UserItemKind.Frame), UserItemKind.Frame, false ) ) }, get [UserItemKind.Ticket]() { return self._useCache( 'root.item.ticket', async () => new ItemSet( await getItem(UserItemKind.Ticket), UserItemKind.Ticket, false ) ) }, get [UserItemKind.Music]() { return self._useCache( 'root.item.music', async () => new ItemSet( await getItem(UserItemKind.Music), UserItemKind.Music, false ) ) }, get [UserItemKind.MusicMas]() { return self._useCache( 'root.item.musicMas', async () => new ItemSet( await getItem(UserItemKind.MusicMas), UserItemKind.MusicMas, false ) ) }, get [UserItemKind.MusicRem]() { return self._useCache( 'root.item.musicRem', async () => new ItemSet( await getItem(UserItemKind.MusicRem), UserItemKind.MusicRem, false ) ) } // get musicSrg() { // return self._useCache('root.item.musicSrg', () => // getItem(UserItemKind.MusicSrg) // ) // }, })) }, get course() { return self._useCache('root.course', async () => ( await requestAll('GetUserCourseApi', { userId: self.id, nextIndex: 0n }) ) .map(v => v.userCourseList ?? []) .flat() ) }, get charge() { return self._useCache( 'root.charge', async () => ( await self.client.request( 'GetUserChargeApi', { userId: self.id }, { userId: self.id } ) ).userChargeList ?? [] ) }, get favorite() { return self._useCache('root.favorite', () => ({ get icon() { return self._useCache('root.favorite.icon', () => getFavorite(1)) }, get plate() { return self._useCache('root.favorite.plate', () => getFavorite(2)) }, get title() { return self._useCache('root.favorite.title', () => getFavorite(3)) }, get character() { return self._useCache('root.favorite.character', () => getFavorite(4) ) }, get frame() { return self._useCache('root.favorite.frame', () => getFavorite(5)) }, get music() { return self._useCache('root.favorite.music', () => getFavoriteItem(1) ) }, get rival() { return self._useCache('root.favorite.rival', () => getFavoriteItem(2) ) } })) }, get ghost() { return self._useCache( 'root.ghost', async () => ( await self.client.request( 'GetUserGhostApi', { userId: self.id }, { userId: self.id } ) ).userGhostList // TODO: Ghost Instance ) }, get map() { return self._useCache('root.map', async () => ( await requestAll('GetUserMapApi', { userId: self.id, nextIndex: 0n, maxCount: 10000 }) ) .map(v => v.userMapList ?? []) .flat() ) }, get loginBonus() { return self._useCache('root.loginBonus', async () => ( await requestAll('GetUserLoginBonusApi', { userId: self.id, nextIndex: 0n, maxCount: 10000 }) ) .map(v => v.userLoginBonusList ?? []) .flat() ) }, get region() { return self._useCache( 'root.region', async () => ( await self.client.request( 'GetUserRegionApi', { userId: self.id }, { userId: self.id } ) ).userRegionList ?? [] ) }, get recommendRateMusic() { return self._useCache( 'root.recommendRateMusic', async () => ( await self.client.request( 'GetUserRecommendRateMusicApi', { userId: self.id }, { userId: self.id } ) ).userRecommendRateMusicIdList ) }, get recommendSelectMusic() { return self._useCache( 'root.recommendSelectMusic', async () => ( await self.client.request( 'GetUserRecommendSelectMusicApi', { userId: self.id }, { userId: self.id } ) ).userRecommendSelectionMusicIdList ) }, get option() { return self._useCache('root.option', async () => { const result = ( await self.client.request( 'GetUserOptionApi', { userId: self.id }, { userId: self.id } ) ).userOption const obj = result as unknown as Record delete obj['tempoVolume'] return result }) }, get extend() { return self._useCache( 'root.extend', async () => ( await self.client.request( 'GetUserExtendApi', { userId: self.id }, { userId: self.id } ) ).userExtend ) }, get rating() { return self._useCache('root.rating', async () => { const result = ( await self.client.request( 'GetUserRatingApi', { userId: self.id }, { userId: self.id } ) ).userRating const obj = result.udemae as unknown as Record delete obj['MaxLoseNum'] delete obj['NpcLoseNum'] delete obj['NpcMaxLoseNum'] delete obj['NpcMaxWinNum'] delete obj['NpcTotalLoseNum'] delete obj['NpcTotalWinNum'] delete obj['NpcWinNum'] return result }) }, get score() { return self._useCache('root.score', async () => { const res = ScoreSet.resettable( ( await requestAll('GetUserMusicApi', { userId: self.id, nextIndex: 0n, maxCount: 10000 }) ) .map(v => v.userMusicList ?? []) .flat() .map(v => v.userMusicDetailList ?? []) .flat() .map(v => { const obj = v as unknown as Record delete obj['extNum2'] return v }) ) self._cache['root.score.reset'] = res[1] return res[0] }) }, get activity() { return self._useCache( 'root.activity', async () => ( await self.client.request( 'GetUserActivityApi', { userId: self.id }, { userId: self.id } ) ).userActivity ?? {} ) }, get mission() { return self._useCache('root.mission', async () => { const result = await self.client.request( 'GetUserMissionDataApi', { userId: self.id }, { userId: self.id } ) return new Mission(result.userWeeklyData, result.userMissionDataList) }) } })) } async [Symbol.asyncDispose]() { if (this.isLogin) { await this.logout() } } async refresh() { if (this.isLogin) { await this.logout() } const newInstance = await this.client.login(this.id, this.token, { isContinue: true }) this.isLogin = newInstance.isLogin this.loginId = newInstance.loginId this.token = newInstance.token this.loginDateTime = newInstance.loginDateTime } async charge(options: { kind: number stock: number price: number purchaseDate?: Date validDate?: Date }) { options.purchaseDate = options.purchaseDate ?? new Date() // validDate 默认为 purchaseDate + 90 天的凌晨4点 options.validDate = options.validDate ?? new Date( options.purchaseDate.getFullYear(), options.purchaseDate.getMonth(), options.purchaseDate.getDate() + 90, 4, 0, 0, 0 ) await this.client.request( 'UpsertUserChargelogApi', { userId: this.id, userCharge: { chargeId: options.kind, stock: options.stock, purchaseDate: toLocalDateTimeString(options.purchaseDate), validDate: toLocalDateTimeString(options.validDate) }, userChargelog: { chargeId: options.kind, clientId: this.client.shortChipId, regionId: this.client.place.region.id, placeId: this.client.place.id, price: options.price, purchaseDate: toLocalDateTimeString(options.purchaseDate) } }, { userId: this.id } ) } async logout(type = LogoutType.Logout) { if (!this.isLogin) { throw new Error('Unable to logout: User already logged out') } if (!this.loginDateTime) { throw new Error('Unable to logout: loginDateTime is null') } this.isLogin = false const timestamp = Math.floor(this.loginDateTime.getTime() / 1000) await this.client.request( 'UserLogoutApi', { userId: this.id, accessCode: '', regionId: this.client.place.region.id, placeId: this.client.place.id, clientId: this.client.shortChipId, loginDateTime: timestamp, type }, { userId: this.id } ) } async update(options: { romVersion: string dataVersion: string }): Promise { const data = await this.data.data data.lastRomVersion = options.romVersion data.lastDataVersion = options.dataVersion } /** * Saves the current user's state including play log, user data, character information, * and various game details to the backend. * * This asynchronous function aggregates new and existing play log data, generates additional * play logs if necessary, and prepares comprehensive user data by merging character details, * item lists, mission data, and various other game state information. * * The function performs the following main steps: * - Verifies that the user is logged in; throws an error if not. * - Generates random numbers and special values used in play logs. * - Constructs play log entries by merging pre-existing log entries with newly generated ones. * - Fetches and validates the user's selected characters, ensuring that necessary character * information is retrieved or defaulted. * - Aggregates and organizes several parts of the user data, including scores, achievements, * and other related information. * - Sequentially uploads each play log entry and then sends the complete user state update * to the backend via client API requests. * * @param options - An optional object containing: * - playlog: An array of play log entries (of types Playlog or PlaylogInit) to be used in the save. * - eventMode: A boolean indicating if the event mode should be enabled. * - freePlay: A boolean indicating if the free play mode should be enabled. * * @throws {Error} Throws if the user is not logged in correctly or if character information is * missing or invalid. * * @returns A Promise that resolves when the save process (including all API requests) has completed. */ save(options?: { playlog?: (Playlog | PlaylogInit)[] eventMode?: boolean freePlay?: boolean }): AwaitableEventEmitter { const emitter = AwaitableEventEmitter.fromAsync(async () => { const [ data, character, score, option, extend, charge, mission, activity, rating ] = await Promise.all([ this.data.data, this.data.character, this.data.score, this.data.option, this.data.extend, this.data.charge, this.data.mission, this.data.activity, this.data.rating ]) const generatePlaySpecial = () => { // 生成一个 1 至 1037933 的随机整数 let num = (Math.floor(Math.random() * 1037933) + 1) * 2069 num += 1024 // += GameManager.CalcSpecialNum(); let num2 = 0 for (let i = 0; i < 32; i++) { num2 <<= 1 num2 += num % 2 num >>= 1 } return num2 } const generatePlaylog = (detail: UserMusicDetail) => { return { level: detail.level, achievement: 0, musicId: detail.musicId, tapCriticalPerfect: 0, tapPerfect: 0, tapGreat: 0, tapGood: 0, tapMiss: 1, holdCriticalPerfect: 0, holdPerfect: 0, holdGreat: 0, holdGood: 0, holdMiss: 0, slideCriticalPerfect: 0, slidePerfect: 0, slideGreat: 0, slideGood: 0, slideMiss: 0, touchCriticalPerfect: 0, touchPerfect: 0, touchGreat: 0, touchGood: 0, touchMiss: 0, breakCriticalPerfect: 0, breakPerfect: 0, breakGreat: 0, breakGood: 0, breakMiss: 0, fastCount: 0, lateCount: 0, isCriticalDisp: false, comboStatus: PlayComboFlagID.None, syncStatus: PlaySyncFlagID.None, maxCombo: 0, totalCombo: 0, maxSync: 0, totalSync: 0 } satisfies PlaylogInit } interface CharacterData { characterId1: number characterAwakening1: number characterLevel1: number characterId2: number characterAwakening2: number characterLevel2: number characterId3: number characterAwakening3: number characterLevel3: number characterId4: number characterAwakening4: number characterLevel4: number characterId5: number characterAwakening5: number characterLevel5: number } const convertVersionNumber = (version: string): number => { // 1.41.00 -> 1041000 const parts = version.split('.') if (parts.length !== 3) { throw new Error(`Invalid version format: ${version}`) } const major = parseInt(parts[0], 10) const minor = parseInt(parts[1], 10) const patch = parseInt(parts[2], 10) if (isNaN(major) || isNaN(minor) || isNaN(patch)) { throw new Error(`Invalid version number: ${version}`) } return major * 1000000 + minor * 1000 + patch } const getUserSelectedCharacter = ( option: Partial ): CharacterData => { const raiseException = (message: string) => { throw new Error(message) } const final: CharacterData = { characterId1: 0, characterAwakening1: 0, characterLevel1: 0, characterId2: 0, characterAwakening2: 0, characterLevel2: 0, characterId3: 0, characterAwakening3: 0, characterLevel3: 0, characterId4: 0, characterAwakening4: 0, characterLevel4: 0, characterId5: 0, characterAwakening5: 0, characterLevel5: 0 } for (let i = 0; i < 5; i++) { const originalId = option[ `characterId${i + 1}` as keyof CharacterData ] as number | undefined const originalAwakening = option[ `characterAwakening${i + 1}` as keyof CharacterData ] as number | undefined const originalLevel = option[ `characterLevel${i + 1}` as keyof CharacterData ] as number | undefined if (originalId !== undefined) { Object.assign(final, { [`characterId${i + 1}` as keyof CharacterData]: originalId, [`characterAwakening${i + 1}` as keyof CharacterData]: originalAwakening ?? character.get(originalId)?.awakening ?? raiseException(`Character ${i + 1} not found`), [`characterLevel${i + 1}` as keyof CharacterData]: originalLevel ?? character.get(originalId)?.level ?? raiseException(`Character ${i + 1} not found`) }) } else if ( originalLevel !== undefined || originalAwakening !== undefined ) { throw new Error( `Character ${i + 1} ID is required if level or awakening is set` ) } else { const fill = character.get(data.charaSlot[i]) if (!fill) throw new Error(`Character ${i + 1} not found`) Object.assign(final, { [`characterId${i + 1}` as keyof CharacterData]: data.charaSlot[i], [`characterAwakening${i + 1}` as keyof CharacterData]: fill.awakening, [`characterLevel${i + 1}` as keyof CharacterData]: fill.level }) } } return final } if (!this.loginId || !this.dateTime) throw new Error('Not logged in correctly') // TODO: type hint let playlog: (Playlog | PlaylogInit)[] = options?.playlog ?? [] const musicDetail = score.export() const generatedPlaylog = musicDetail .map(v => generatePlaylog(v.value)) .filter(v => { return !playlog.some( v2 => v2.level === v.level && v2.musicId === v.musicId ) }) satisfies PlaylogInit[] playlog = playlog.concat(generatedPlaylog) if (playlog.length === 0) { const ids = Array.from(score.keys()) const randomId = ids[Math.floor(Math.random() * ids.length)] const entry = Object.entries(score.get(randomId)).find(([, v]) => { return !!v }) if (!entry) throw new Error('At least one playlog is required for new user') const level = Number(entry[0]) as MusicDifficultyID const detail = entry[1] as ScoreEntry const generated: UserMusicDetail = { musicId: randomId, achievement: detail.achievement, comboStatus: detail.comboStatus, syncStatus: detail.syncStatus, deluxscoreMax: detail.deluxscoreMax, level, playCount: detail.playCount, scoreRank: detail.scoreRank, extNum1: detail.extNum1 } musicDetail.push({ value: generated, isNew: false }) playlog.push(generatePlaylog(generated)) } const playlogList: Playlog[] = await Promise.all( Array.from(playlog.entries()).map( async ([index, log]) => Object.assign( {}, { userId: 0, orderId: 0, playlogId: this.loginId, version: convertVersionNumber(data.lastRomVersion), placeId: this.client.place.id, placeName: this.client.place.name, loginDate: Math.floor(this.dateTime!.getTime() / 1000), playDate: '', userPlayDate: '', type: 0, trackNo: (index % 4) + 1, vsMode: 0, vsStatus: 0, vsUserName: '', vsUserRating: 0, vsUserAchievement: 0, vsUserGradeRank: 0, vsRank: 0, playerNum: 1, playedUserId1: 0, playedUserName1: '', playedMusicLevel1: MusicDifficultyID.Basic, playedUserId2: 0, playedUserName2: '', playedMusicLevel2: MusicDifficultyID.Basic, playedUserId3: 0, playedUserName3: '', playedMusicLevel3: MusicDifficultyID.Basic, ...getUserSelectedCharacter(log), scoreRank: convertAchievementToScoreRank( log.achievement, log.achievement > 1010000 && log.level === MusicDifficultyID.Utage ), // TODO: utage isTap: log.tapCriticalPerfect + log.tapPerfect + log.tapGreat + log.tapGood + log.tapMiss > 0, isHold: log.holdCriticalPerfect + log.holdPerfect + log.holdGreat + log.holdGood + log.holdMiss > 0, isSlide: log.slideCriticalPerfect + log.slidePerfect + log.slideGreat + log.slideGood + log.slideMiss > 0, isTouch: log.touchCriticalPerfect + log.touchPerfect + log.touchGreat + log.touchGood + log.touchMiss > 0, isBreak: log.breakCriticalPerfect + log.breakPerfect + log.breakGreat + log.breakGood + log.breakMiss > 0, isFastLateDisp: true, deluxscore: calculateDeluxscore(log as PlaylogInit), isAchieveNewRecord: false, isDeluxscoreNewRecord: false, isClear: log.achievement >= 500000, totalCombo: log.tapCriticalPerfect + log.tapPerfect + log.tapGreat + log.tapGood + log.tapMiss + (log.holdCriticalPerfect + log.holdPerfect + log.holdGreat + log.holdGood + log.holdMiss) + (log.slideCriticalPerfect + log.slidePerfect + log.slideGreat + log.slideGood + log.slideMiss) + (log.touchCriticalPerfect + log.touchPerfect + log.touchGreat + log.touchGood + log.touchMiss) + (log.breakCriticalPerfect + log.breakPerfect + log.breakGreat + log.breakGood + log.breakMiss), maxSync: log.tapCriticalPerfect + log.tapPerfect + log.tapGreat + log.tapGood + log.tapMiss + (log.holdCriticalPerfect + log.holdPerfect + log.holdGreat + log.holdGood + log.holdMiss) + (log.slideCriticalPerfect + log.slidePerfect + log.slideGreat + log.slideGood + log.slideMiss) + (log.touchCriticalPerfect + log.touchPerfect + log.touchGreat + log.touchGood + log.touchMiss) + (log.breakCriticalPerfect + log.breakPerfect + log.breakGreat + log.breakGood + log.breakMiss), totalSync: 0, beforeRating: log.beforeRating ?? data.playerRating, afterRating: log.afterRating ?? data.playerRating, beforeGrade: log.beforeGrade ?? data.gradeRating, afterGrade: log.afterGrade ?? data.gradeRating, afterGradeRank: log.afterGradeRank ?? data.gradeRank, beforeDeluxRating: log.beforeDeluxRating ?? data.playerRating, afterDeluxRating: log.afterDeluxRating ?? data.playerRating, isPlayTutorial: false, isEventMode: false, isFreedomMode: false, playMode: 0, isNewFree: false, trialPlayAchievement: -1, extNum1: 0, extNum2: 0, extNum4: 0, extBool1: log.achievement > 1010000 && log.level === MusicDifficultyID.Utage, // NOTE: This is a workaround for Buddy charts (バディ譜面) extBool2: false }, log ) satisfies Playlog ) ) const groupedPlaylogList = [] const groupedMusicDetailList = [] for (let i = 0; i < playlogList.length; i += 4) { groupedPlaylogList.push(playlogList.slice(i, i + 4)) groupedMusicDetailList.push(musicDetail.slice(i, i + 4)) } let lastPlayDate = new Date() // let lastPlayDate = new Date( // fromLocalDateTimeString(data.lastPlayDate).getTime() + 1000 // ) for (let [index, group] of groupedPlaylogList.entries()) { if (index > 0) { const currentDate = new Date() if (currentDate.getTime() - lastPlayDate.getTime() < 1000) { await new Promise(resolve => setTimeout( resolve, 1100 - (currentDate.getTime() - lastPlayDate.getTime()) ) ) } await this.refresh() lastPlayDate = new Date() } group = group.map(v => Object.assign({}, v, { playlogId: this.loginId, playDate: toLocalDateString(lastPlayDate), userPlayDate: toLocalDateTimeString(lastPlayDate, true) }) ) if (index === 0) { await new Promise(resolve => setTimeout(resolve, 70000)) await this.client.request('UpsertUserAllApi', { userId: this.id, playlogId: this.loginId, isEventMode: !!(options?.eventMode ?? false), isFreePlay: !!(options?.freePlay ?? false), loginDateTime: Math.floor(this.loginDateTime.getTime() / 1000), userPlaylogList: group, upsertUserAll: { userData: [ Object.assign({}, data, { dateTime: Math.floor(this.dateTime.getTime() / 1000), lastGameId: this.client.codename, lastPlaceId: this.client.place.id, lastPlaceName: this.client.place.name, lastRegionId: this.client.place.region.id, lastRegionName: this.client.place.region.name[0], lastClientId: this.client.shortChipId, accessCode: '', lastLoginDate: toLocalDateTimeString(this.dateTime, true), // TODO: compare lastPlayDate: toLocalDateTimeString(lastPlayDate, true) } satisfies Partial< Api['UpsertUserAllApi'][0]['upsertUserAll']['userData'][0] >) satisfies Api['UpsertUserAllApi'][0]['upsertUserAll']['userData'][0] // data ], userExtend: [extend], userOption: [option], ...(await (async () => { const character = (await this._queryCache('root.character'))?.export() ?? [] return { userCharacterList: character.map(v => v.value), isNewCharacterList: character .map(v => (v.isNew ? '1' : '0')) .join('') } })()), ...(await (async () => { const itemList = [ ...((await this._queryCache('root.item.plate'))?.export() ?? []), ...((await this._queryCache('root.item.title'))?.export() ?? []), ...((await this._queryCache('root.item.partner'))?.export() ?? []), ...((await this._queryCache('root.item.icon'))?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Plate ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Title ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Icon ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Music ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Character ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Partner ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Frame ]?.export() ?? []), ...((await this._queryCache('root.item.present'))?.[ UserItemKind.Ticket ]?.export() ?? []), ...((await this._queryCache('root.item.frame'))?.export() ?? []), ...((await this._queryCache('root.item.ticket'))?.export() ?? []), ...((await this._queryCache('root.item.music'))?.export() ?? []), ...(( await this._queryCache('root.item.musicMas') )?.export() ?? []), ...(( await this._queryCache('root.item.musicRem') )?.export() ?? []) ] return { userItemList: itemList.map(v => v.value), isNewItemList: itemList .map(v => (v.isNew ? '1' : '0')) .join('') } })()), userGhost: [], // TODO: lazy update userMapList: [], // TODO: lazy update userLoginBonusList: [], // TODO: lazy update userRatingList: [rating], ...(() => { return { userMusicDetailList: groupedMusicDetailList[index].map( v => v.value ), isNewMusicDetailList: groupedMusicDetailList[index] .map(v => (v.isNew ? '1' : '0')) .join('') } })(), userCourseList: [], // await this.data.course, // TODO: lazy update userFavoriteList: [], // TODO: lazy update userFriendSeasonRankingList: [], // TODO: lazy update userWeeklyData: mission.weekly, userMissionDataList: mission.mission, userIntimateList: [], // TODO: lazy update isNewUserIntimateList: '', userKaleidxScopeList: [], // TODO: lazy update isNewKaleidxScopeList: '', userShopItemStockList: [], // sega 程序员没有设置,故意的还是不小心的? userGetPointList: [], // TODO: lazy update userTradeItemList: [], // TODO: lazy update userFavoritemusicList: [], // TODO: lazy update isNewFavoritemusicList: '', userChargeList: charge.map(v => ({ chargeId: v.chargeId, stock: v.stock, purchaseDate: v.purchaseDate, validDate: v.validDate })), userActivityList: [activity], // TODO: slice activity data & activity container userGamePlaylogList: [ { playlogId: this.loginId, version: data.lastRomVersion, playDate: toLocalDateTimeString(lastPlayDate, true), playMode: 0, useTicketId: -1, playCredit: options?.freePlay ? 0 : 1, playTrack: group.length, clientId: this.client.shortChipId, isPlayTutorial: false, isEventMode: !!(options?.eventMode ?? false), isNewFree: false, playCount: 0, playSpecial: generatePlaySpecial(), playOtherUserId: 0 } ], user2pPlaylog: { userId1: 0, userId2: 0, userName1: '', userName2: '', regionId: 0, placeId: 0, user2pPlaylogDetailList: [] }, // 对应的槽位变更: 0, 对应的槽位追加: 1 // TODO: 根据变更计算字段 isNewMapList: '', isNewLoginBonusList: '', isNewCourseList: '', isNewFavoriteList: '', isNewFriendSeasonRankingList: '' } } satisfies Api['UpsertUserAllApi'][0], { userId: this.id }) } else { await new Promise(resolve => setTimeout(resolve, 70000)) await this.client.request('UpsertUserAllApi', { userId: this.id, playlogId: this.loginId, isEventMode: !!(options?.eventMode ?? false), isFreePlay: !!(options?.freePlay ?? false), loginDateTime: Math.floor(this.loginDateTime.getTime() / 1000), userPlaylogList: group, upsertUserAll: { userData: [ Object.assign({}, data, { dateTime: Math.floor(this.dateTime.getTime() / 1000), lastGameId: this.client.codename, lastPlaceId: this.client.place.id, lastPlaceName: this.client.place.name, lastRegionId: this.client.place.region.id, lastRegionName: this.client.place.region.name[0], lastClientId: this.client.shortChipId, accessCode: '', lastLoginDate: toLocalDateTimeString(this.dateTime, true), lastPlayDate: toLocalDateTimeString(lastPlayDate, true) } satisfies Partial< Api['UpsertUserAllApi'][0]['upsertUserAll']['userData'][0] >) satisfies Api['UpsertUserAllApi'][0]['upsertUserAll']['userData'][0] // data ], userExtend: [extend], userOption: [option], userCharacterList: [], isNewCharacterList: '', userItemList: [], isNewItemList: '', userGhost: [], userMapList: [], userLoginBonusList: [], userRatingList: [rating], ...(() => { return { userMusicDetailList: groupedMusicDetailList[index].map( v => v.value ), isNewMusicDetailList: groupedMusicDetailList[index] .map(v => (v.isNew ? '1' : '0')) .join('') } })(), userCourseList: [], userFavoriteList: [], userFriendSeasonRankingList: [], userWeeklyData: mission.weekly, userMissionDataList: mission.mission, userIntimateList: [], isNewUserIntimateList: '', userKaleidxScopeList: [], isNewKaleidxScopeList: '', userShopItemStockList: [], userGetPointList: [], userTradeItemList: [], userFavoritemusicList: [], isNewFavoritemusicList: '', userChargeList: charge.map(v => ({ chargeId: v.chargeId, stock: v.stock, purchaseDate: v.purchaseDate, validDate: v.validDate })), userActivityList: [activity], userGamePlaylogList: [ { playlogId: this.loginId, version: data.lastRomVersion, playDate: toLocalDateTimeString(lastPlayDate, true), playMode: 0, useTicketId: -1, playCredit: options?.freePlay ? 0 : 1, playTrack: group.length, clientId: this.client.shortChipId, isPlayTutorial: false, isEventMode: !!(options?.eventMode ?? false), isNewFree: false, playCount: 0, playSpecial: generatePlaySpecial(), playOtherUserId: 0 } ], user2pPlaylog: { userId1: 0, userId2: 0, userName1: '', userName2: '', regionId: 0, placeId: 0, user2pPlaylogDetailList: [] }, isNewMapList: '', isNewLoginBonusList: '', isNewCourseList: '', isNewFavoriteList: '', isNewFriendSeasonRankingList: '' } } satisfies Api['UpsertUserAllApi'][0], { userId: this.id }) } emitter.emit('progress', { loaded: index + 1, total: groupedPlaylogList.length }) } this._cache['root.score.reset']?.() }) return emitter } }