mirror of
https://github.com/asphyxia-core/plugins.git
synced 2026-10-08 22:50:13 +03: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 { PLUGIN_VER } from "../const";
|
||||||
import Logger from "../utils/logger"
|
import Logger from "../utils/logger"
|
||||||
import { isAsphyxiaDebugMode, isSharedSongScoresEnabled } from "../utils/index";
|
import { isAsphyxiaDebugMode, isRivalEnabled, isSharedSongScoresEnabled } from "../utils/index";
|
||||||
import { SecretMusicEntry } from "../models/secretmusicentry";
|
import { SecretMusicEntry } from "../models/secretmusicentry";
|
||||||
import { CheckPlayerResponse, getCheckPlayerResponse } from "../models/Responses/checkplayerresponse";
|
import { CheckPlayerResponse, getCheckPlayerResponse } from "../models/Responses/checkplayerresponse";
|
||||||
import { getPlayerStickerResponse, PlayerStickerResponse } from "../models/Responses/playerstickerresponse";
|
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 { getPlayerPlayInfoResponse, PlayerPlayInfoResponse } from "../models/Responses/playerplayinforesponse";
|
||||||
import { getMergedSharedScores, mergeScoresIntoShared } from "./SharedScores";
|
import { getMergedSharedScores, mergeScoresIntoShared } from "./SharedScores";
|
||||||
import { regist as registDelta, check as checkDelta, getPlayer as getPlayerDelta, savePlayers as savePlayersDelta } from "./profiles_delta";
|
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")
|
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) => {
|
export const regist: EPR = async (info, data, send) => {
|
||||||
if (isGalaxyWaveDeltaModel(info.model)) {
|
if (isGalaxyWaveDeltaModel(info.model)) {
|
||||||
return registDelta(info, data, send);
|
return registDelta(info, data, send);
|
||||||
@@ -84,6 +128,32 @@ export const getPlayer: EPR = async (info, data, send) => {
|
|||||||
const time = BigInt(31536000);
|
const time = BigInt(31536000);
|
||||||
const dm = isDM(info);
|
const dm = isDM(info);
|
||||||
const game = dm ? 'dm' : 'gf';
|
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();
|
const sharedScoresEnabled = isSharedSongScoresEnabled();
|
||||||
|
|
||||||
logger.debugInfo(`Loading ${game} profile for player ${no} with refid: ${refid}`)
|
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
|
// Format scores
|
||||||
const musicdata = [];
|
const musicdata = [];
|
||||||
const scores = dm ? dmScores : gfScores;
|
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 }, {
|
musicdata.push(K.ATTR({ musicid }, {
|
||||||
mdata: K.ARRAY('s16', [
|
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.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.2.clear', false) ? _.get(score, 'diffs.2.perc', -2) : -1,
|
||||||
_.get(score, 'diffs.3.clear', false) ? _.get(score, 'diffs.3.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,
|
sticker,
|
||||||
},
|
},
|
||||||
player_info: {
|
player_info: {
|
||||||
player_type: K.ITEM('s8', 0),
|
// The initial profile is player type 0. Each follow-up rival profile is
|
||||||
did: K.ITEM('s32', 13376666),
|
// 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),
|
name: K.ITEM('str', name.name),
|
||||||
title: K.ITEM('str', name.title),
|
title: K.ITEM('str', name.title),
|
||||||
charaid: K.ITEM('s32', 0),
|
charaid: K.ITEM('s32', 0),
|
||||||
@@ -344,6 +450,12 @@ export const getPlayer: EPR = async (info, data, send) => {
|
|||||||
const innerSecretMusic = getSecretMusicResponse(profile)
|
const innerSecretMusic = getSecretMusicResponse(profile)
|
||||||
const innerFriendData = getFriendDataResponse(profile)
|
const innerFriendData = getFriendDataResponse(profile)
|
||||||
const innerBattleData = getDefaultBattleDataResponse()
|
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 = {
|
const response = {
|
||||||
player: K.ATTR({ 'no': `${no}` }, {
|
player: K.ATTR({ 'no': `${no}` }, {
|
||||||
@@ -359,7 +471,7 @@ export const getPlayer: EPR = async (info, data, send) => {
|
|||||||
reward: {
|
reward: {
|
||||||
status: K.ARRAY('u32', extra.reward_status ?? Array(50).fill(0)),
|
status: K.ARRAY('u32', extra.reward_status ?? Array(50).fill(0)),
|
||||||
},
|
},
|
||||||
rivaldata: {},
|
rivaldata: innerRivalData,
|
||||||
frienddata: {
|
frienddata: {
|
||||||
friend: innerFriendData
|
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 { PlayerInfo } from "../models/playerinfo"
|
||||||
|
import { Rival } from "../models/rival"
|
||||||
|
|
||||||
export const updatePlayerInfo = async (data: {
|
export const updatePlayerInfo = async (data: {
|
||||||
refid: string;
|
refid: string;
|
||||||
@@ -25,4 +26,59 @@ export const updatePlayerInfo = async (data: {
|
|||||||
{ collection: 'playerinfo', version: data.version },
|
{ collection: 'playerinfo', version: data.version },
|
||||||
{ $set: update }
|
{ $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 { gameInfoGet, shopInfoRegist } from "./handlers/info";
|
||||||
import { playableMusic } from "./handlers/MusicList"
|
import { playableMusic } from "./handlers/MusicList"
|
||||||
import { getPlayer, check, regist, savePlayers } from "./handlers/profiles";
|
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 { isAsphyxiaDebugMode, isRequiredCoreVersion } from "./utils";
|
||||||
import Logger from "./utils/logger";
|
import Logger from "./utils/logger";
|
||||||
|
|
||||||
@@ -61,6 +61,13 @@ export function register() {
|
|||||||
default: false,
|
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", {
|
R.DataFile("data/mdb/custom.xml", {
|
||||||
accept: ".xml",
|
accept: ".xml",
|
||||||
name: "Custom MDB",
|
name: "Custom MDB",
|
||||||
@@ -68,6 +75,7 @@ export function register() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
R.WebUIEvent('updatePlayerInfo', updatePlayerInfo);
|
R.WebUIEvent('updatePlayerInfo', updatePlayerInfo);
|
||||||
|
R.WebUIEvent('updateRival', updateRival);
|
||||||
|
|
||||||
const MultiRoute = (method: string, handler: EPR | boolean) => {
|
const MultiRoute = (method: string, handler: EPR | boolean) => {
|
||||||
// Helper for register multiple versions.
|
// Helper for register multiple versions.
|
||||||
@@ -103,5 +111,6 @@ export function register() {
|
|||||||
if (["eventlog"].includes(info.module)) return;
|
if (["eventlog"].includes(info.module)) return;
|
||||||
logger.error(`Received Unhandled Request on Method "${info.method}" by ${info.model}/${info.module}`)
|
logger.error(`Received Unhandled Request on Method "${info.method}" by ${info.model}/${info.module}`)
|
||||||
logger.debugError(`Received Request: ${JSON.stringify(data, null, 4)}`)
|
logger.debugError(`Received Request: ${JSON.stringify(data, null, 4)}`)
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
export function isSharedSongScoresEnabled() : boolean{
|
||||||
return Boolean(U.GetConfig("shared_song_scores"))
|
return Boolean(U.GetConfig("shared_song_scores"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isRivalEnabled() : boolean {
|
||||||
|
return Boolean(U.GetConfig("enable_rivals"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//DATA//
|
//DATA//
|
||||||
info: DB.Find(refid, { collection: 'playerinfo' })
|
info: DB.Find(refid, { collection: 'playerinfo' })
|
||||||
profile: DB.Find(refid, { collection: 'profile' })
|
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
|
.field
|
||||||
label.label Sessions
|
label.label Sessions
|
||||||
.control
|
.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