Files
Lionheart/src/user.ts
2026-04-09 18:57:44 +08:00

1649 lines
55 KiB
TypeScript

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<void> {
progress: [ProgressEvent]
}
export interface SaveProgress {
loaded: number
total: number
}
interface CacheMap {
root?: {
get data(): NonNullable<CacheMap['root.data']>
get card(): NonNullable<CacheMap['root.card']>
get character(): NonNullable<CacheMap['root.character']>
get item(): NonNullable<CacheMap['root.item']>
get course(): NonNullable<CacheMap['root.course']>
get charge(): NonNullable<CacheMap['root.charge']>
get favorite(): NonNullable<CacheMap['root.favorite']>
get ghost(): NonNullable<CacheMap['root.ghost']>
get map(): NonNullable<CacheMap['root.map']>
get loginBonus(): NonNullable<CacheMap['root.loginBonus']>
get region(): NonNullable<CacheMap['root.region']>
get recommendRateMusic(): NonNullable<CacheMap['root.recommendRateMusic']>
get recommendSelectMusic(): NonNullable<
CacheMap['root.recommendSelectMusic']
>
get option(): NonNullable<CacheMap['root.option']>
get extend(): NonNullable<CacheMap['root.extend']>
get rating(): NonNullable<CacheMap['root.rating']>
get score(): NonNullable<CacheMap['root.score']>
get activity(): NonNullable<CacheMap['root.activity']>
get mission(): NonNullable<CacheMap['root.mission']>
}
'root.item'?: {
get [UserItemKind.Plate](): NonNullable<CacheMap['root.item.plate']>
get [UserItemKind.Title](): NonNullable<CacheMap['root.item.title']>
get [UserItemKind.Partner](): NonNullable<CacheMap['root.item.partner']>
get [UserItemKind.Icon](): NonNullable<CacheMap['root.item.icon']>
get [UserItemKind.Present](): NonNullable<CacheMap['root.item.present']>
get [UserItemKind.Frame](): NonNullable<CacheMap['root.item.frame']>
get [UserItemKind.Ticket](): NonNullable<CacheMap['root.item.ticket']>
get [UserItemKind.Music](): NonNullable<CacheMap['root.item.music']>
get [UserItemKind.MusicMas](): NonNullable<CacheMap['root.item.musicMas']>
get [UserItemKind.MusicRem](): NonNullable<CacheMap['root.item.musicRem']>
// get musicSrg(): NonNullable<CacheMap['root.item.musicSrg']>
}
'root.item.present'?: Promise<Present>
'root.favorite'?: {
get icon(): NonNullable<CacheMap['root.favorite.icon']>
get plate(): NonNullable<CacheMap['root.favorite.plate']>
get title(): NonNullable<CacheMap['root.favorite.title']>
get character(): NonNullable<CacheMap['root.favorite.character']>
get frame(): NonNullable<CacheMap['root.favorite.frame']>
get music(): NonNullable<CacheMap['root.favorite.music']>
get rival(): NonNullable<CacheMap['root.favorite.rival']>
}
'root.data'?: Promise<
Api['GetUserDataApi'][1]['userData'] & { banState: number }
>
'root.card'?: Promise<{
card: NonNullable<Api['GetUserCardApi'][1]['userCardList']>
serialId: NonNullable<Api['GetUserCardApi'][1]['serialIdList']>
}>
'root.map'?: Promise<NonNullable<Api['GetUserMapApi'][1]['userMapList']>>
// TODO: Watch Modification
'root.character'?: Promise<CharacterSet>
'root.item.plate'?: Promise<ItemSet>
'root.item.title'?: Promise<ItemSet>
'root.item.partner'?: Promise<ItemSet>
'root.item.icon'?: Promise<ItemSet>
'root.item.frame'?: Promise<ItemSet>
'root.item.ticket'?: Promise<ItemSet>
'root.item.music'?: Promise<ItemSet>
'root.item.musicMas'?: Promise<ItemSet>
'root.item.musicRem'?: Promise<ItemSet>
// 'root.item.musicSrg'?: Promise<
// NonNullable<Api['GetUserItemApi'][1]['userItemList']>
// >
'root.ghost'?: Promise<Api['GetUserGhostApi'][1]['userGhostList']>
'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<Api['GetUserFavoriteItemApi'][1]['userFavoriteItemList']>
>
'root.favorite.rival'?: Promise<
NonNullable<Api['GetUserFavoriteItemApi'][1]['userFavoriteItemList']>
>
'root.loginBonus'?: Promise<
NonNullable<Api['GetUserLoginBonusApi'][1]['userLoginBonusList']>
>
'root.course'?: Promise<
NonNullable<Api['GetUserCourseApi'][1]['userCourseList']>
>
'root.charge'?: Promise<
NonNullable<Api['GetUserChargeApi'][1]['userChargeList']>
>
'root.score'?: Promise<ScoreSet>
'root.score.reset'?: () => void
'root.rating'?: Promise<Api['GetUserRatingApi'][1]['userRating']>
'root.region'?: Promise<
NonNullable<Api['GetUserRegionApi'][1]['userRegionList']>
>
'root.recommendRateMusic'?: Promise<
Api['GetUserRecommendRateMusicApi'][1]['userRecommendRateMusicIdList']
>
'root.recommendSelectMusic'?: Promise<
Api['GetUserRecommendSelectMusicApi'][1]['userRecommendSelectionMusicIdList']
>
'root.option'?: Promise<Api['GetUserOptionApi'][1]['userOption']>
'root.extend'?: Promise<Api['GetUserExtendApi'][1]['userExtend']>
'root.activity'?: Promise<Api['GetUserActivityApi'][1]['userActivity']>
'root.mission'?: Promise<Mission>
}
export type PlaylogInit = Partial<Playlog> & {
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<UserItemKind, UserItem[]> = 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<T extends keyof CacheMap>(
key: T,
fn: () => NonNullable<CacheMap[T]>
) {
if (this._cache[key]) {
return this._cache[key]
}
const result = fn()
this._cache[key] = result
return result
}
private _queryCache<T extends keyof CacheMap>(
key: T
): CacheMap[T] | undefined {
return this._cache[key]
}
get data() {
// 被 sbga 大粪代码气笑了
const requestAll = async <T extends keyof Api & RequestAllApi>(
apiName: T,
data: Api[T][0]
): Promise<Api[T][1][]> => {
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<Api['GetUserFavoriteApi'][1]['userFavorite']['itemIdList']> => {
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<void> {
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<void, SaveEventMap> {
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>
): 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
}
}