Commit 3ca2aca1 authored by nanahira's avatar nanahira

add challonge

parent 3ec356e0
Pipeline #43300 passed with stages
in 1 minute and 33 seconds
......@@ -10,7 +10,8 @@ dbName: srvpro2
dbNoInit: 0
redisUrl: ""
logLevel: info
wsPort: 0
wsHost: ""
wsPort: 7912
enableSsl: 0
sslPath: ./ssl
sslCert: ""
......@@ -66,6 +67,14 @@ enableReconnect: 1
enableCloudReplay: 1
tournamentMode: 0
tournamentModeCheckDeck: 1
challongeEnabled: 0
challongeNoMatchMode: 0
challongePostDetailedScore: 1
challongePostScoreMidduel: 1
challongeCacheTtl: 60000
challongeApiKey: ""
challongeTournamentId: ""
challongeUrl: https://api.challonge.com
blockReplayToPlayer: 0
enableRoomlist: 1
reconnectTimeout: 180000
......
......@@ -154,6 +154,27 @@ export const defaultConfig = {
// Enable tournament mode deck lock check hook.
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
TOURNAMENT_MODE_CHECK_DECK: '1',
// Enable Challonge integration.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
CHALLONGE_ENABLED: '0',
// Disable challonge room name "M#" prefix and use pure match id as room name.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
CHALLONGE_NO_MATCH_MODE: '0',
// Post detailed match score to Challonge (for example 2-1).
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
CHALLONGE_POST_DETAILED_SCORE: '1',
// Post score at siding stage without winner_id (midduel sync).
// Boolean parse rule (default true): only '0'/'false'/'null' => false, otherwise true.
CHALLONGE_POST_SCORE_MIDDUEL: '1',
// Challonge tournament cache TTL in milliseconds.
// Format: integer string in milliseconds (ms).
CHALLONGE_CACHE_TTL: '60000',
// Challonge API key.
CHALLONGE_API_KEY: '',
// Challonge tournament id/slug.
CHALLONGE_TOURNAMENT_ID: '',
// Challonge API base URL.
CHALLONGE_URL: 'https://api.challonge.com',
// Block replay packets to players who are currently in a room.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
BLOCK_REPLAY_TO_PLAYER: '0',
......
......@@ -18,6 +18,7 @@ export const TRANSLATIONS = {
replay_hint_part1: 'Sending the replay of the duel number ',
replay_hint_part2: '.',
watch_join: 'joined as spectator.',
cannot_to_observer: 'Spectating is not allowed in a matchup.',
quit_watch: 'quited spectating',
left_game: 'quited game',
disconnect_from_game: 'disconnected from the game',
......@@ -130,6 +131,8 @@ export const TRANSLATIONS = {
cloud_replay_playing: 'Accessing cloud replay',
cloud_replay_hint:
'These are your recent cloud replays. Select one from the menu to continue.',
cloud_replay_delay_part1: 'The replay code for last duel is ',
cloud_replay_delay_part2: '. It can be accessed after this match.',
cloud_replay_detail_time: 'Time: ',
cloud_replay_detail_players: 'Duel: ',
cloud_replay_detail_score: 'Score: ',
......@@ -139,6 +142,15 @@ export const TRANSLATIONS = {
cloud_replay_menu_play: 'Play Cloud Replay',
cloud_replay_menu_download_yrp: 'Download YRP Replay',
cloud_replay_menu_back: 'Back',
challonge_user_not_found: 'You are not a participant of the tournament.',
challonge_match_load_failed: 'Failed loading tournament info.',
challonge_match_not_found: 'Your current match was not found.',
challonge_match_already_finished:
'Your current match was already finished. Please call the judge for any help.',
challonge_match_created:
'A room for match only is created. Your opponent will join in automatically.',
challonge_player_already_in:
'Please do not enter the room you are already in.',
death_cancel: 'Over-match has been canceled from this game.',
death_start:
'Over-time match has begun, player with higher LP will win the single game after 4 turns.',
......@@ -184,6 +196,7 @@ export const TRANSLATIONS = {
replay_hint_part1: '正在发送第',
replay_hint_part2: '局决斗的录像。',
watch_join: '加入了观战',
cannot_to_observer: '匹配模式中决斗者不允许观战。',
quit_watch: '退出了观战',
left_game: '离开了游戏',
disconnect_from_game: '断开了连接',
......@@ -287,6 +300,8 @@ export const TRANSLATIONS = {
cloud_replay_error: '播放录像出错',
cloud_replay_playing: '正在观看云录像',
cloud_replay_hint: '以下是您近期的云录像,请在菜单中选择一条继续。',
cloud_replay_delay_part1: '本场比赛云录像:',
cloud_replay_delay_part2: '。将于本局结束后可播放。',
cloud_replay_detail_time: '时间:',
cloud_replay_detail_players: '对局:',
cloud_replay_detail_score: '比分:',
......@@ -296,9 +311,18 @@ export const TRANSLATIONS = {
cloud_replay_menu_play: '播放云录像',
cloud_replay_menu_download_yrp: '下载 YRP 录像',
cloud_replay_menu_back: '返回',
challonge_user_not_found: '未找到你的参赛信息。',
challonge_match_load_failed: '读取比赛信息失败。',
challonge_match_not_found: '你没有当前轮次的比赛。',
challonge_match_already_finished:
'你在当前轮次的比赛已经结束,如需重赛,请联系裁判。',
challonge_match_created: '已建立比赛专用房间,将会自动匹配你的对手。',
challonge_player_already_in: '请不要重复加入比赛房间。',
death_cancel: '已取消本房间的加时赛状态。',
death_start: '加时赛开始,从本回合开始计算4回合,基本分高的玩家获得本次决斗的胜利。',
death_start_siding: '加时赛开始,下次决斗的第4回合结束时,基本分高的玩家决斗胜利。',
death_start:
'加时赛开始,从本回合开始计算4回合,基本分高的玩家获得本次决斗的胜利。',
death_start_siding:
'加时赛开始,下次决斗的第4回合结束时,基本分高的玩家决斗胜利。',
death_start_final:
'本次决斗将进入猝死赛,基本分发生变动的回合结束时,基本分高的玩家将获得本次决斗的胜利。',
death_start_extra:
......
import axios, { AxiosInstance } from 'axios';
import PQueue from 'p-queue';
export interface Match {
id: number;
state: 'pending' | 'open' | 'complete';
player1_id: number;
player2_id: number;
winner_id?: number | 'tie';
scores_csv?: string;
}
export interface MatchWrapper {
match: Match;
}
export interface Participant {
id: number;
name: string;
deckbuf?: string;
}
export interface ParticipantWrapper {
participant: Participant;
}
export interface Tournament {
id: number;
participants: ParticipantWrapper[];
matches: MatchWrapper[];
}
export interface TournamentWrapper {
tournament: Tournament;
}
export interface MatchPost {
scores_csv: string;
winner_id?: number | 'tie';
}
export interface ChallongeConfig {
api_key: string;
tournament_id: string;
cache_ttl: number;
challonge_url: string;
}
export type ChallongeLogger = {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
const NOOP_LOGGER: ChallongeLogger = {
info: () => {},
warn: () => {},
error: () => {},
};
export class Challonge {
constructor(
private config: ChallongeConfig,
private http: AxiosInstance = axios.create(),
private logger: ChallongeLogger = NOOP_LOGGER,
) {}
private queue = new PQueue({ concurrency: 1 });
private previous?: Tournament;
private previousTime = 0;
private get tournamentEndpoint() {
const root = this.config.challonge_url.replace(/\/+$/, '');
return `${root}/v1/tournaments/${this.config.tournament_id}.json`;
}
private async getTournamentProcess(noCache = false) {
const now = Date.now();
if (
!noCache &&
this.previous &&
now - this.previousTime <= this.config.cache_ttl
) {
return this.previous;
}
try {
const {
data: { tournament },
} = await this.http.get<TournamentWrapper>(this.tournamentEndpoint, {
params: {
api_key: this.config.api_key,
include_participants: 1,
include_matches: 1,
},
timeout: 5000,
});
this.previous = tournament;
this.previousTime = Date.now();
return tournament;
} catch (e: unknown) {
this.logger.error(
`Failed to get tournament ${this.config.tournament_id}: ${String(e)}`,
);
return undefined;
}
}
async getTournament(noCache = false) {
if (noCache) {
return this.getTournamentProcess(noCache);
}
return this.queue.add(() => this.getTournamentProcess(noCache));
}
async putScore(matchId: number, match: MatchPost, retried = 0) {
try {
const root = this.config.challonge_url.replace(/\/+$/, '');
await this.http.put(
`${root}/v1/tournaments/${this.config.tournament_id}/matches/${matchId}.json`,
{
api_key: this.config.api_key,
match,
},
);
this.previous = undefined;
this.previousTime = 0;
return true;
} catch (e: unknown) {
this.logger.error(
`Failed to put score for match ${matchId}: ${String(e)}`,
);
if (retried < 5) {
this.logger.info(`Retrying match ${matchId}`);
return this.putScore(matchId, match, retried + 1);
}
this.logger.error(
`Failed to put score for match ${matchId} after 5 retries`,
);
return false;
}
}
async clearParticipants() {
try {
const root = this.config.challonge_url.replace(/\/+$/, '');
await this.http.delete(
`${root}/v1/tournaments/${this.config.tournament_id}/participants/clear.json`,
{
params: {
api_key: this.config.api_key,
},
validateStatus: () => true,
},
);
this.previous = undefined;
this.previousTime = 0;
return true;
} catch (e: unknown) {
this.logger.error(
`Failed to clear participants for tournament ${this.config.tournament_id}: ${String(e)}`,
);
return false;
}
}
async uploadParticipants(participants: { name: string; deckbuf?: string }[]) {
try {
const root = this.config.challonge_url.replace(/\/+$/, '');
await this.http.post(
`${root}/v1/tournaments/${this.config.tournament_id}/participants/bulk_add.json`,
{
api_key: this.config.api_key,
participants,
},
);
this.previous = undefined;
this.previousTime = 0;
return true;
} catch (e: unknown) {
this.logger.error(
`Failed to upload participants for tournament ${this.config.tournament_id}: ${String(e)}`,
);
return false;
}
}
}
This diff is collapsed.
......@@ -153,6 +153,7 @@ export class CloudReplayService {
});
await duelRecordRepo.save(record);
await this.trySendTournamentReplayHint(room, record.id);
} catch (error) {
this.logger.warn(
{
......@@ -164,6 +165,16 @@ export class CloudReplayService {
}
}
private async trySendTournamentReplayHint(room: Room, replayId: number) {
if (!this.ctx.config.getBoolean('TOURNAMENT_MODE')) {
return;
}
await room.sendChat(
`#{cloud_replay_delay_part1}R#${replayId}#{cloud_replay_delay_part2}`,
ChatColor.BABYBLUE,
);
}
private buildPlayerRecord(
room: Room,
client: Client,
......
......@@ -19,6 +19,7 @@ import { LockDeckService } from './lock-deck';
import { BlockReplay } from './block-replay';
import { RoomDeathService } from './room-death-service';
import { RoomAutoDeathService } from './room-auto-death-service';
import { ChallongeService } from './challonge-service';
export const FeatsModule = createAppContext()
.provide(ClientKeyProvider)
......@@ -35,6 +36,7 @@ export const FeatsModule = createAppContext()
.provide(LpLowHintService) // low LP hint in duel
.provide(RoomDeathService) // srvpro-style death mode (model 2)
.provide(RoomAutoDeathService) // auto trigger death mode after duel start
.provide(ChallongeService) // challonge deck lock + score sync
.provide(LockDeckService) // srvpro-style tournament deck lock check
.provide(RefreshFieldService)
.provide(Reconnect)
......
......@@ -2,6 +2,8 @@ export * from './client-version-check';
export * from './client-key-provider';
export * from './chatgpt-service';
export * from './block-replay';
export * from './challonge-api';
export * from './challonge-service';
export * from './cloud-replay';
export * from './hide-player-name-provider';
export * from './lock-deck';
......
......@@ -243,12 +243,6 @@ export class RandomDuelProvider {
);
if (found) {
const foundType = found.randomType || type || this.defaultType;
found.randomType = foundType;
found.hidePlayerNames = this.hidePlayerNameProvider.enabled;
found.randomDuelDeprecated = joinState.deprecated;
found.checkChatBadword = true;
found.noHost = true;
found.randomDuelMaxPlayer = this.resolveRandomDuelMaxPlayer(foundType);
found.welcome = '#{random_duel_enter_room_waiting}';
this.applyWelcomeType(found, foundType);
return { room: found };
......
......@@ -39,7 +39,6 @@ export class JoinWindbotAi {
const existingRoom = this.roomManager.findByName(normalizedPass);
if (existingRoom) {
existingRoom.noHost = true;
await existingRoom.join(client);
return true;
}
......
import { ChatColor, YGOProCtosJoinGame } from 'ygopro-msg-encode';
import { Context } from '../app';
import { ChallongeService } from '../feats';
import { DuelStage, Room, RoomManager } from '../room';
export class ChallongeJoinHandler {
private logger = this.ctx.createLogger(this.constructor.name);
private challongeService = this.ctx.get(() => ChallongeService);
private roomManager = this.ctx.get(() => RoomManager);
constructor(private ctx: Context) {
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
if (!this.challongeService.enabled) {
return next();
}
const preRoom = this.resolvePreRoom(msg.pass);
if (preRoom && preRoom.duelStage !== DuelStage.Begin) {
return preRoom.join(client, true);
}
const resolved = await this.challongeService.resolveJoinInfo(client.name);
if (resolved.ok === false) {
return client.die(
this.resolveJoinErrorMessage(resolved.reason),
ChatColor.RED,
);
}
const roomName = this.resolveRoomName(resolved.match.id);
const room = await this.roomManager.findOrCreateByName(roomName);
room.noHost = true;
room.challongeInfo = resolved.match;
room.welcome = '#{challonge_match_created}';
if (this.hasSameParticipantInRoom(room, resolved.participant.id)) {
this.logger.debug(
{
roomName: room.name,
participantId: resolved.participant.id,
clientName: client.name,
},
'Rejected duplicated challonge participant in room',
);
return client.die('#{challonge_player_already_in}', ChatColor.RED);
}
client.challongeInfo = resolved.participant;
return room.join(client);
});
}
private resolveRoomName(matchId: number) {
if (this.ctx.config.getBoolean('CHALLONGE_NO_MATCH_MODE')) {
return `${matchId}`;
}
return `M#${matchId}`;
}
private resolvePreRoom(pass: string | undefined) {
const roomName = (pass || '').trim();
if (!roomName) {
return undefined;
}
return this.roomManager.findByName(roomName);
}
private hasSameParticipantInRoom(room: Room, participantId: number) {
return room.playingPlayers.some(
(player) => player && player.challongeInfo?.id === participantId,
);
}
private resolveJoinErrorMessage(
reason: 'match_load_failed' | 'user_not_found' | 'match_not_found',
) {
if (reason === 'match_load_failed') {
return '#{challonge_match_load_failed}';
}
if (reason === 'user_not_found') {
return '#{challonge_user_not_found}';
}
return '#{challonge_match_not_found}';
}
}
......@@ -14,20 +14,22 @@ import { JoinBlankPassWindbotAi } from './join-blank-pass-windbot-ai';
import { JoinBlankPassMenu } from './join-blank-pass-menu';
import { JoinRoomlist } from './join-roomlist';
import { JoinBotlist } from './join-botlist';
import { ChallongeJoinHandler } from './challonge-join-handler';
export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
.provide(JoinPrechecks)
.provide(JoinWindbotToken)
.provide(BadwordPlayerInfoChecker)
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi)
.provide(JoinRoomIp)
.provide(CloudReplayJoinHandler)
.provide(JoinRoomlist)
.provide(JoinBotlist)
.provide(JoinRoom)
.provide(JoinBlankPassMenu)
.provide(JoinRoomIp) // IP
.provide(CloudReplayJoinHandler) // R, R#, W, W#, YRP#
.provide(ChallongeJoinHandler) // any
.provide(RandomDuelJoinHandler) // M, T
.provide(JoinWindbotAi) // AI, AI#
.provide(JoinRoomlist) // L
.provide(JoinBotlist) // B
.provide(JoinRoom) // room pass
.provide(JoinBlankPassMenu) // blank pass below
.provide(JoinBlankPassRandomDuel)
.provide(JoinBlankPassWindbotAi)
.provide(JoinFallback)
......
......@@ -3,6 +3,7 @@ import { LegacyDeckEntity } from './legacy-deck.entity';
import { decodeDeckBase64, encodeDeckBase64 } from '../feats/cloud-replay';
import YGOProDeck from 'ygopro-deck-encode';
import { LockDeckExpectedDeckCheck } from '../feats/lock-deck';
import { ChallongeParticipantUpload, ChallongeService } from '../feats';
import { deckNameMatch } from './utility/deck-name-match';
import {
getDeckNameExactCandidates,
......@@ -32,6 +33,7 @@ type DeckDashboardStreamConnection = {
export class LegacyApiDeckService {
private logger = this.ctx.createLogger('LegacyApiDeckService');
private challongeService = this.ctx.get(() => ChallongeService);
private streamConnections = new Map<string, DeckDashboardStreamConnection>();
private backgrounds: DeckDashboardBg[] = [{ url: '', desc: '' }];
private bgRefreshedAt = 0;
......@@ -199,7 +201,22 @@ export class LegacyApiDeckService {
koaCtx.body = 'Auth Failed.';
return;
}
this.sendDeckDashboardMessage('未开启Challonge模式。');
this.sendDeckDashboardMessage('开始读取玩家列表。');
const participants = await this.loadChallongeParticipantsFromDecks();
if (!participants.length) {
this.sendDeckDashboardMessage('玩家列表为空。');
koaCtx.body = '操作完成。';
return;
}
this.sendDeckDashboardMessage(
`读取玩家列表完毕,共有${participants.length}名玩家。`,
);
for await (const text of this.challongeService.uploadToChallonge(
participants,
)) {
this.sendDeckDashboardMessage(text);
}
koaCtx.body = '操作完成。';
});
......@@ -420,6 +437,52 @@ export class LegacyApiDeckService {
await repo.save(row);
}
private async loadChallongeParticipantsFromDecks() {
const repo = this.getDeckRepo();
if (!repo) {
return [] as ChallongeParticipantUpload[];
}
const rows = await repo.find({
order: {
uploadTime: 'DESC',
id: 'DESC',
},
});
const loaded = new Set<string>();
const participants: ChallongeParticipantUpload[] = [];
for (const row of rows) {
const name = this.toChallongeParticipantName(row.name);
if (!name || loaded.has(name)) {
continue;
}
try {
const deck = decodeDeckBase64(row.payload, row.mainc);
participants.push({
name,
deckbuf: Buffer.from(deck.toUpdateDeckPayload()).toString('base64'),
});
loaded.add(name);
} catch (error: unknown) {
this.logger.warn(
{
deckName: row.name,
err: error,
},
'Failed to decode legacy deck for challonge upload',
);
}
}
return participants;
}
private toChallongeParticipantName(deckName: string) {
if (deckName.endsWith('.ydk')) {
return deckName.slice(0, -4);
}
return deckName;
}
private addStreamConnection(ip: string, response: ServerResponse) {
this.closeStreamConnection(ip, 'replaced_by_same_ip');
......@@ -432,7 +495,10 @@ export class LegacyApiDeckService {
});
}
private removeStreamConnection(ip: string, expectedResponse?: ServerResponse) {
private removeStreamConnection(
ip: string,
expectedResponse?: ServerResponse,
) {
const connection = this.streamConnections.get(ip);
if (!connection) {
return;
......@@ -559,7 +625,10 @@ export class LegacyApiDeckService {
const body = response.data as any;
const images = Array.isArray(body?.images) ? body.images : [];
if (!images.length) {
this.logger.warn({ body }, 'Deck dashboard background API returned no images');
this.logger.warn(
{ body },
'Deck dashboard background API returned no images',
);
return;
}
const next = images
......@@ -575,7 +644,9 @@ export class LegacyApiDeckService {
})
.filter((item): item is DeckDashboardBg => !!item);
if (!next.length) {
this.logger.warn('Deck dashboard background API parse produced no valid result');
this.logger.warn(
'Deck dashboard background API parse produced no valid result',
);
return;
}
this.backgrounds = next;
......
......@@ -86,7 +86,9 @@ export class LegacyApiService {
this.addApiMessageHandler('death', 'start_death', async (value) => {
const roomDeathService = this.ctx.get(() => RoomDeathService);
const foundRooms =
value === 'all' ? this.ctx.get(() => RoomManager).allRooms() : this.findRoomByTarget(value);
value === 'all'
? this.ctx.get(() => RoomManager).allRooms()
: this.findRoomByTarget(value);
if (!foundRooms.length) {
return ['room not found', value];
}
......@@ -105,7 +107,9 @@ export class LegacyApiService {
this.addApiMessageHandler('deathcancel', 'start_death', async (value) => {
const roomDeathService = this.ctx.get(() => RoomDeathService);
const foundRooms =
value === 'all' ? this.ctx.get(() => RoomManager).allRooms() : this.findRoomByTarget(value);
value === 'all'
? this.ctx.get(() => RoomManager).allRooms()
: this.findRoomByTarget(value);
if (!foundRooms.length) {
return ['room not found', value];
}
......
......@@ -24,7 +24,9 @@ export class LegacyBanService {
return client.die('#{banned_user_login}', ChatColor.RED);
}
const ipBan = client.ip ? await this.findBanRecord({ ip: client.ip }) : null;
const ipBan = client.ip
? await this.findBanRecord({ ip: client.ip })
: null;
if (ipBan) {
this.logger.info(
{ name: client.name, ip: client.ip },
......
export function deckNameMatch(deckName: string, playerName: string) {
if (
deckName === playerName ||
deckName === `${playerName}.ydk` ||
deckName === `${playerName}.ydk.ydk`
) {
return true;
}
const parsedDeckName = deckName.match(
/^([^\+ \uff0b]+)[\+ \uff0b](.+?)(\.ydk){0,2}$/,
);
return !!(
parsedDeckName &&
(playerName === parsedDeckName[1] || playerName === parsedDeckName[2])
);
}
export { deckNameMatch } from '../../utility/deck-name-match';
......@@ -371,12 +371,16 @@ export class Room {
}
}
async join(client: Client) {
async join(client: Client, toObserver = false) {
client.roomName = this.name;
client.isHost = this.noHost ? false : !this.allPlayers.length;
const firstEmptyPlayerSlot = this.players.findIndex((p) => !p);
const isPlayer =
firstEmptyPlayerSlot >= 0 && this.duelStage === DuelStage.Begin;
!toObserver &&
firstEmptyPlayerSlot >= 0 &&
this.duelStage === DuelStage.Begin;
client.isHost = this.noHost
? false
: isPlayer && !this.playingPlayers.length;
if (isPlayer) {
this.players[firstEmptyPlayerSlot] = client;
......
......@@ -35,7 +35,6 @@ export const TypeormFactory = async (ctx: AppContext) => {
const password = config.getString('DB_PASS');
const database = config.getString('DB_NAME');
const synchronize = !config.getBoolean('DB_NO_INIT');
const dataSource = new DataSource({
type: 'postgres',
......
export function deckNameMatch(deckName: string, playerName: string) {
if (
deckName === playerName ||
deckName === `${playerName}.ydk` ||
deckName === `${playerName}.ydk.ydk`
) {
return true;
}
const parsedDeckName = deckName.match(
/^([^\+ \uff0b]+)[\+ \uff0b](.+?)(\.ydk){0,2}$/,
);
return !!(
parsedDeckName &&
(playerName === parsedDeckName[1] || playerName === parsedDeckName[2])
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment