mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-09-12 05:05:49 -05:00
Merge pull request #88 from jjk1227/feat/gitadora-rival-support
Add GITADORA rival support
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
|
||||
},
|
||||
|
||||
54
gitadora@asphyxia/handlers/rival.ts
Normal file
54
gitadora@asphyxia/handlers/rival.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
8
gitadora@asphyxia/models/rival.ts
Normal file
8
gitadora@asphyxia/models/rival.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Rival {
|
||||
collection: 'rival';
|
||||
|
||||
game: 'gf' | 'dm';
|
||||
version: string;
|
||||
rival_refid: string;
|
||||
slot: number;
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user