Merge pull request #88 from jjk1227/feat/gitadora-rival-support

Add GITADORA rival support
This commit is contained in:
Freddie W
2026-08-21 16:41:51 +08:00
committed by GitHub
7 changed files with 289 additions and 10 deletions

View File

@@ -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
},

View File

@@ -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 };
}

View File

@@ -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,
});
}
};

View File

@@ -1,7 +1,7 @@
import { gameInfoGet, shopInfoRegist } from "./handlers/info";
import { playableMusic } from "./handlers/MusicList"
import { getPlayer, check, regist, savePlayers } from "./handlers/profiles";
import { updatePlayerInfo } from "./handlers/webui";
import { updatePlayerInfo, updateRival } from "./handlers/webui";
import { isAsphyxiaDebugMode, isRequiredCoreVersion } from "./utils";
import Logger from "./utils/logger";
@@ -61,6 +61,13 @@ export function register() {
default: false,
})
R.Config("enable_rivals", {
name: "Enable Rivals",
desc: "Enable registered rivals and their skill data.",
type: "boolean",
default: false,
})
R.DataFile("data/mdb/custom.xml", {
accept: ".xml",
name: "Custom MDB",
@@ -68,6 +75,7 @@ export function register() {
})
R.WebUIEvent('updatePlayerInfo', updatePlayerInfo);
R.WebUIEvent('updateRival', updateRival);
const MultiRoute = (method: string, handler: EPR | boolean) => {
// Helper for register multiple versions.
@@ -103,5 +111,6 @@ export function register() {
if (["eventlog"].includes(info.module)) return;
logger.error(`Received Unhandled Request on Method "${info.method}" by ${info.model}/${info.module}`)
logger.debugError(`Received Request: ${JSON.stringify(data, null, 4)}`)
})
}
}

View File

@@ -0,0 +1,8 @@
export interface Rival {
collection: 'rival';
game: 'gf' | 'dm';
version: string;
rival_refid: string;
slot: number;
}

View File

@@ -44,3 +44,7 @@ export function isSharedFavoriteMusicEnabled() : boolean{
export function isSharedSongScoresEnabled() : boolean{
return Boolean(U.GetConfig("shared_song_scores"))
}
export function isRivalEnabled() : boolean {
return Boolean(U.GetConfig("enable_rivals"))
}

View File

@@ -1,6 +1,8 @@
//DATA//
info: DB.Find(refid, { collection: 'playerinfo' })
profile: DB.Find(refid, { collection: 'profile' })
rivals: DB.Find(refid, { collection: 'rival' })
all_players: DB.Find(null, { collection: 'playerinfo' })
-
-
@@ -116,4 +118,38 @@ div
.field
label.label Sessions
.control
input.input(type="text" name="session_cnt", value=pr.session_cnt readonly)
input.input(type="text" name="session_cnt", value=pr.session_cnt readonly)
.card
.card-header
p.card-header-title
span.icon
i.mdi.mdi-account-multiple
| Rivals (#{getFullGameName(pr.game)} #{getFullGameVersion(pr.version)})
.card-content
-
const selectedRivals = (rivals || [])
.filter(r => r.game === pr.game && r.version === pr.version)
.sort((a, b) => (a.slot || 0) - (b.slot || 0))
.map(r => r.rival_refid);
const candidates = (all_players || []).filter(p =>
p.__refid !== refid && p.version === pr.version);
form(method="post" action="/emit/updateRival")
input(type="hidden" name="refid" value=refid)
input(type="hidden" name="version" value=pr.version)
input(type="hidden" name="game" value=pr.game)
p.help Select up to five distinct players. Only players with the same game version are shown.
each slot in [1, 2, 3, 4, 5]
.field
label.label Rival #{slot}
.control
.select.is-fullwidth
select(name=`rival${slot}`)
option(value="") None
each candidate in candidates
option(value=candidate.__refid selected=(selectedRivals[slot - 1] === candidate.__refid)) #{candidate.name} (DID: #{candidate.id})
.field
button.button.is-primary(type="submit")
span.icon
i.mdi.mdi-check
span Save rivals