mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-09-12 13:15:50 -05:00
Merge pull request #89 from Avimitin/dance-around-support
Add DANCE aROUND game service
This commit is contained in:
7
dancearound@asphyxia/README.md
Normal file
7
dancearound@asphyxia/README.md
Normal file
@@ -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.
|
||||
127
dancearound@asphyxia/handlers/common.ts
Normal file
127
dancearound@asphyxia/handlers/common.ts
Normal file
@@ -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();
|
||||
};
|
||||
208
dancearound@asphyxia/handlers/motion.ts
Normal file
208
dancearound@asphyxia/handlers/motion.ts
Normal file
@@ -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 }
|
||||
);
|
||||
};
|
||||
337
dancearound@asphyxia/handlers/profile.ts
Normal file
337
dancearound@asphyxia/handlers/profile.ts
Normal file
@@ -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 }
|
||||
);
|
||||
};
|
||||
70
dancearound@asphyxia/index.ts
Normal file
70
dancearound@asphyxia/index.ts
Normal file
@@ -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');
|
||||
}
|
||||
391
dancearound@asphyxia/mdb.ts
Normal file
391
dancearound@asphyxia/mdb.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
166
dancearound@asphyxia/models.ts
Normal file
166
dancearound@asphyxia/models.ts
Normal file
@@ -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;
|
||||
}
|
||||
474
dancearound@asphyxia/utils.ts
Normal file
474
dancearound@asphyxia/utils.ts
Normal file
@@ -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