mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-09-22 22:27:56 +03:00
Add GITADORA rival support
Implement rival registration, profile responses, and Web UI controls. The UI is functional only; visual design and polish are not included in this change.
This commit is contained in:
@@ -10,7 +10,7 @@ import { getDefaultScores, Scores } from "../models/scores";
|
||||
|
||||
import { PLUGIN_VER } from "../const";
|
||||
import Logger from "../utils/logger"
|
||||
import { isAsphyxiaDebugMode, isSharedSongScoresEnabled } from "../utils/index";
|
||||
import { isAsphyxiaDebugMode, isRivalEnabled, isSharedSongScoresEnabled } from "../utils/index";
|
||||
import { SecretMusicEntry } from "../models/secretmusicentry";
|
||||
import { CheckPlayerResponse, getCheckPlayerResponse } from "../models/Responses/checkplayerresponse";
|
||||
import { getPlayerStickerResponse, PlayerStickerResponse } from "../models/Responses/playerstickerresponse";
|
||||
@@ -22,9 +22,53 @@ import { getPlayerRecordResponse } from "../models/Responses/playerrecordrespons
|
||||
import { getPlayerPlayInfoResponse, PlayerPlayInfoResponse } from "../models/Responses/playerplayinforesponse";
|
||||
import { getMergedSharedScores, mergeScoresIntoShared } from "./SharedScores";
|
||||
import { regist as registDelta, check as checkDelta, getPlayer as getPlayerDelta, savePlayers as savePlayersDelta } from "./profiles_delta";
|
||||
import { getRivalDataResponse } from "./rival";
|
||||
import { Rival } from "../models/rival";
|
||||
import { findMDBFile, loadSongsForGameVersion, readMDBFile } from "../data/mdb";
|
||||
|
||||
const logger = new Logger("profiles")
|
||||
|
||||
// gametop.get first loads the card owner, then loads each listed rival with
|
||||
// the same request_key and player.is_rival=1. Keep that short-lived context so
|
||||
// every response preserves the owner's rival list instead of replacing it
|
||||
// with the fetched rival's own list.
|
||||
const rivalRequestOwners = new Map<string, { refid: string; expiresAt: number }>();
|
||||
const RIVAL_REQUEST_OWNER_TTL_MS = 5 * 60 * 1000;
|
||||
const rivalSkillHotMapCache = new Map<string, Promise<Map<number, boolean>>>();
|
||||
|
||||
async function getRivalSkillHotMap(version: string): Promise<Map<number, boolean>> {
|
||||
const customEnabled = Boolean(U.GetConfig("enable_custom_mdb"));
|
||||
const cacheKey = `${version}:${customEnabled ? "custom" : "default"}`;
|
||||
let pending = rivalSkillHotMapCache.get(cacheKey);
|
||||
if (!pending) {
|
||||
pending = (async () => {
|
||||
let music = [];
|
||||
if (customEnabled) {
|
||||
const customMdb = findMDBFile("custom");
|
||||
if (customMdb) music = (await readMDBFile(customMdb)).music;
|
||||
}
|
||||
if (music.length === 0) {
|
||||
music = (await loadSongsForGameVersion(version)).music;
|
||||
}
|
||||
|
||||
const result = new Map<number, boolean>();
|
||||
for (const entry of music) {
|
||||
const musicId = Number(entry.id["@content"][0]);
|
||||
result.set(musicId, Boolean(entry.is_hot["@content"][0]));
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
rivalSkillHotMapCache.set(cacheKey, pending);
|
||||
}
|
||||
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error) {
|
||||
rivalSkillHotMapCache.delete(cacheKey);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const regist: EPR = async (info, data, send) => {
|
||||
if (isGalaxyWaveDeltaModel(info.model)) {
|
||||
return registDelta(info, data, send);
|
||||
@@ -84,6 +128,32 @@ export const getPlayer: EPR = async (info, data, send) => {
|
||||
const time = BigInt(31536000);
|
||||
const dm = isDM(info);
|
||||
const game = dm ? 'dm' : 'gf';
|
||||
const requestedAsRival = $(data).bool('player.is_rival');
|
||||
const requestKey = $(data).str('request_key');
|
||||
const now = Date.now();
|
||||
for (const [key, context] of rivalRequestOwners) {
|
||||
if (context.expiresAt <= now) rivalRequestOwners.delete(key);
|
||||
}
|
||||
|
||||
let rivalOwnerRefid = refid;
|
||||
if (requestKey) {
|
||||
if (requestedAsRival) {
|
||||
rivalOwnerRefid = rivalRequestOwners.get(requestKey)?.refid ?? refid;
|
||||
} else {
|
||||
rivalRequestOwners.set(requestKey, {
|
||||
refid,
|
||||
expiresAt: now + RIVAL_REQUEST_OWNER_TTL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
const targetRivalSlot = requestedAsRival
|
||||
? (await DB.FindOne<Rival>(rivalOwnerRefid, {
|
||||
collection: 'rival',
|
||||
version,
|
||||
game,
|
||||
rival_refid: refid,
|
||||
}))?.slot ?? 1
|
||||
: 0;
|
||||
const sharedScoresEnabled = isSharedSongScoresEnabled();
|
||||
|
||||
logger.debugInfo(`Loading ${game} profile for player ${no} with refid: ${refid}`)
|
||||
@@ -113,10 +183,43 @@ export const getPlayer: EPR = async (info, data, send) => {
|
||||
// Format scores
|
||||
const musicdata = [];
|
||||
const scores = dm ? dmScores : gfScores;
|
||||
for (const [musicid, score] of _.entries(scores)) {
|
||||
// FUZZ-UP's rival-skill builder only uses records whose mdata[0] is
|
||||
// positive. Put the highest skill records first so the client receives the
|
||||
// actual skill targets instead of whichever music IDs are enumerated first.
|
||||
let scoreEntries = _.entries(scores);
|
||||
if (requestedAsRival) {
|
||||
// A failed play still creates a score document, but FUZZ-UP may use the
|
||||
// rival musiclist count when deciding whether a rival has skill targets.
|
||||
// Do not expose zero-skill placeholder records as rival skill data.
|
||||
scoreEntries = scoreEntries.filter(([, score]) =>
|
||||
_.get(score, 'update.1', 0) > 0
|
||||
);
|
||||
scoreEntries.sort(([, left], [, right]) =>
|
||||
_.get(right, 'update.1', 0) - _.get(left, 'update.1', 0)
|
||||
);
|
||||
try {
|
||||
const hotMap = await getRivalSkillHotMap(version);
|
||||
const hotScores = scoreEntries.filter(([musicId]) =>
|
||||
hotMap.get(Number(musicId)) === true
|
||||
);
|
||||
const otherScores = scoreEntries.filter(([musicId]) =>
|
||||
hotMap.get(Number(musicId)) === false
|
||||
);
|
||||
// FUZZ-UP skill is the best 25 HOT songs plus the best 25 OTHER songs.
|
||||
scoreEntries = hotScores.slice(0, 25).concat(otherScores.slice(0, 25));
|
||||
} catch (error) {
|
||||
// Keep login usable if the MDB cannot be classified. Fifty entries are
|
||||
// still below the client's physical 53-entry rival buffer.
|
||||
logger.warn("Unable to classify rival skill songs with MDB; using overall top 50.");
|
||||
logger.debugWarn(error?.stack ?? error);
|
||||
scoreEntries = scoreEntries.slice(0, 50);
|
||||
}
|
||||
}
|
||||
for (const [musicid, score] of scoreEntries) {
|
||||
const newSkill = _.get(score, 'update.1', 0);
|
||||
musicdata.push(K.ATTR({ musicid }, {
|
||||
mdata: K.ARRAY('s16', [
|
||||
-1,
|
||||
requestedAsRival && newSkill > 0 ? 1 : -1,
|
||||
_.get(score, 'diffs.1.clear', false) ? _.get(score, 'diffs.1.perc', -2) : -1,
|
||||
_.get(score, 'diffs.2.clear', false) ? _.get(score, 'diffs.2.perc', -2) : -1,
|
||||
_.get(score, 'diffs.3.clear', false) ? _.get(score, 'diffs.3.perc', -2) : -1,
|
||||
@@ -199,8 +302,11 @@ export const getPlayer: EPR = async (info, data, send) => {
|
||||
sticker,
|
||||
},
|
||||
player_info: {
|
||||
player_type: K.ITEM('s8', 0),
|
||||
did: K.ITEM('s32', 13376666),
|
||||
// The initial profile is player type 0. Each follow-up rival profile is
|
||||
// copied into its own Rival 1..5 slot by the client, so identify it with
|
||||
// the owner's one-based registered slot rather than a shared type.
|
||||
player_type: K.ITEM('s8', targetRivalSlot),
|
||||
did: K.ITEM('s32', name.id),
|
||||
name: K.ITEM('str', name.name),
|
||||
title: K.ITEM('str', name.title),
|
||||
charaid: K.ITEM('s32', 0),
|
||||
@@ -344,6 +450,12 @@ export const getPlayer: EPR = async (info, data, send) => {
|
||||
const innerSecretMusic = getSecretMusicResponse(profile)
|
||||
const innerFriendData = getFriendDataResponse(profile)
|
||||
const innerBattleData = getDefaultBattleDataResponse()
|
||||
// The client also needs rivaldata on its follow-up is_rival=1 profile
|
||||
// response. Omitting it lets the request complete but prevents the rival
|
||||
// list from being displayed.
|
||||
const innerRivalData = isRivalEnabled()
|
||||
? await getRivalDataResponse(rivalOwnerRefid, version, game)
|
||||
: {};
|
||||
|
||||
const response = {
|
||||
player: K.ATTR({ 'no': `${no}` }, {
|
||||
@@ -359,7 +471,7 @@ export const getPlayer: EPR = async (info, data, send) => {
|
||||
reward: {
|
||||
status: K.ARRAY('u32', extra.reward_status ?? Array(50).fill(0)),
|
||||
},
|
||||
rivaldata: {},
|
||||
rivaldata: innerRivalData,
|
||||
frienddata: {
|
||||
friend: innerFriendData
|
||||
},
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { PlayerInfo } from "../models/playerinfo";
|
||||
import { Profile } from "../models/profile";
|
||||
import { Rival } from "../models/rival";
|
||||
|
||||
type Game = 'gf' | 'dm';
|
||||
|
||||
/**
|
||||
* Build the persistent rival list included in gametop.get.
|
||||
* This is unrelated to online/session matching: it only supplies the local
|
||||
* client's registered-rival profiles and skill data.
|
||||
*/
|
||||
export async function getRivalDataResponse(refid: string, version: string, game: Game) {
|
||||
const savedRivals = await DB.Find<Rival>(refid, {
|
||||
collection: 'rival',
|
||||
version,
|
||||
game,
|
||||
});
|
||||
|
||||
const rivals = savedRivals
|
||||
.filter(rival => rival.rival_refid && rival.rival_refid !== refid)
|
||||
.sort((a, b) => (a.slot ?? 0) - (b.slot ?? 0));
|
||||
const responseRivals: any[] = [];
|
||||
|
||||
for (const rival of rivals) {
|
||||
const rivalRefid = rival.rival_refid;
|
||||
const playerInfo = await DB.FindOne<PlayerInfo>(rivalRefid, {
|
||||
collection: 'playerinfo',
|
||||
version,
|
||||
});
|
||||
if (!playerInfo) continue;
|
||||
|
||||
// FUZZ-UP leaves an empty registered rival slot active and can expose
|
||||
// music ID 0 as a bogus one-song Rival 5 skill folder. Only advertise
|
||||
// targets that can provide a real skill list.
|
||||
const skillProfile = await DB.FindOne<Profile>(rivalRefid, {
|
||||
collection: 'profile',
|
||||
version,
|
||||
game,
|
||||
});
|
||||
if (!skillProfile || ((skillProfile.skill ?? 0) <= 0 && (skillProfile.all_skill ?? 0) <= 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FUZZ-UP reads these four direct children from each repeated rival node.
|
||||
responseRivals.push({
|
||||
did: K.ITEM('s32', playerInfo.id),
|
||||
name: K.ITEM('str', playerInfo.name),
|
||||
active_index: K.ITEM('s32', rival.slot ?? 1),
|
||||
refid: K.ITEM('str', rivalRefid),
|
||||
});
|
||||
}
|
||||
|
||||
return { rival: responseRivals };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PlayerInfo } from "../models/playerinfo"
|
||||
import { Rival } from "../models/rival"
|
||||
|
||||
export const updatePlayerInfo = async (data: {
|
||||
refid: string;
|
||||
@@ -25,4 +26,59 @@ export const updatePlayerInfo = async (data: {
|
||||
{ collection: 'playerinfo', version: data.version },
|
||||
{ $set: update }
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the rival list for one game/version pair.
|
||||
*
|
||||
* Rival documents live in the owner's profile space, so removing a player from
|
||||
* Asphyxia also removes their outgoing rival list automatically.
|
||||
*/
|
||||
export const updateRival = async (data: {
|
||||
refid: string;
|
||||
version: string;
|
||||
game: 'gf' | 'dm';
|
||||
rival1?: string;
|
||||
rival2?: string;
|
||||
rival3?: string;
|
||||
rival4?: string;
|
||||
rival5?: string;
|
||||
}) => {
|
||||
if (!data.refid || !data.version || (data.game !== 'gf' && data.game !== 'dm')) return;
|
||||
|
||||
const requestedRefids = [data.rival1, data.rival2, data.rival3, data.rival4, data.rival5];
|
||||
const seenRefids = new Set<string>();
|
||||
const rivalSlots = requestedRefids.flatMap((input, index) => {
|
||||
const rivalRefid = input?.trim();
|
||||
if (!rivalRefid || rivalRefid === data.refid || seenRefids.has(rivalRefid)) return [];
|
||||
seenRefids.add(rivalRefid);
|
||||
return [{ rivalRefid, slot: index + 1 }];
|
||||
});
|
||||
|
||||
// Store only players that have a profile in the same game version. This keeps
|
||||
// the later game response from referencing a player whose DID/profile is absent.
|
||||
const validRivalSlots: Array<{ rivalRefid: string; slot: number }> = [];
|
||||
for (const rival of rivalSlots) {
|
||||
const player = await DB.FindOne<PlayerInfo>(rival.rivalRefid, {
|
||||
collection: 'playerinfo',
|
||||
version: data.version,
|
||||
});
|
||||
if (player) validRivalSlots.push(rival);
|
||||
}
|
||||
|
||||
await DB.Remove<Rival>(data.refid, {
|
||||
collection: 'rival',
|
||||
game: data.game,
|
||||
version: data.version,
|
||||
});
|
||||
|
||||
for (const { rivalRefid: rival_refid, slot } of validRivalSlots) {
|
||||
await DB.Insert<Rival>(data.refid, {
|
||||
collection: 'rival',
|
||||
game: data.game,
|
||||
version: data.version,
|
||||
rival_refid,
|
||||
slot,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user