From 6768ed89b5131ac206858284e80ebe1b00bcc8d7 Mon Sep 17 00:00:00 2001 From: jjk1227 <16343872+jjk1227@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:28:56 +0900 Subject: [PATCH] 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. --- gitadora@asphyxia/handlers/profiles.ts | 124 +++++++++++++++++- gitadora@asphyxia/handlers/rival.ts | 54 ++++++++ gitadora@asphyxia/handlers/webui.ts | 58 +++++++- gitadora@asphyxia/index.ts | 13 +- gitadora@asphyxia/models/rival.ts | 8 ++ gitadora@asphyxia/utils/index.ts | 4 + .../webui/profile_player_info.pug | 38 +++++- 7 files changed, 289 insertions(+), 10 deletions(-) create mode 100644 gitadora@asphyxia/handlers/rival.ts create mode 100644 gitadora@asphyxia/models/rival.ts diff --git a/gitadora@asphyxia/handlers/profiles.ts b/gitadora@asphyxia/handlers/profiles.ts index 74bd902..9066bb5 100644 --- a/gitadora@asphyxia/handlers/profiles.ts +++ b/gitadora@asphyxia/handlers/profiles.ts @@ -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(); +const RIVAL_REQUEST_OWNER_TTL_MS = 5 * 60 * 1000; +const rivalSkillHotMapCache = new Map>>(); + +async function getRivalSkillHotMap(version: string): Promise> { + 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(); + 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(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 }, diff --git a/gitadora@asphyxia/handlers/rival.ts b/gitadora@asphyxia/handlers/rival.ts new file mode 100644 index 0000000..4236b0d --- /dev/null +++ b/gitadora@asphyxia/handlers/rival.ts @@ -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(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(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(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 }; +} diff --git a/gitadora@asphyxia/handlers/webui.ts b/gitadora@asphyxia/handlers/webui.ts index 04297bf..795ae07 100644 --- a/gitadora@asphyxia/handlers/webui.ts +++ b/gitadora@asphyxia/handlers/webui.ts @@ -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 } ); -}; \ No newline at end of file +}; + +/** + * 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(); + 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(rival.rivalRefid, { + collection: 'playerinfo', + version: data.version, + }); + if (player) validRivalSlots.push(rival); + } + + await DB.Remove(data.refid, { + collection: 'rival', + game: data.game, + version: data.version, + }); + + for (const { rivalRefid: rival_refid, slot } of validRivalSlots) { + await DB.Insert(data.refid, { + collection: 'rival', + game: data.game, + version: data.version, + rival_refid, + slot, + }); + } +}; diff --git a/gitadora@asphyxia/index.ts b/gitadora@asphyxia/index.ts index 442839f..3d64cb3 100644 --- a/gitadora@asphyxia/index.ts +++ b/gitadora@asphyxia/index.ts @@ -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)}`) + }) -} \ No newline at end of file +} diff --git a/gitadora@asphyxia/models/rival.ts b/gitadora@asphyxia/models/rival.ts new file mode 100644 index 0000000..13a8900 --- /dev/null +++ b/gitadora@asphyxia/models/rival.ts @@ -0,0 +1,8 @@ +export interface Rival { + collection: 'rival'; + + game: 'gf' | 'dm'; + version: string; + rival_refid: string; + slot: number; +} diff --git a/gitadora@asphyxia/utils/index.ts b/gitadora@asphyxia/utils/index.ts index 2330da3..abd351d 100644 --- a/gitadora@asphyxia/utils/index.ts +++ b/gitadora@asphyxia/utils/index.ts @@ -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")) +} diff --git a/gitadora@asphyxia/webui/profile_player_info.pug b/gitadora@asphyxia/webui/profile_player_info.pug index 1922d0b..d8ff39e 100644 --- a/gitadora@asphyxia/webui/profile_player_info.pug +++ b/gitadora@asphyxia/webui/profile_player_info.pug @@ -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) \ No newline at end of file + 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