mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-09-22 22:27:56 +03:00
Merge pull request #89 from Avimitin/dance-around-support
Add DANCE aROUND game service
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
# DANCE aROUND
|
||||||
|
|
||||||
|
DANCE aROUND (`UDN`) plugin for Asphyxia CORE.
|
||||||
|
|
||||||
|
Drag the `dancearound@asphyxia` folder into CORE's `plugins` folder. Set the
|
||||||
|
local game path in the plugin settings to load and unlock the installed music
|
||||||
|
catalog.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/// <reference lib="es2020.bigint" />
|
||||||
|
|
||||||
|
import { nowMs, readNumber, readString } from '../utils';
|
||||||
|
import { loadLocalMdb, MdbMusic } from '../mdb';
|
||||||
|
|
||||||
|
function mdbMusicResponse(music: MdbMusic): any {
|
||||||
|
return {
|
||||||
|
music_id: K.ITEM('s32', music.music_id),
|
||||||
|
title_name: K.ITEM('str', music.title_name),
|
||||||
|
title_yomigana: K.ITEM('str', music.title_yomigana),
|
||||||
|
artist_name: K.ITEM('str', music.artist_name),
|
||||||
|
artist_yomigana: K.ITEM('str', music.artist_yomigana),
|
||||||
|
bpm_max: K.ITEM('s32', music.bpm_max),
|
||||||
|
bpm_min: K.ITEM('s32', music.bpm_min),
|
||||||
|
distribution_date: K.ITEM('str', music.distribution_date),
|
||||||
|
release_code: K.ITEM('str', music.release_code),
|
||||||
|
volume: K.ITEM('s32', music.volume),
|
||||||
|
bg_no: K.ITEM('s32', music.bg_no),
|
||||||
|
region: K.ITEM('s32', music.region),
|
||||||
|
tags: {
|
||||||
|
tag: music.tags.map(tag => K.ITEM('str', tag)),
|
||||||
|
},
|
||||||
|
limitation_type: K.ITEM('s32', music.limitation_type),
|
||||||
|
license: K.ITEM('str', music.license),
|
||||||
|
color1: K.ITEM('str', music.color1),
|
||||||
|
color2: K.ITEM('str', music.color2),
|
||||||
|
color3: K.ITEM('str', music.color3),
|
||||||
|
has_mv: K.ITEM('bool', music.has_mv),
|
||||||
|
demo_pri: K.ITEM('s32', music.demo_pri),
|
||||||
|
fumens: {
|
||||||
|
fumen: music.fumens.map(fumen => ({
|
||||||
|
fumen_type: K.ITEM('str', fumen.fumen_type),
|
||||||
|
level: K.ITEM('s32', fumen.level),
|
||||||
|
playable: K.ITEM('s32', fumen.playable),
|
||||||
|
has_official_dance: K.ITEM('s32', fumen.has_official_dance),
|
||||||
|
backdancer_id: K.ITEM('s32', fumen.backdancer_id),
|
||||||
|
price: K.ITEM('s32', fumen.price),
|
||||||
|
limitation_type: K.ITEM('s32', fumen.limitation_type),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
video_flags: {
|
||||||
|
JP: K.ITEM('s32', music.video_flags.JP),
|
||||||
|
US: K.ITEM('s32', music.video_flags.US),
|
||||||
|
},
|
||||||
|
motion_flags: {
|
||||||
|
JP: K.ITEM('s32', music.motion_flags.JP),
|
||||||
|
US: K.ITEM('s32', music.motion_flags.US),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCommon: EPR = async (info, data, send) => {
|
||||||
|
const catalog = U.GetConfig('unlock_all_songs') ? await loadLocalMdb() : null;
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
mdb: {
|
||||||
|
music: catalog ? catalog.musics.map(mdbMusicResponse) : [],
|
||||||
|
},
|
||||||
|
event: {},
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const lockMultiLogin: EPR = async (info, data, send) => {
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
result: K.ITEM('s32', 0),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePcbData: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
await DB.Upsert(
|
||||||
|
{ collection: 'pcb' } as any,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
collection: 'pcb',
|
||||||
|
model: info.model,
|
||||||
|
locationId: readString(reader, 'pcbinfo.loc_id'),
|
||||||
|
region: readNumber(reader, 'pcbinfo.region'),
|
||||||
|
locationName: readString(reader, 'pcbinfo.locname'),
|
||||||
|
customer: readString(reader, 'pcbinfo.customer'),
|
||||||
|
company: readString(reader, 'pcbinfo.company'),
|
||||||
|
systemId: readString(reader, 'pcbinfo.system_id'),
|
||||||
|
hardwareId: readString(reader, 'pcbinfo.hardware_id'),
|
||||||
|
licenseId: readString(reader, 'pcbinfo.license_id'),
|
||||||
|
accountId: readString(reader, 'pcbinfo.account_id'),
|
||||||
|
boot: readNumber(reader, 'pcbinfo.boot'),
|
||||||
|
eacoinStatus: readNumber(reader, 'pcbinfo.eacoin_status'),
|
||||||
|
updateProgress: readNumber(reader, 'pcbinfo.update_progress'),
|
||||||
|
shopName: readString(reader, 'etc.network.shopname'),
|
||||||
|
matchingGroup: readString(reader, 'etc.network.matching_group'),
|
||||||
|
updatedAt: nowMs(),
|
||||||
|
},
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
next_request_interval_sec: K.ITEM('u64', BigInt(300)),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveLog: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const items = reader.elements('logdata');
|
||||||
|
await DB.Upsert(
|
||||||
|
{ collection: 'telemetry' } as any,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
collection: 'telemetry',
|
||||||
|
lastModel: info.model,
|
||||||
|
lastDataKey: items.length
|
||||||
|
? readString(items[items.length - 1], 'data_key')
|
||||||
|
: '',
|
||||||
|
updatedAt: nowMs(),
|
||||||
|
},
|
||||||
|
$inc: { receivedLogBatches: 1, receivedLogItems: items.length },
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
return send.success();
|
||||||
|
};
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/// <reference lib="es2020.bigint" />
|
||||||
|
|
||||||
|
declare const Buffer: any;
|
||||||
|
|
||||||
|
import { MotionDocument, MotionEntry, ProfileDocument } from '../models';
|
||||||
|
import {
|
||||||
|
avatarResponse,
|
||||||
|
createProfile,
|
||||||
|
dancerGradeResponse,
|
||||||
|
makeUuid,
|
||||||
|
motionInfoResponse,
|
||||||
|
nowMs,
|
||||||
|
readAvatar,
|
||||||
|
readNumber,
|
||||||
|
readStageResult,
|
||||||
|
readString,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
|
function readMotionIds(reader: KDataReader): string[] {
|
||||||
|
const values = reader
|
||||||
|
.elements('motion_id_list.motion_id')
|
||||||
|
.map(item => readString(item, ''));
|
||||||
|
for (const dataItem of reader.elements('data_item')) {
|
||||||
|
values.push(
|
||||||
|
...dataItem
|
||||||
|
.elements('motion_id_list.motion_id')
|
||||||
|
.map(item => readString(item, ''))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return values.filter(
|
||||||
|
(value, index) => !!value && values.indexOf(value) === index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProfile(refid: string): Promise<ProfileDocument> {
|
||||||
|
return (
|
||||||
|
(await DB.FindOne<ProfileDocument>(refid, { collection: 'profile' })) ||
|
||||||
|
createProfile(refid, 'PLAYER')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveMotionData: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(reader, 'userid.ref_id');
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] save_motiondata is missing userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const motionReader = reader.element('motiondata');
|
||||||
|
const full = motionReader.buffer('motion_full', Buffer.alloc(0));
|
||||||
|
const preview = motionReader.buffer('motion_prev', Buffer.alloc(0));
|
||||||
|
const entry: MotionEntry = {
|
||||||
|
motionId: makeUuid(),
|
||||||
|
active: true,
|
||||||
|
publishSetting: readNumber(motionReader, 'publish_setting'),
|
||||||
|
playCount: 1,
|
||||||
|
totalGv: 0,
|
||||||
|
playResult: readStageResult(motionReader),
|
||||||
|
playDate: readNumber(motionReader, 'play_date', nowMs()),
|
||||||
|
locationId: readString(motionReader, 'loc_id'),
|
||||||
|
shopName: readString(motionReader, 'shopname'),
|
||||||
|
avatar: readAvatar(motionReader, 'avatar'),
|
||||||
|
motionFullBase64: full.toString('base64'),
|
||||||
|
motionPreviewBase64: preview.toString('base64'),
|
||||||
|
createdAt: nowMs(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const configuredLimit = Number(U.GetConfig('motion_history_limit'));
|
||||||
|
const historyLimit = Number.isFinite(configuredLimit)
|
||||||
|
? Math.max(0, Math.min(100, Math.floor(configuredLimit)))
|
||||||
|
: 20;
|
||||||
|
if (historyLimit > 0) {
|
||||||
|
const document = (await DB.FindOne<MotionDocument>(refid, {
|
||||||
|
collection: 'motions',
|
||||||
|
})) || {
|
||||||
|
collection: 'motions' as const,
|
||||||
|
schemaVersion: 1,
|
||||||
|
entries: [],
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
document.entries = [entry, ...(document.entries || [])].slice(
|
||||||
|
0,
|
||||||
|
historyLimit
|
||||||
|
);
|
||||||
|
document.updatedAt = nowMs();
|
||||||
|
await DB.Upsert<MotionDocument>(
|
||||||
|
refid,
|
||||||
|
{ collection: 'motions' },
|
||||||
|
{ $set: document }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Dance Around] accepted motion ${entry.motionId} (${full.length}/${preview.length} bytes)`
|
||||||
|
);
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
motion_id: K.ITEM('str', entry.motionId),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMotionInfoList: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(reader, 'userid.ref_id');
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] get_motioninfolist is missing userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await DB.FindOne<MotionDocument>(refid, {
|
||||||
|
collection: 'motions',
|
||||||
|
});
|
||||||
|
const entries = document ? document.entries || [] : [];
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
data: entries.map(motionInfoResponse),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkPlayableMotionData: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const requested = readMotionIds(reader);
|
||||||
|
const refid = readString(reader, 'ref_id');
|
||||||
|
const document = refid
|
||||||
|
? await DB.FindOne<MotionDocument>(refid, { collection: 'motions' })
|
||||||
|
: null;
|
||||||
|
const available = new Set(
|
||||||
|
(document ? document.entries || [] : []).map(entry => entry.motionId)
|
||||||
|
);
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
data: requested.map(motionId => ({
|
||||||
|
motion_id: K.ITEM('str', motionId),
|
||||||
|
is_playable: K.ITEM('bool', available.has(motionId)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LocatedMotion {
|
||||||
|
refid: string;
|
||||||
|
entry: MotionEntry;
|
||||||
|
profile: ProfileDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function locateMotions(motionIds: string[]): Promise<LocatedMotion[]> {
|
||||||
|
if (!motionIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const wanted = new Set(motionIds);
|
||||||
|
const result: LocatedMotion[] = [];
|
||||||
|
const documents = await DB.Find<MotionDocument>(null, {
|
||||||
|
collection: 'motions',
|
||||||
|
});
|
||||||
|
for (const document of documents) {
|
||||||
|
const refid = (document as any).__refid as string;
|
||||||
|
if (!refid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const profile = await findProfile(refid);
|
||||||
|
for (const entry of document.entries || []) {
|
||||||
|
if (wanted.has(entry.motionId)) {
|
||||||
|
result.push({ refid, entry, profile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMotionData: EPR = async (info, data, send) => {
|
||||||
|
const requested = readMotionIds($(data));
|
||||||
|
const located = await locateMotions(requested);
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
data: located.map(item => ({
|
||||||
|
info: motionInfoResponse(item.entry),
|
||||||
|
userid: {
|
||||||
|
dancer_id: K.ITEM('str', item.profile.dancerId),
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: K.ITEM('str', item.profile.name),
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
publish_setting: K.ITEM('s32', item.profile.privacy.publishSetting),
|
||||||
|
},
|
||||||
|
dancer_grade: dancerGradeResponse(item.profile.dancerGrade),
|
||||||
|
avatar: avatarResponse(item.entry.avatar),
|
||||||
|
motiondata: {
|
||||||
|
motion_full: K.ITEM(
|
||||||
|
'bin',
|
||||||
|
Buffer.from(item.entry.motionFullBase64, 'base64')
|
||||||
|
),
|
||||||
|
motion_prev: K.ITEM(
|
||||||
|
'bin',
|
||||||
|
Buffer.from(item.entry.motionPreviewBase64, 'base64')
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/// <reference lib="es2020.bigint" />
|
||||||
|
|
||||||
|
import {
|
||||||
|
MusicScoreEntry,
|
||||||
|
ProfileDocument,
|
||||||
|
ScoreDocument,
|
||||||
|
UnlockMusicEntry,
|
||||||
|
} from '../models';
|
||||||
|
import {
|
||||||
|
avatarResponse,
|
||||||
|
createProfile,
|
||||||
|
dancerGradeResponse,
|
||||||
|
mergeScore,
|
||||||
|
mergeUnlocks,
|
||||||
|
musicScoreKey,
|
||||||
|
nowMs,
|
||||||
|
nowS64,
|
||||||
|
playInfoResponse,
|
||||||
|
playOptionResponse,
|
||||||
|
readAvatar,
|
||||||
|
readDeltaGroup,
|
||||||
|
readMusicScore,
|
||||||
|
readMusicUnlockGauges,
|
||||||
|
readNumber,
|
||||||
|
readPlayInfo,
|
||||||
|
readPlayOption,
|
||||||
|
readString,
|
||||||
|
readUnlocks,
|
||||||
|
readViewFlags,
|
||||||
|
stageResultResponse,
|
||||||
|
} from '../utils';
|
||||||
|
import { loadLocalMdb } from '../mdb';
|
||||||
|
|
||||||
|
async function findProfile(refid: string): Promise<ProfileDocument | null> {
|
||||||
|
return (
|
||||||
|
(await DB.FindOne<ProfileDocument>(refid, { collection: 'profile' })) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertProfile(
|
||||||
|
refid: string,
|
||||||
|
profile: ProfileDocument
|
||||||
|
): Promise<void> {
|
||||||
|
profile.updatedAt = nowMs();
|
||||||
|
await DB.Upsert<ProfileDocument>(
|
||||||
|
refid,
|
||||||
|
{ collection: 'profile' },
|
||||||
|
{ $set: profile }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function effectiveUnlocks(
|
||||||
|
profile: ProfileDocument
|
||||||
|
): Promise<UnlockMusicEntry[]> {
|
||||||
|
if (!U.GetConfig('unlock_all_songs')) {
|
||||||
|
return profile.unlockedMusics || [];
|
||||||
|
}
|
||||||
|
const catalog = await loadLocalMdb();
|
||||||
|
return mergeUnlocks(
|
||||||
|
profile.unlockedMusics || [],
|
||||||
|
catalog ? catalog.unlocks : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockMusicResponse(entries: UnlockMusicEntry[]): any {
|
||||||
|
return {
|
||||||
|
music: entries.map(entry =>
|
||||||
|
K.ATTR(
|
||||||
|
{ music_id: `${entry.musicId}` },
|
||||||
|
{ fumen_type: entry.fumens.map(fumen => K.ITEM('str', fumen)) }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileResponse(
|
||||||
|
profile: ProfileDocument,
|
||||||
|
unlocks: UnlockMusicEntry[]
|
||||||
|
): any {
|
||||||
|
return {
|
||||||
|
result: K.ITEM('s32', 0),
|
||||||
|
now_date: K.ITEM('s64', nowS64()),
|
||||||
|
userid: {
|
||||||
|
code: K.ITEM('s32', profile.userCode),
|
||||||
|
dancer_id: K.ITEM('str', profile.dancerId),
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
name: K.ITEM('str', profile.name),
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
publish_setting: K.ITEM('s32', profile.privacy.publishSetting),
|
||||||
|
},
|
||||||
|
dancer_grade: dancerGradeResponse(profile.dancerGrade),
|
||||||
|
delta_group: {
|
||||||
|
delta: (profile.deltaGroup || []).map(delta => ({
|
||||||
|
name: K.ITEM('str', delta.name),
|
||||||
|
income: K.ITEM('s32', delta.income),
|
||||||
|
expense: K.ITEM('s32', delta.expense),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
avatar: avatarResponse(profile.avatar),
|
||||||
|
playoption: playOptionResponse(profile.playOption),
|
||||||
|
playinfo: playInfoResponse(profile.playInfo),
|
||||||
|
unlock_music: unlockMusicResponse(unlocks),
|
||||||
|
follow_data: {
|
||||||
|
follower_num: K.ITEM('s32', 0),
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
view_flags: {
|
||||||
|
view_flag: (profile.viewFlags || []).map(entry => ({
|
||||||
|
view_id: K.ITEM('str', entry.viewId),
|
||||||
|
flag: K.ITEM('s32', entry.flag),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
music_unlock_gauges: {
|
||||||
|
music_unlock_gauge: (profile.musicUnlockGauges || []).map(entry => ({
|
||||||
|
event_id: K.ITEM('str', entry.eventId),
|
||||||
|
progress: K.ITEM('s32', entry.progress),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPlayData: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(reader, 'userid.ref_id');
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] get_playdata is missing userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await findProfile(refid);
|
||||||
|
if (!profile) {
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
result: K.ITEM('s32', 1),
|
||||||
|
now_date: K.ITEM('s64', nowS64()),
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await DB.Update<ProfileDocument>(
|
||||||
|
refid,
|
||||||
|
{ collection: 'profile' },
|
||||||
|
{ $inc: { loginCount: 1 }, $set: { updatedAt: nowMs() } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return send.object(
|
||||||
|
profileResponse(profile, await effectiveUnlocks(profile)),
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const signup: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(reader, 'userid.ref_id');
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] sign_up is missing userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = readString(reader, 'profile.name', 'PLAYER');
|
||||||
|
const dataId = readString(reader, 'userid.data_id');
|
||||||
|
const cardNumber = readString(reader, 'userid.card_no');
|
||||||
|
const existing = await findProfile(refid);
|
||||||
|
const profile =
|
||||||
|
existing || createProfile(refid, name, dataId, cardNumber, info.model);
|
||||||
|
profile.name = name || profile.name;
|
||||||
|
profile.dataId = dataId || profile.dataId;
|
||||||
|
profile.cardNumber = cardNumber || profile.cardNumber;
|
||||||
|
await upsertProfile(refid, profile);
|
||||||
|
console.log(
|
||||||
|
`[Dance Around] registered player ${profile.name} (${profile.dancerId})`
|
||||||
|
);
|
||||||
|
return send.success();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mergeScoreEntries(
|
||||||
|
refid: string,
|
||||||
|
incoming: MusicScoreEntry[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (!incoming.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const document = (await DB.FindOne<ScoreDocument>(refid, {
|
||||||
|
collection: 'scores',
|
||||||
|
})) || {
|
||||||
|
collection: 'scores' as const,
|
||||||
|
schemaVersion: 1,
|
||||||
|
entries: [],
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const scores: { [key: string]: MusicScoreEntry } = {};
|
||||||
|
for (const entry of document.entries || []) {
|
||||||
|
scores[musicScoreKey(entry)] = entry;
|
||||||
|
}
|
||||||
|
for (const entry of incoming) {
|
||||||
|
if (entry.musicId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = musicScoreKey(entry);
|
||||||
|
scores[key] = mergeScore(scores[key], entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.entries = Object.keys(scores)
|
||||||
|
.map(key => scores[key])
|
||||||
|
.sort(
|
||||||
|
(a, b) => a.musicId - b.musicId || a.fumenType.localeCompare(b.fumenType)
|
||||||
|
);
|
||||||
|
document.updatedAt = nowMs();
|
||||||
|
await DB.Upsert<ScoreDocument>(
|
||||||
|
refid,
|
||||||
|
{ collection: 'scores' },
|
||||||
|
{ $set: document }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const savePlayData: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(
|
||||||
|
reader,
|
||||||
|
'data.userid.ref_id',
|
||||||
|
readString(reader, 'userid.ref_id')
|
||||||
|
);
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] save_playdata is missing data.userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile =
|
||||||
|
(await findProfile(refid)) ||
|
||||||
|
createProfile(refid, 'PLAYER', '', '', info.model);
|
||||||
|
profile.privacy = {
|
||||||
|
publishSetting: readNumber(
|
||||||
|
reader,
|
||||||
|
'data.privacy.publish_setting',
|
||||||
|
profile.privacy.publishSetting
|
||||||
|
),
|
||||||
|
};
|
||||||
|
profile.dancerGrade = {
|
||||||
|
grade: readNumber(
|
||||||
|
reader,
|
||||||
|
'data.dancer_grade.grade',
|
||||||
|
profile.dancerGrade.grade
|
||||||
|
),
|
||||||
|
gvGauge: readNumber(
|
||||||
|
reader,
|
||||||
|
'data.dancer_grade.gv_gauge',
|
||||||
|
profile.dancerGrade.gvGauge
|
||||||
|
),
|
||||||
|
achievedGv: readNumber(
|
||||||
|
reader,
|
||||||
|
'data.dancer_grade.achieve_gv',
|
||||||
|
profile.dancerGrade.achievedGv
|
||||||
|
),
|
||||||
|
receivedGv: 0,
|
||||||
|
increaseGv: 0,
|
||||||
|
};
|
||||||
|
profile.avatar = readAvatar(reader, 'data.avatar');
|
||||||
|
profile.playOption = readPlayOption(reader, 'data.playoption');
|
||||||
|
profile.playInfo = readPlayInfo(reader, 'data.playinfo', info.model);
|
||||||
|
profile.unlockedMusics = mergeUnlocks(
|
||||||
|
profile.unlockedMusics || [],
|
||||||
|
readUnlocks(reader, 'data.unlock_music.music')
|
||||||
|
);
|
||||||
|
profile.viewFlags = readViewFlags(reader, 'data.view_flags.view_flag');
|
||||||
|
profile.musicUnlockGauges = readMusicUnlockGauges(
|
||||||
|
reader,
|
||||||
|
'data.music_unlock_gauges.music_unlock_gauge'
|
||||||
|
);
|
||||||
|
profile.deltaGroup = readDeltaGroup(reader, 'data.delta_group.delta');
|
||||||
|
await upsertProfile(refid, profile);
|
||||||
|
|
||||||
|
const stagedata = reader.elements('data.stagedata.data').map(readMusicScore);
|
||||||
|
await mergeScoreEntries(refid, stagedata);
|
||||||
|
console.log(
|
||||||
|
`[Dance Around] saved profile ${profile.dancerId}; ${stagedata.length} stage result(s)`
|
||||||
|
);
|
||||||
|
return send.success();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveMusicScore: EPR = async (info, data, send) => {
|
||||||
|
const reader = $(data);
|
||||||
|
const refid = readString(
|
||||||
|
reader,
|
||||||
|
'data.userid.ref_id',
|
||||||
|
readString(reader, 'userid.ref_id')
|
||||||
|
);
|
||||||
|
if (!refid) {
|
||||||
|
console.error(
|
||||||
|
'[Dance Around] save_musicscore is missing data.userid.ref_id'
|
||||||
|
);
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreReader = reader.element('data');
|
||||||
|
const score = readMusicScore(scoreReader);
|
||||||
|
await mergeScoreEntries(refid, [score]);
|
||||||
|
console.log(
|
||||||
|
`[Dance Around] saved score ${score.musicId}/${score.fumenType}: ${score.score}`
|
||||||
|
);
|
||||||
|
return send.success();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMusicScore: EPR = async (info, data, send) => {
|
||||||
|
const refid = readString($(data), 'userid.ref_id');
|
||||||
|
if (!refid) {
|
||||||
|
console.error('[Dance Around] get_musicscore is missing userid.ref_id');
|
||||||
|
return send.deny();
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await DB.FindOne<ScoreDocument>(refid, {
|
||||||
|
collection: 'scores',
|
||||||
|
});
|
||||||
|
const entries = document ? document.entries || [] : [];
|
||||||
|
return send.object(
|
||||||
|
{
|
||||||
|
scoredata: {
|
||||||
|
music: entries.map(score => ({
|
||||||
|
...stageResultResponse(score),
|
||||||
|
play_cnt: K.ITEM('s32', score.playCount),
|
||||||
|
play_date: K.ITEM('s64', BigInt(score.playDate)),
|
||||||
|
bestscore_date: K.ITEM('s64', BigInt(score.bestScoreDate)),
|
||||||
|
pcb_id: K.ITEM('str', score.pcbId),
|
||||||
|
loc_id: K.ITEM('str', score.locationId),
|
||||||
|
shopname: K.ITEM('str', score.shopName),
|
||||||
|
rec_cnt: K.ITEM('s32', score.recordCount),
|
||||||
|
gv_score: K.ITEM('s32', score.gvScore),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 0 }
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
getPlayData,
|
||||||
|
signup,
|
||||||
|
savePlayData,
|
||||||
|
getMusicScore,
|
||||||
|
saveMusicScore,
|
||||||
|
} from './handlers/profile';
|
||||||
|
import {
|
||||||
|
getCommon,
|
||||||
|
lockMultiLogin,
|
||||||
|
saveLog,
|
||||||
|
savePcbData,
|
||||||
|
} from './handlers/common';
|
||||||
|
import {
|
||||||
|
checkPlayableMotionData,
|
||||||
|
getMotionData,
|
||||||
|
getMotionInfoList,
|
||||||
|
saveMotionData,
|
||||||
|
} from './handlers/motion';
|
||||||
|
|
||||||
|
export function register() {
|
||||||
|
R.GameCode('UDN');
|
||||||
|
R.Contributor('Avimitin', 'https://github.com/Avimitin');
|
||||||
|
R.ExtraModuleHandler(async () => ['game']);
|
||||||
|
|
||||||
|
R.Config('unlock_all_songs', {
|
||||||
|
name: 'Unlock all songs',
|
||||||
|
desc: 'Load and unlock every playable chart from the installed game MDB.',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
R.Config('game_path', {
|
||||||
|
name: 'Game path',
|
||||||
|
desc: 'Path to the local DANCE aROUND installation.',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
R.Config('motion_history_limit', {
|
||||||
|
name: 'Motion history limit',
|
||||||
|
desc: 'Maximum number of saved motion captures per player. Set to 0 to disable storage.',
|
||||||
|
type: 'integer',
|
||||||
|
range: [0, 100],
|
||||||
|
default: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
R.Route('game.get_playdata', getPlayData);
|
||||||
|
R.Route('game.sign_up', signup);
|
||||||
|
R.Route('game.get_musicscore', getMusicScore);
|
||||||
|
R.Route('game.lock_multi_login', lockMultiLogin);
|
||||||
|
R.Route('game.get_common', getCommon);
|
||||||
|
R.Route('game.save_playdata', savePlayData);
|
||||||
|
R.Route('game.save_musicscore', saveMusicScore);
|
||||||
|
R.Route('game.save_pcbdata', savePcbData);
|
||||||
|
R.Route('game.save_log', saveLog);
|
||||||
|
R.Route('game.save_motiondata', saveMotionData);
|
||||||
|
R.Route('game.get_motioninfolist', getMotionInfoList);
|
||||||
|
R.Route('game.check_playable_motiondata', checkPlayableMotionData);
|
||||||
|
R.Route('game.get_motiondata', getMotionData);
|
||||||
|
|
||||||
|
R.Unhandled(async (info, data, send) => {
|
||||||
|
console.warn(
|
||||||
|
`[Dance Around] unhandled route ${info.module}.${info.method}`
|
||||||
|
);
|
||||||
|
await send.success();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Dance Around] UDN network service registered');
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
|
|
||||||
|
import { FumenType, UnlockMusicEntry } from './models';
|
||||||
|
|
||||||
|
const MDB_BUNDLE = [
|
||||||
|
'game',
|
||||||
|
'dancearound_data',
|
||||||
|
'StreamingAssets',
|
||||||
|
'aa',
|
||||||
|
'win64',
|
||||||
|
'audioWorks',
|
||||||
|
'musicassetgroup_assets_music_config',
|
||||||
|
'mdb_cabinet_base.bundle',
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface MdbMusic {
|
||||||
|
music_id: number;
|
||||||
|
title_name: string;
|
||||||
|
title_yomigana: string;
|
||||||
|
artist_name: string;
|
||||||
|
artist_yomigana: string;
|
||||||
|
bpm_max: number;
|
||||||
|
bpm_min: number;
|
||||||
|
distribution_date: string;
|
||||||
|
release_code: string;
|
||||||
|
volume: number;
|
||||||
|
bg_no: number;
|
||||||
|
region: number;
|
||||||
|
tags: string[];
|
||||||
|
limitation_type: number;
|
||||||
|
license: string;
|
||||||
|
color1: string;
|
||||||
|
color2: string;
|
||||||
|
color3: string;
|
||||||
|
has_mv: number;
|
||||||
|
demo_pri: number;
|
||||||
|
video_flags: { JP: number; US: number };
|
||||||
|
motion_flags: { JP: number; US: number };
|
||||||
|
fumens: Array<{
|
||||||
|
fumen_type: FumenType;
|
||||||
|
level: number;
|
||||||
|
playable: number;
|
||||||
|
has_official_dance: number;
|
||||||
|
backdancer_id: number;
|
||||||
|
price: number;
|
||||||
|
limitation_type: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalMdbCatalog {
|
||||||
|
sourcePath: string;
|
||||||
|
musicCount: number;
|
||||||
|
chartCount: number;
|
||||||
|
lockedChartCount: number;
|
||||||
|
baselineChartCount: number;
|
||||||
|
musics: MdbMusic[];
|
||||||
|
unlocks: UnlockMusicEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class Reader {
|
||||||
|
offset = 0;
|
||||||
|
|
||||||
|
constructor(readonly data: Buffer) {}
|
||||||
|
|
||||||
|
bytes(size: number): Buffer {
|
||||||
|
const value = this.data.slice(this.offset, this.offset + size);
|
||||||
|
this.offset += size;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
cstring(): string {
|
||||||
|
const end = this.data.indexOf(0, this.offset);
|
||||||
|
if (end < 0) throw new Error('unterminated UnityFS string');
|
||||||
|
const value = this.data.toString('utf8', this.offset, end);
|
||||||
|
this.offset = end + 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
u16(): number {
|
||||||
|
const value = this.data.readUInt16BE(this.offset);
|
||||||
|
this.offset += 2;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
u32(): number {
|
||||||
|
const value = this.data.readUInt32BE(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
u64(): number {
|
||||||
|
return this.u32() * 0x100000000 + this.u32();
|
||||||
|
}
|
||||||
|
|
||||||
|
align(size: number): void {
|
||||||
|
this.offset = Math.ceil(this.offset / size) * size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lz4Length(
|
||||||
|
input: Buffer,
|
||||||
|
position: { value: number },
|
||||||
|
length: number
|
||||||
|
): number {
|
||||||
|
if (length !== 15) return length;
|
||||||
|
let next = 255;
|
||||||
|
while (next === 255) {
|
||||||
|
next = input[position.value++];
|
||||||
|
length += next;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lz4(input: Buffer, outputSize: number): Buffer {
|
||||||
|
const output = Buffer.alloc(outputSize);
|
||||||
|
const position = { value: 0 };
|
||||||
|
let target = 0;
|
||||||
|
while (position.value < input.length) {
|
||||||
|
const token = input[position.value++];
|
||||||
|
const literalSize = lz4Length(input, position, token >>> 4);
|
||||||
|
input.copy(output, target, position.value, position.value + literalSize);
|
||||||
|
position.value += literalSize;
|
||||||
|
target += literalSize;
|
||||||
|
if (position.value >= input.length) break;
|
||||||
|
|
||||||
|
const distance = input[position.value] | (input[position.value + 1] << 8);
|
||||||
|
position.value += 2;
|
||||||
|
const matchSize = lz4Length(input, position, token & 0x0f) + 4;
|
||||||
|
for (let i = 0; i < matchSize; ++i) {
|
||||||
|
output[target] = output[target - distance];
|
||||||
|
++target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target !== outputSize)
|
||||||
|
throw new Error(`LZ4 size mismatch: ${target}/${outputSize}`);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decompress(input: Buffer, outputSize: number, flags: number): Buffer {
|
||||||
|
switch (flags & 0x3f) {
|
||||||
|
case 0:
|
||||||
|
return input;
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return lz4(input, outputSize);
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported UnityFS compression ${flags & 0x3f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpackUnityFs(bundle: Buffer): Buffer {
|
||||||
|
const header = new Reader(bundle);
|
||||||
|
if (header.cstring() !== 'UnityFS') throw new Error('not a UnityFS bundle');
|
||||||
|
const version = header.u32();
|
||||||
|
header.cstring();
|
||||||
|
header.cstring();
|
||||||
|
header.u64();
|
||||||
|
const compressedInfoSize = header.u32();
|
||||||
|
const infoSize = header.u32();
|
||||||
|
const flags = header.u32();
|
||||||
|
if (version >= 7) header.align(16);
|
||||||
|
|
||||||
|
const infoAtEnd = (flags & 0x80) !== 0;
|
||||||
|
const infoOffset = infoAtEnd
|
||||||
|
? bundle.length - compressedInfoSize
|
||||||
|
: header.offset;
|
||||||
|
const info = new Reader(
|
||||||
|
decompress(
|
||||||
|
bundle.slice(infoOffset, infoOffset + compressedInfoSize),
|
||||||
|
infoSize,
|
||||||
|
flags
|
||||||
|
)
|
||||||
|
);
|
||||||
|
info.bytes(16);
|
||||||
|
|
||||||
|
const blocks: Array<{ raw: number; packed: number; flags: number }> = [];
|
||||||
|
for (let count = info.u32(); count > 0; --count) {
|
||||||
|
blocks.push({ raw: info.u32(), packed: info.u32(), flags: info.u16() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes: Array<{ offset: number; size: number }> = [];
|
||||||
|
for (let count = info.u32(); count > 0; --count) {
|
||||||
|
const offset = info.u64();
|
||||||
|
const size = info.u64();
|
||||||
|
info.u32();
|
||||||
|
info.cstring();
|
||||||
|
nodes.push({ offset, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataOffset = infoAtEnd ? header.offset : infoOffset + compressedInfoSize;
|
||||||
|
if ((flags & 0x200) !== 0) dataOffset = Math.ceil(dataOffset / 16) * 16;
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for (const block of blocks) {
|
||||||
|
chunks.push(
|
||||||
|
decompress(
|
||||||
|
bundle.slice(dataOffset, dataOffset + block.packed),
|
||||||
|
block.raw,
|
||||||
|
block.flags
|
||||||
|
)
|
||||||
|
);
|
||||||
|
dataOffset += block.packed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.concat(chunks);
|
||||||
|
return Buffer.concat(
|
||||||
|
nodes.map(node => data.slice(node.offset, node.offset + node.size))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findJsonEnd(data: Buffer, start: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let quoted = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = start; i < data.length; ++i) {
|
||||||
|
if (quoted) {
|
||||||
|
if (escaped) escaped = false;
|
||||||
|
else if (data[i] === 0x5c) escaped = true;
|
||||||
|
else if (data[i] === 0x22) quoted = false;
|
||||||
|
} else if (data[i] === 0x22) quoted = true;
|
||||||
|
else if (data[i] === 0x7b) ++depth;
|
||||||
|
else if (data[i] === 0x7d && --depth === 0) return i + 1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMdb(bundle: Buffer): any {
|
||||||
|
const data = unpackUnityFs(bundle);
|
||||||
|
const marker = data.indexOf(Buffer.from('"music_count"'));
|
||||||
|
if (marker < 0) throw new Error('mdb_cabinet_base TextAsset was not found');
|
||||||
|
for (
|
||||||
|
let start = data.lastIndexOf(0x7b, marker);
|
||||||
|
start >= 0;
|
||||||
|
start = data.lastIndexOf(0x7b, start - 1)
|
||||||
|
) {
|
||||||
|
const end = findJsonEnd(data, start);
|
||||||
|
if (end < 0) continue;
|
||||||
|
try {
|
||||||
|
const document = JSON.parse(data.toString('utf8', start, end));
|
||||||
|
if (Array.isArray(document.musics)) return document;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
throw new Error('mdb_cabinet_base TextAsset was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
function number(value: any, fallback = 0): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: any): string {
|
||||||
|
return value == null ? '' : `${value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagPair(value: any): { JP: number; US: number } {
|
||||||
|
return { JP: number(value && value.JP), US: number(value && value.US) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog(document: any, sourcePath: string): LocalMdbCatalog {
|
||||||
|
const musics: MdbMusic[] = [];
|
||||||
|
const unlocks: UnlockMusicEntry[] = [];
|
||||||
|
let lockedChartCount = 0;
|
||||||
|
let baselineChartCount = 0;
|
||||||
|
|
||||||
|
for (const source of document.musics) {
|
||||||
|
const musicId = number(source.music_id);
|
||||||
|
const limitation = number(source.limitation_type);
|
||||||
|
const fumens = (source.fumens || [])
|
||||||
|
.filter(
|
||||||
|
(fumen: any) =>
|
||||||
|
number(fumen.playable) === 1 || number(fumen.playable) === 2
|
||||||
|
)
|
||||||
|
.map((fumen: any) => {
|
||||||
|
const playable = number(fumen.playable);
|
||||||
|
if (playable === 1) ++lockedChartCount;
|
||||||
|
else ++baselineChartCount;
|
||||||
|
return {
|
||||||
|
fumen_type: text(fumen.fumen_type) as FumenType,
|
||||||
|
level: number(fumen.level),
|
||||||
|
playable,
|
||||||
|
has_official_dance: number(fumen.has_official_dance),
|
||||||
|
backdancer_id: number(fumen.backdancer_id),
|
||||||
|
price: number(fumen.fumen_price),
|
||||||
|
limitation_type: playable === 1 ? 2 : limitation,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!fumens.length) continue;
|
||||||
|
|
||||||
|
musics.push({
|
||||||
|
music_id: musicId,
|
||||||
|
title_name: text(source.title_name),
|
||||||
|
title_yomigana: text(source.title_yomigana),
|
||||||
|
artist_name: text(source.artist_name),
|
||||||
|
artist_yomigana: text(source.artist_yomigana),
|
||||||
|
bpm_max: number(source.bpm_max),
|
||||||
|
bpm_min: number(source.bpm_min),
|
||||||
|
distribution_date: text(source.distribution_date),
|
||||||
|
release_code: text(source.release_code),
|
||||||
|
volume: number(source.volume),
|
||||||
|
bg_no: number(source.bg_no),
|
||||||
|
region: number(source.region),
|
||||||
|
tags: Array.isArray(source.tags) ? source.tags.map(text) : [],
|
||||||
|
limitation_type: limitation,
|
||||||
|
license: text(source.license),
|
||||||
|
color1: text(source.color1),
|
||||||
|
color2: text(source.color2),
|
||||||
|
color3: text(source.color3),
|
||||||
|
has_mv: number(source.has_mv),
|
||||||
|
demo_pri: number(source.demo_pri),
|
||||||
|
video_flags: flagPair(source.video_flags),
|
||||||
|
motion_flags: flagPair(source.motion_flags),
|
||||||
|
fumens,
|
||||||
|
});
|
||||||
|
unlocks.push({ musicId, fumens: fumens.map(fumen => fumen.fumen_type) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourcePath,
|
||||||
|
musicCount: musics.length,
|
||||||
|
chartCount: lockedChartCount + baselineChartCount,
|
||||||
|
lockedChartCount,
|
||||||
|
baselineChartCount,
|
||||||
|
musics,
|
||||||
|
unlocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBundle(gamePath: string): string | null {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const roots = gamePath
|
||||||
|
? [gamePath]
|
||||||
|
: [process.cwd(), path.dirname(process.cwd())];
|
||||||
|
for (const value of roots) {
|
||||||
|
const root = path.resolve(value);
|
||||||
|
const candidates =
|
||||||
|
path.extname(root).toLowerCase() === '.bundle'
|
||||||
|
? [root]
|
||||||
|
: [MDB_BUNDLE, MDB_BUNDLE.slice(1), MDB_BUNDLE.slice(2)].map(parts =>
|
||||||
|
path.join(root, ...parts)
|
||||||
|
);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cacheKey = '';
|
||||||
|
let cache: LocalMdbCatalog | null = null;
|
||||||
|
let reportedError = '';
|
||||||
|
|
||||||
|
export function readLocalMdb(bundlePath: string): LocalMdbCatalog {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const resolved = path.resolve(bundlePath);
|
||||||
|
return catalog(extractMdb(fs.readFileSync(resolved)), resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLocalMdb(): Promise<LocalMdbCatalog | null> {
|
||||||
|
const fs = require('fs');
|
||||||
|
const configured = text(U.GetConfig('game_path')).trim();
|
||||||
|
const bundlePath = findBundle(configured);
|
||||||
|
if (!bundlePath) {
|
||||||
|
const error = configured
|
||||||
|
? `MDB was not found under ${configured}`
|
||||||
|
: 'game_path is not set';
|
||||||
|
if (reportedError !== error) console.warn(`[Dance Around] ${error}`);
|
||||||
|
reportedError = error;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(bundlePath);
|
||||||
|
const key = `${bundlePath}:${stat.size}:${stat.mtimeMs}`;
|
||||||
|
if (cache && cacheKey === key) return cache;
|
||||||
|
try {
|
||||||
|
cache = readLocalMdb(bundlePath);
|
||||||
|
cacheKey = key;
|
||||||
|
reportedError = '';
|
||||||
|
console.log(
|
||||||
|
`[Dance Around] loaded ${cache.musicCount} music / ${cache.chartCount} charts from local MDB`
|
||||||
|
);
|
||||||
|
return cache;
|
||||||
|
} catch (error) {
|
||||||
|
const message = `${bundlePath}: ${error}`;
|
||||||
|
if (reportedError !== message)
|
||||||
|
console.error(`[Dance Around] failed to load MDB: ${message}`);
|
||||||
|
reportedError = message;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
export type FumenType = 'BASIC' | 'ADVANCED' | 'MASTER';
|
||||||
|
|
||||||
|
export interface PrivacySetting {
|
||||||
|
publishSetting: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DancerGrade {
|
||||||
|
grade: number;
|
||||||
|
gvGauge: number;
|
||||||
|
achievedGv: number;
|
||||||
|
receivedGv: number;
|
||||||
|
increaseGv: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarSetting {
|
||||||
|
physique: number;
|
||||||
|
skinColor: number;
|
||||||
|
face: number;
|
||||||
|
eyeColor: number;
|
||||||
|
hairstyle: number;
|
||||||
|
hairColor: number;
|
||||||
|
costumeUpper: number;
|
||||||
|
costumeLower: number;
|
||||||
|
accessoryHead: number;
|
||||||
|
accessoryFace: number;
|
||||||
|
accessoryBody: number;
|
||||||
|
accessoryHand: number;
|
||||||
|
baseChara: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayOptionSetting {
|
||||||
|
noteSpeed: number;
|
||||||
|
noteSize: number;
|
||||||
|
judgeSeType: number;
|
||||||
|
judgeSeVolume: number;
|
||||||
|
judgeTiming: number;
|
||||||
|
noteTiming: number;
|
||||||
|
guide: number;
|
||||||
|
invert: number;
|
||||||
|
stealth: number;
|
||||||
|
timelineDisplay: number;
|
||||||
|
avatarAction: number;
|
||||||
|
timelineModelDirection: number;
|
||||||
|
avatarModelDirection: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayInfo {
|
||||||
|
locationId: string;
|
||||||
|
modeId: number;
|
||||||
|
styleId: number;
|
||||||
|
folderId: number;
|
||||||
|
musicId: number;
|
||||||
|
fumenType: FumenType;
|
||||||
|
startDate: number;
|
||||||
|
endDate: number;
|
||||||
|
lightPlayCount: number;
|
||||||
|
standardPlayCount: number;
|
||||||
|
trainingPlayCount: number;
|
||||||
|
pcbId: string;
|
||||||
|
softcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockMusicEntry {
|
||||||
|
musicId: number;
|
||||||
|
fumens: FumenType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewFlagEntry {
|
||||||
|
viewId: string;
|
||||||
|
flag: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicUnlockGaugeEntry {
|
||||||
|
eventId: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeltaEntry {
|
||||||
|
name: string;
|
||||||
|
income: number;
|
||||||
|
expense: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileDocument {
|
||||||
|
collection: 'profile';
|
||||||
|
schemaVersion: number;
|
||||||
|
name: string;
|
||||||
|
dataId: string;
|
||||||
|
cardNumber: string;
|
||||||
|
userCode: number;
|
||||||
|
dancerId: string;
|
||||||
|
privacy: PrivacySetting;
|
||||||
|
dancerGrade: DancerGrade;
|
||||||
|
avatar: AvatarSetting;
|
||||||
|
playOption: PlayOptionSetting;
|
||||||
|
playInfo: PlayInfo;
|
||||||
|
unlockedMusics: UnlockMusicEntry[];
|
||||||
|
viewFlags: ViewFlagEntry[];
|
||||||
|
musicUnlockGauges: MusicUnlockGaugeEntry[];
|
||||||
|
deltaGroup: DeltaEntry[];
|
||||||
|
loginCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StageResult {
|
||||||
|
musicId: number;
|
||||||
|
fumenType: FumenType;
|
||||||
|
clearStatus: number;
|
||||||
|
score: number;
|
||||||
|
rank: number;
|
||||||
|
combo: number;
|
||||||
|
perfect: number;
|
||||||
|
great: number;
|
||||||
|
good: number;
|
||||||
|
bad: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicScoreEntry extends StageResult {
|
||||||
|
gameStartDate: number;
|
||||||
|
mode: number;
|
||||||
|
style: number;
|
||||||
|
stageNo: number;
|
||||||
|
playCount: number;
|
||||||
|
playDate: number;
|
||||||
|
bestScoreDate: number;
|
||||||
|
pcbId: string;
|
||||||
|
locationId: string;
|
||||||
|
shopName: string;
|
||||||
|
recordCount: number;
|
||||||
|
gvScore: number;
|
||||||
|
dropFrame: number;
|
||||||
|
dropFrameMax: number;
|
||||||
|
dropCount: number;
|
||||||
|
videoKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreDocument {
|
||||||
|
collection: 'scores';
|
||||||
|
schemaVersion: number;
|
||||||
|
entries: MusicScoreEntry[];
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionEntry {
|
||||||
|
motionId: string;
|
||||||
|
active: boolean;
|
||||||
|
publishSetting: number;
|
||||||
|
playCount: number;
|
||||||
|
totalGv: number;
|
||||||
|
playResult: StageResult;
|
||||||
|
playDate: number;
|
||||||
|
locationId: string;
|
||||||
|
shopName: string;
|
||||||
|
avatar: AvatarSetting;
|
||||||
|
motionFullBase64: string;
|
||||||
|
motionPreviewBase64: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionDocument {
|
||||||
|
collection: 'motions';
|
||||||
|
schemaVersion: number;
|
||||||
|
entries: MotionEntry[];
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
/// <reference lib="es2020.bigint" />
|
||||||
|
|
||||||
|
declare const Buffer: any;
|
||||||
|
|
||||||
|
import {
|
||||||
|
AvatarSetting,
|
||||||
|
DancerGrade,
|
||||||
|
DeltaEntry,
|
||||||
|
FumenType,
|
||||||
|
MotionEntry,
|
||||||
|
MusicScoreEntry,
|
||||||
|
MusicUnlockGaugeEntry,
|
||||||
|
PlayInfo,
|
||||||
|
PlayOptionSetting,
|
||||||
|
ProfileDocument,
|
||||||
|
StageResult,
|
||||||
|
UnlockMusicEntry,
|
||||||
|
ViewFlagEntry,
|
||||||
|
} from './models';
|
||||||
|
|
||||||
|
export const PROFILE_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
export function nowMs(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowS64(): bigint {
|
||||||
|
return BigInt(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeUserCode(refid: string): number {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < refid.length; ++i) {
|
||||||
|
hash ^= refid.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619) >>> 0;
|
||||||
|
}
|
||||||
|
return 100000000 + (hash % 900000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDancerId(userCode: number): string {
|
||||||
|
return `${userCode}`.padStart(9, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeUuid(): string {
|
||||||
|
const bytes = Buffer.alloc(16);
|
||||||
|
let timestamp = Date.now();
|
||||||
|
for (let i = 0; i < bytes.length; ++i) {
|
||||||
|
const timeByte = timestamp & 0xff;
|
||||||
|
bytes[i] = (Math.floor(Math.random() * 256) ^ timeByte) & 0xff;
|
||||||
|
timestamp = Math.floor(timestamp / 256);
|
||||||
|
}
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = bytes.toString('hex');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(
|
||||||
|
12,
|
||||||
|
16
|
||||||
|
)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAvatar(): AvatarSetting {
|
||||||
|
return {
|
||||||
|
physique: 0,
|
||||||
|
skinColor: 0,
|
||||||
|
face: 0,
|
||||||
|
eyeColor: 0,
|
||||||
|
hairstyle: 0,
|
||||||
|
hairColor: 0,
|
||||||
|
costumeUpper: 0,
|
||||||
|
costumeLower: 0,
|
||||||
|
accessoryHead: 0,
|
||||||
|
accessoryFace: 0,
|
||||||
|
accessoryBody: 0,
|
||||||
|
accessoryHand: 0,
|
||||||
|
baseChara: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPlayOption(): PlayOptionSetting {
|
||||||
|
return {
|
||||||
|
noteSpeed: 0,
|
||||||
|
noteSize: 0,
|
||||||
|
judgeSeType: 0,
|
||||||
|
judgeSeVolume: 5,
|
||||||
|
judgeTiming: 0,
|
||||||
|
noteTiming: 0,
|
||||||
|
guide: 0,
|
||||||
|
invert: 0,
|
||||||
|
stealth: 0,
|
||||||
|
timelineDisplay: 0,
|
||||||
|
avatarAction: 0,
|
||||||
|
timelineModelDirection: 0,
|
||||||
|
avatarModelDirection: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultPlayInfo(model = ''): PlayInfo {
|
||||||
|
return {
|
||||||
|
locationId: '',
|
||||||
|
modeId: -1,
|
||||||
|
styleId: 0,
|
||||||
|
folderId: 0,
|
||||||
|
musicId: 0,
|
||||||
|
// The managed response parser throws on values outside the three XRPC
|
||||||
|
// fumen names, including an empty string on a brand-new profile.
|
||||||
|
fumenType: 'BASIC',
|
||||||
|
startDate: 0,
|
||||||
|
endDate: 0,
|
||||||
|
lightPlayCount: 0,
|
||||||
|
standardPlayCount: 0,
|
||||||
|
trainingPlayCount: 0,
|
||||||
|
pcbId: '',
|
||||||
|
softcode: model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultDancerGrade(): DancerGrade {
|
||||||
|
return {
|
||||||
|
grade: 0,
|
||||||
|
gvGauge: 0,
|
||||||
|
achievedGv: 0,
|
||||||
|
receivedGv: 0,
|
||||||
|
increaseGv: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProfile(
|
||||||
|
refid: string,
|
||||||
|
name: string,
|
||||||
|
dataId = '',
|
||||||
|
cardNumber = '',
|
||||||
|
model = ''
|
||||||
|
): ProfileDocument {
|
||||||
|
const createdAt = nowMs();
|
||||||
|
const userCode = makeUserCode(refid);
|
||||||
|
return {
|
||||||
|
collection: 'profile',
|
||||||
|
schemaVersion: PROFILE_SCHEMA_VERSION,
|
||||||
|
name: name || 'PLAYER',
|
||||||
|
dataId,
|
||||||
|
cardNumber,
|
||||||
|
userCode,
|
||||||
|
dancerId: makeDancerId(userCode),
|
||||||
|
privacy: { publishSetting: 0 },
|
||||||
|
dancerGrade: defaultDancerGrade(),
|
||||||
|
avatar: defaultAvatar(),
|
||||||
|
playOption: defaultPlayOption(),
|
||||||
|
playInfo: defaultPlayInfo(model),
|
||||||
|
unlockedMusics: [],
|
||||||
|
viewFlags: [],
|
||||||
|
musicUnlockGauges: [],
|
||||||
|
deltaGroup: [],
|
||||||
|
loginCount: 0,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFumen(value: string): FumenType {
|
||||||
|
if (value === 'ADVANCED' || value === 'MASTER') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return 'BASIC';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNumber(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string,
|
||||||
|
fallback = 0
|
||||||
|
): number {
|
||||||
|
return reader.number(path, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readString(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string,
|
||||||
|
fallback = ''
|
||||||
|
): string {
|
||||||
|
return reader.str(path, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAvatar(reader: KDataReader, prefix: string): AvatarSetting {
|
||||||
|
const p = prefix ? `${prefix}.` : '';
|
||||||
|
return {
|
||||||
|
physique: readNumber(reader, `${p}physique`),
|
||||||
|
skinColor: readNumber(reader, `${p}skin_color`),
|
||||||
|
face: readNumber(reader, `${p}face`),
|
||||||
|
eyeColor: readNumber(reader, `${p}eye_color`),
|
||||||
|
hairstyle: readNumber(reader, `${p}hairstyle`),
|
||||||
|
hairColor: readNumber(reader, `${p}hair_color`),
|
||||||
|
costumeUpper: readNumber(reader, `${p}costume_upper`),
|
||||||
|
costumeLower: readNumber(reader, `${p}costume_lower`),
|
||||||
|
accessoryHead: readNumber(reader, `${p}accessory_head`),
|
||||||
|
accessoryFace: readNumber(reader, `${p}accessory_face`),
|
||||||
|
accessoryBody: readNumber(reader, `${p}accessory_body`),
|
||||||
|
accessoryHand: readNumber(reader, `${p}accessory_hand`),
|
||||||
|
baseChara: readNumber(reader, `${p}base_chara`, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPlayOption(
|
||||||
|
reader: KDataReader,
|
||||||
|
prefix: string
|
||||||
|
): PlayOptionSetting {
|
||||||
|
const p = prefix ? `${prefix}.` : '';
|
||||||
|
return {
|
||||||
|
noteSpeed: readNumber(reader, `${p}note_speed`),
|
||||||
|
noteSize: readNumber(reader, `${p}note_size`),
|
||||||
|
judgeSeType: readNumber(reader, `${p}judge_se_type`),
|
||||||
|
judgeSeVolume: readNumber(reader, `${p}judge_se_vol`, 5),
|
||||||
|
judgeTiming: readNumber(reader, `${p}judge_timing`),
|
||||||
|
noteTiming: readNumber(reader, `${p}note_timing`),
|
||||||
|
guide: readNumber(reader, `${p}guide`),
|
||||||
|
invert: readNumber(reader, `${p}invert`),
|
||||||
|
stealth: readNumber(reader, `${p}stealth`),
|
||||||
|
timelineDisplay: readNumber(reader, `${p}timeline_disp`),
|
||||||
|
avatarAction: readNumber(reader, `${p}avatar_action`),
|
||||||
|
timelineModelDirection: readNumber(reader, `${p}timeline_model_dir`),
|
||||||
|
avatarModelDirection: readNumber(reader, `${p}avatar_model_dir`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPlayInfo(
|
||||||
|
reader: KDataReader,
|
||||||
|
prefix: string,
|
||||||
|
model = ''
|
||||||
|
): PlayInfo {
|
||||||
|
const p = prefix ? `${prefix}.` : '';
|
||||||
|
return {
|
||||||
|
locationId: readString(reader, `${p}loc_id`),
|
||||||
|
modeId: readNumber(reader, `${p}mode_id`, -1),
|
||||||
|
styleId: readNumber(reader, `${p}style_id`),
|
||||||
|
folderId: readNumber(reader, `${p}folder_id`),
|
||||||
|
musicId: readNumber(reader, `${p}music_id`),
|
||||||
|
fumenType: normalizeFumen(readString(reader, `${p}fumen_type`, 'BASIC')),
|
||||||
|
startDate: readNumber(reader, `${p}start_date`),
|
||||||
|
endDate: readNumber(reader, `${p}end_date`),
|
||||||
|
lightPlayCount: readNumber(reader, `${p}light_play_count`),
|
||||||
|
standardPlayCount: readNumber(reader, `${p}standard_play_count`),
|
||||||
|
trainingPlayCount: readNumber(reader, `${p}training_play_count`),
|
||||||
|
pcbId: readString(reader, `${p}pcb_id`),
|
||||||
|
softcode: readString(reader, `${p}softcode`, model),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStageResult(reader: KDataReader, prefix = ''): StageResult {
|
||||||
|
const p = prefix ? `${prefix}.` : '';
|
||||||
|
return {
|
||||||
|
musicId: readNumber(reader, `${p}music_id`),
|
||||||
|
fumenType: normalizeFumen(readString(reader, `${p}fumen_type`, 'BASIC')),
|
||||||
|
clearStatus: readNumber(reader, `${p}clear_status`),
|
||||||
|
score: readNumber(reader, `${p}score`),
|
||||||
|
rank: readNumber(reader, `${p}rank`),
|
||||||
|
combo: readNumber(reader, `${p}combo`),
|
||||||
|
perfect: readNumber(reader, `${p}perfect`),
|
||||||
|
great: readNumber(reader, `${p}great`),
|
||||||
|
good: readNumber(reader, `${p}good`),
|
||||||
|
bad: readNumber(reader, `${p}bad`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMusicScore(reader: KDataReader): MusicScoreEntry {
|
||||||
|
const playResult = readStageResult(reader);
|
||||||
|
const playDate = readNumber(reader, 'play_date', nowMs());
|
||||||
|
return {
|
||||||
|
...playResult,
|
||||||
|
gameStartDate: readNumber(reader, 'start_date'),
|
||||||
|
mode: readNumber(reader, 'mode'),
|
||||||
|
style: readNumber(reader, 'style'),
|
||||||
|
stageNo: readNumber(reader, 'stage_no'),
|
||||||
|
playCount: readNumber(reader, 'play_cnt', 1),
|
||||||
|
playDate,
|
||||||
|
bestScoreDate: playDate,
|
||||||
|
pcbId: '',
|
||||||
|
locationId: readString(reader, 'loc_id'),
|
||||||
|
shopName: readString(reader, 'shopname'),
|
||||||
|
recordCount: readNumber(reader, 'rec_cnt'),
|
||||||
|
gvScore: 0,
|
||||||
|
dropFrame: readNumber(reader, 'drop_frame'),
|
||||||
|
dropFrameMax: readNumber(reader, 'drop_frame_max'),
|
||||||
|
dropCount: readNumber(reader, 'drop_count'),
|
||||||
|
videoKey: readString(reader, 'video_key'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeScore(
|
||||||
|
existing: MusicScoreEntry | undefined,
|
||||||
|
incoming: MusicScoreEntry
|
||||||
|
): MusicScoreEntry {
|
||||||
|
if (!existing) {
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNewBest = incoming.score >= existing.score;
|
||||||
|
const best = isNewBest ? incoming : existing;
|
||||||
|
return {
|
||||||
|
...best,
|
||||||
|
playCount: Math.max(existing.playCount, incoming.playCount),
|
||||||
|
playDate: Math.max(existing.playDate, incoming.playDate),
|
||||||
|
bestScoreDate: isNewBest ? incoming.playDate : existing.bestScoreDate,
|
||||||
|
clearStatus: Math.max(existing.clearStatus, incoming.clearStatus),
|
||||||
|
combo: Math.max(existing.combo, incoming.combo),
|
||||||
|
recordCount: Math.max(existing.recordCount, incoming.recordCount),
|
||||||
|
gvScore: Math.max(existing.gvScore, incoming.gvScore),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function musicScoreKey(
|
||||||
|
score: Pick<StageResult, 'musicId' | 'fumenType'>
|
||||||
|
): string {
|
||||||
|
return `${score.musicId}:${score.fumenType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUnlocks(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string
|
||||||
|
): UnlockMusicEntry[] {
|
||||||
|
const result: UnlockMusicEntry[] = [];
|
||||||
|
for (const item of reader.elements(path)) {
|
||||||
|
const musicId = parseInt(item.attr().music_id || '0', 10);
|
||||||
|
if (!Number.isFinite(musicId) || musicId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fumens = item
|
||||||
|
.elements('fumen_type')
|
||||||
|
.map(f => normalizeFumen(f.str('', 'BASIC')));
|
||||||
|
result.push({ musicId, fumens: uniqueFumens(fumens) });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readViewFlags(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string
|
||||||
|
): ViewFlagEntry[] {
|
||||||
|
return reader.elements(path).map(item => ({
|
||||||
|
viewId: readString(item, 'view_id'),
|
||||||
|
flag: readNumber(item, 'flag'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMusicUnlockGauges(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string
|
||||||
|
): MusicUnlockGaugeEntry[] {
|
||||||
|
return reader.elements(path).map(item => ({
|
||||||
|
eventId: readString(item, 'event_id'),
|
||||||
|
progress: readNumber(item, 'progress'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDeltaGroup(
|
||||||
|
reader: KDataReader,
|
||||||
|
path: string
|
||||||
|
): DeltaEntry[] {
|
||||||
|
return reader.elements(path).map(item => ({
|
||||||
|
name: readString(item, 'name'),
|
||||||
|
income: readNumber(item, 'extra_income'),
|
||||||
|
expense: readNumber(item, 'extra_expense'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeUnlocks(
|
||||||
|
left: UnlockMusicEntry[],
|
||||||
|
right: UnlockMusicEntry[]
|
||||||
|
): UnlockMusicEntry[] {
|
||||||
|
const merged: { [musicId: string]: FumenType[] } = {};
|
||||||
|
for (const entry of [...left, ...right]) {
|
||||||
|
const key = `${entry.musicId}`;
|
||||||
|
merged[key] = uniqueFumens([...(merged[key] || []), ...entry.fumens]);
|
||||||
|
}
|
||||||
|
return Object.keys(merged)
|
||||||
|
.map(key => ({ musicId: parseInt(key, 10), fumens: merged[key] }))
|
||||||
|
.sort((a, b) => a.musicId - b.musicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueFumens(values: FumenType[]): FumenType[] {
|
||||||
|
return ['BASIC', 'ADVANCED', 'MASTER'].filter(
|
||||||
|
value => values.indexOf(value as FumenType) >= 0
|
||||||
|
) as FumenType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avatarResponse(avatar: AvatarSetting): any {
|
||||||
|
return {
|
||||||
|
physique: K.ITEM('s32', avatar.physique),
|
||||||
|
skin_color: K.ITEM('s32', avatar.skinColor),
|
||||||
|
face: K.ITEM('s32', avatar.face),
|
||||||
|
eye_color: K.ITEM('s32', avatar.eyeColor),
|
||||||
|
hairstyle: K.ITEM('s32', avatar.hairstyle),
|
||||||
|
hair_color: K.ITEM('s32', avatar.hairColor),
|
||||||
|
costume_upper: K.ITEM('s32', avatar.costumeUpper),
|
||||||
|
costume_lower: K.ITEM('s32', avatar.costumeLower),
|
||||||
|
accessory_head: K.ITEM('s32', avatar.accessoryHead),
|
||||||
|
accessory_face: K.ITEM('s32', avatar.accessoryFace),
|
||||||
|
accessory_body: K.ITEM('s32', avatar.accessoryBody),
|
||||||
|
accessory_hand: K.ITEM('s32', avatar.accessoryHand),
|
||||||
|
base_chara: K.ITEM('s32', avatar.baseChara),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playOptionResponse(option: PlayOptionSetting): any {
|
||||||
|
return {
|
||||||
|
note_speed: K.ITEM('s32', option.noteSpeed),
|
||||||
|
note_size: K.ITEM('s32', option.noteSize),
|
||||||
|
judge_se_type: K.ITEM('s32', option.judgeSeType),
|
||||||
|
judge_se_vol: K.ITEM('s32', option.judgeSeVolume),
|
||||||
|
judge_timing: K.ITEM('s32', option.judgeTiming),
|
||||||
|
note_timing: K.ITEM('s32', option.noteTiming),
|
||||||
|
guide: K.ITEM('s32', option.guide),
|
||||||
|
invert: K.ITEM('s32', option.invert),
|
||||||
|
stealth: K.ITEM('s32', option.stealth),
|
||||||
|
timeline_disp: K.ITEM('s32', option.timelineDisplay),
|
||||||
|
avatar_action: K.ITEM('s32', option.avatarAction),
|
||||||
|
timeline_model_dir: K.ITEM('s32', option.timelineModelDirection),
|
||||||
|
avatar_model_dir: K.ITEM('s32', option.avatarModelDirection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dancerGradeResponse(grade: DancerGrade): any {
|
||||||
|
return {
|
||||||
|
grade: K.ITEM('s32', grade.grade),
|
||||||
|
gv_gauge: K.ITEM('s32', grade.gvGauge),
|
||||||
|
achieve_gv: K.ITEM('s32', grade.achievedGv),
|
||||||
|
get_gv: K.ITEM('s32', grade.receivedGv),
|
||||||
|
increase_gv: K.ITEM('s32', grade.increaseGv),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playInfoResponse(info: PlayInfo): any {
|
||||||
|
return {
|
||||||
|
loc_id: K.ITEM('str', info.locationId),
|
||||||
|
mode_id: K.ITEM('s32', info.modeId),
|
||||||
|
style_id: K.ITEM('s32', info.styleId),
|
||||||
|
folder_id: K.ITEM('s32', info.folderId),
|
||||||
|
music_id: K.ITEM('s32', info.musicId),
|
||||||
|
fumen_type: K.ITEM('str', normalizeFumen(info.fumenType)),
|
||||||
|
start_date: K.ITEM('s64', BigInt(info.startDate)),
|
||||||
|
end_date: K.ITEM('s64', BigInt(info.endDate)),
|
||||||
|
light_play_count: K.ITEM('s32', info.lightPlayCount),
|
||||||
|
standard_play_count: K.ITEM('s32', info.standardPlayCount),
|
||||||
|
training_play_count: K.ITEM('s32', info.trainingPlayCount),
|
||||||
|
pcb_id: K.ITEM('str', info.pcbId),
|
||||||
|
softcode: K.ITEM('str', info.softcode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageResultResponse(result: StageResult): any {
|
||||||
|
return {
|
||||||
|
music_id: K.ITEM('s32', result.musicId),
|
||||||
|
fumen_type: K.ITEM('str', result.fumenType),
|
||||||
|
clear_status: K.ITEM('s32', result.clearStatus),
|
||||||
|
score: K.ITEM('s32', result.score),
|
||||||
|
rank: K.ITEM('s32', result.rank),
|
||||||
|
combo: K.ITEM('s32', result.combo),
|
||||||
|
perfect: K.ITEM('s32', result.perfect),
|
||||||
|
great: K.ITEM('s32', result.great),
|
||||||
|
good: K.ITEM('s32', result.good),
|
||||||
|
bad: K.ITEM('s32', result.bad),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function motionInfoResponse(entry: MotionEntry): any {
|
||||||
|
return {
|
||||||
|
motion_id: K.ITEM('str', entry.motionId),
|
||||||
|
is_active: K.ITEM('bool', entry.active),
|
||||||
|
publish_setting: K.ITEM('s32', entry.publishSetting),
|
||||||
|
play_cnt: K.ITEM('s32', entry.playCount),
|
||||||
|
total_gv: K.ITEM('s32', entry.totalGv),
|
||||||
|
...stageResultResponse(entry.playResult),
|
||||||
|
play_date: K.ITEM('s64', BigInt(entry.playDate)),
|
||||||
|
loc_id: K.ITEM('str', entry.locationId),
|
||||||
|
shopname: K.ITEM('str', entry.shopName),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user