Commit 91dcb148 authored by nanahira's avatar nanahira

add menu

parent 4af43c04
......@@ -63,6 +63,17 @@ randomDuelDisableChat: 0
randomDuelReadyTime: 20
randomDuelHangTimeout: 90
sideTimeoutMinutes: 3
enableMenu: 0
menu:
"#{menu_random_duel}": ""
"#{menu_random_duel_match}": M
"#{menu_ai_duel}": AI
"#{menu_more}":
"#{menu_random_duel_single}": S
"#{menu_random_duel_tag}": T
"#{menu_ai_duel_match}": AI,M
"#{menu_ai_duel_tag}": AI,T
"#{menu_return}": {}
hostinfoLflist: 0
hostinfoRule: 0
hostinfoMode: 0
......
......@@ -17,7 +17,7 @@
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"koishipro-core.js": "^1.3.4",
"nfkit": "^1.0.32",
"nfkit": "^1.0.33",
"p-queue": "6.6.2",
"pg": "^8.18.0",
"pino": "^10.3.1",
......@@ -5330,9 +5330,9 @@
"license": "MIT"
},
"node_modules/nfkit": {
"version": "1.0.32",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.32.tgz",
"integrity": "sha512-y+UoxDBs6JV4CSBZkidBGK4GfzJ1Qev8uU4m4oClWGs09oxOCh6TQqnOGRaZY1yCmD8yzYcED+8waSMU4WS5fg==",
"version": "1.0.33",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.33.tgz",
"integrity": "sha512-mhF4ZAoGUD3cI0sB/+qH2AothZG2j5y18FkyTKF6etR6nod8jBJWQ5hAr3Q6HnaWlG3HpUKN5i1wfZqQP6hyZw==",
"license": "MIT"
},
"node_modules/node-int64": {
......
......@@ -52,6 +52,14 @@ export class ClientHandler {
.middleware(
YGOProCtosBase,
async (msg, client, next) => {
const bypassEstablished =
msg instanceof YGOProCtosJoinGame &&
msg.bypassEstablished;
if (bypassEstablished) {
delete msg.bypassEstablished;
return next();
}
const isPreHandshakeMsg = [
YGOProCtosExternalAddress,
YGOProCtosPlayerInfo,
......@@ -125,3 +133,9 @@ export class ClientHandler {
});
}
}
declare module 'ygopro-msg-encode' {
interface YGOProCtosJoinGame {
bypassEstablished?: boolean;
}
}
export * from './client';
export * from './client-handler';
export * from './chnroute';
export * from './i18n';
......@@ -152,6 +152,15 @@ export const defaultConfig = {
// Room hostinfo defaults expanded into HOSTINFO_* keys.
// Format: each HOSTINFO_* value is a string; numeric fields use integer strings.
// Unit note: HOSTINFO_TIME_LIMIT is in seconds (s).
// Enable blank-pass panel menu.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_MENU: '0',
// Blank-pass panel definition in JSON object format.
// Format: {"Display Text": "ROOM_PASS"}.
// - key: text shown to client; supports i18n placeholder like "#{menu_random_duel}".
// - value(string): equivalent room password, then redispatches CTOS_JOIN_GAME with this pass.
// - value(object): submenu; empty object {} means "return to previous level".
MENU: '{"#{menu_random_duel}":"","#{menu_random_duel_match}":"M","#{menu_ai_duel}":"AI","#{menu_more}":{"#{menu_random_duel_single}":"S","#{menu_random_duel_tag}":"T","#{menu_ai_duel_match}":"AI,M","#{menu_ai_duel_tag}":"AI,T","#{menu_return}":{}}}',
...(Object.fromEntries(
Object.entries(DefaultHostinfo).map(([key, value]) => [
`HOSTINFO_${key.toUpperCase()}`,
......
......@@ -82,6 +82,19 @@ export const TRANSLATIONS = {
chat_disabled: 'Chat is disabled in this room.',
chat_warn_level1: 'Please avoid sensitive words.',
chat_warn_level2: 'Your message contains blocked words.',
menu_random_duel: 'Random Duel',
menu_random_duel_match: 'Random Duel (Match)',
menu_ai_duel: 'AI Duel',
menu_more: 'More',
menu_random_duel_single: 'Random Duel (Single)',
menu_random_duel_tag: 'Random Duel (Tag)',
menu_ai_duel_match: 'AI Duel (Match)',
menu_ai_duel_tag: 'AI Duel (Tag)',
menu_return: 'Return',
menu_match_random_duel: 'Match Random Duel',
menu_single_random_duel: 'Single Random Duel',
menu_prev_page: 'Previous Page',
menu_next_page: 'Next Page',
},
'zh-CN': {
update_required: '请更新你的客户端版本',
......@@ -157,5 +170,18 @@ export const TRANSLATIONS = {
chat_disabled: '本房间禁止聊天。',
chat_warn_level1: '请注意发言,敏感词已被替换。',
chat_warn_level2: '消息包含敏感词,已被拦截。',
menu_random_duel: '随机对战',
menu_random_duel_match: '随机对战(比赛)',
menu_ai_duel: '人机对战',
menu_more: '更多',
menu_random_duel_single: '随机对战(单局)',
menu_random_duel_tag: '随机对战(双打)',
menu_ai_duel_match: '人机对战(比赛)',
menu_ai_duel_tag: '人机对战(双打)',
menu_return: '返回',
menu_match_random_duel: '随机对战(比赛)',
menu_single_random_duel: '随机对战(单局)',
menu_prev_page: '上一页',
menu_next_page: '下一页',
},
};
export * from './client-version-check';
export * from './welcome';
export * from './random-duel';
export * from './reconnect';
export * from './wait-for-player-provider';
......
import { ChatColor } from 'ygopro-msg-encode';
import { Context } from '../app';
import { Client } from '../client';
import { OnRoomJoin } from '../room/room-event/on-room-join';
declare module '../room' {
......@@ -9,15 +10,19 @@ declare module '../room' {
}
}
declare module '../client' {
interface Client {
configWelcomeSent?: boolean;
}
}
export class Welcome {
private welcomeMessage = this.ctx.config.getString('WELCOME');
constructor(private ctx: Context) {
this.ctx.middleware(OnRoomJoin, async (event, client, next) => {
const room = event.room;
if (this.welcomeMessage) {
await client.sendChat(this.welcomeMessage, ChatColor.GREEN);
}
await this.sendConfigWelcome(client);
if (room.welcome) {
await client.sendChat(room.welcome, ChatColor.BABYBLUE);
}
......@@ -27,4 +32,12 @@ export class Welcome {
return next();
});
}
async sendConfigWelcome(client: Client) {
if (!this.welcomeMessage || client.configWelcomeSent) {
return;
}
client.configWelcomeSent = true;
await client.sendChat(this.welcomeMessage, ChatColor.GREEN);
}
}
import {
GameMode,
NetPlayerType,
YGOProCtosBase,
YGOProCtosHsToDuelist,
YGOProCtosJoinGame,
YGOProCtosKick,
YGOProStocHsPlayerEnter,
YGOProStocJoinGame,
YGOProStocTypeChange,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { Chnroute, Client, I18nService } from '../client';
import { Welcome } from '../feats';
import { DefaultHostinfo } from '../room';
import { resolvePanelPageLayout } from '../utility';
interface PanelMenuNode {
[key: string]: string | PanelMenuNode;
}
type PanelMenuAction =
| {
type: 'entry';
rawLabel: string;
value: string | PanelMenuNode;
}
| {
type: 'next';
rawLabel: string;
offset: number;
}
| {
type: 'prev';
rawLabel: string;
offset: number;
};
type PanelView = {
actions: PanelMenuAction[];
mode: GameMode;
slotCount: number;
};
export class JoinBlankPassMenu {
private logger = this.ctx.createLogger(this.constructor.name);
private i18n = this.ctx.get(() => I18nService);
private chnroute = this.ctx.get(() => Chnroute);
private welcome = this.ctx.get(() => Welcome);
private enabled = this.ctx.config.getBoolean('ENABLE_MENU');
private rootMenu = this.loadRootMenu();
constructor(private ctx: Context) {
if (!this.enabled) {
return;
}
if (!this.rootMenu || !Object.keys(this.rootMenu).length) {
this.logger.warn('MENU is empty or invalid, panel feature disabled');
return;
}
this.ctx.middleware(
YGOProCtosBase,
async (msg, client, next) => {
const bypassEstablished =
msg instanceof YGOProCtosJoinGame && msg.bypassEstablished;
if (!client.isInPanel) {
return next();
}
if (bypassEstablished) {
return next();
}
if (msg instanceof YGOProCtosHsToDuelist || msg instanceof YGOProCtosKick) {
return next();
}
return undefined;
},
true,
);
this.ctx.middleware(YGOProCtosJoinGame, async (msg, client, next) => {
msg.pass = (msg.pass || '').trim();
if (msg.pass) {
if (client.isInPanel) {
this.exitPanel(client);
}
return next();
}
if (client.isInPanel) {
this.exitPanel(client);
return next();
}
this.enterPanel(client, msg);
await this.welcome.sendConfigWelcome(client);
await this.renderPanel(client);
return msg;
});
this.ctx.middleware(YGOProCtosHsToDuelist, async (_msg, client, next) => {
if (!client.isInPanel) {
return next();
}
await this.renderPanel(client);
return _msg;
});
this.ctx.middleware(YGOProCtosKick, async (msg, client, next) => {
if (!client.isInPanel) {
return next();
}
await this.handlePanelKick(client, Number(msg.pos));
return undefined;
});
}
private loadRootMenu() {
const raw = this.ctx.config.getString('MENU').trim();
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as unknown;
return this.parseMenuNode(parsed, 'MENU');
} catch (e) {
this.logger.warn(
{ error: (e as Error).message },
'Failed to parse MENU config',
);
return undefined;
}
}
private parseMenuNode(value: unknown, path: string): PanelMenuNode {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${path} must be a JSON object`);
}
const parsed: PanelMenuNode = {};
for (const [label, entryValue] of Object.entries(
value as Record<string, unknown>,
)) {
if (typeof entryValue === 'string') {
parsed[label] = entryValue;
continue;
}
if (
entryValue &&
typeof entryValue === 'object' &&
!Array.isArray(entryValue)
) {
parsed[label] = this.parseMenuNode(entryValue, `${path}.${label}`);
continue;
}
throw new Error(`${path}.${label} must be a string or object`);
}
return parsed;
}
private enterPanel(client: Client, msg: YGOProCtosJoinGame) {
client.isInPanel = true;
client.panelMenuPath = [];
client.panelOffset = 0;
client.panelJoinVersion = msg.version;
client.panelJoinGameId = msg.gameid;
}
private exitPanel(client: Client) {
client.isInPanel = false;
client.panelMenuPath = undefined;
client.panelOffset = undefined;
client.panelJoinVersion = undefined;
client.panelJoinGameId = undefined;
}
private resolveCurrentMenu(client: Client) {
if (!this.rootMenu) {
return undefined;
}
let node = this.rootMenu;
const path = client.panelMenuPath || [];
for (const key of path) {
const next = node[key];
if (!next || typeof next === 'string') {
return undefined;
}
node = next;
}
return node;
}
private ensureCurrentMenu(client: Client) {
let menu = this.resolveCurrentMenu(client);
if (menu) {
return menu;
}
client.panelMenuPath = [];
client.panelOffset = 0;
menu = this.resolveCurrentMenu(client);
return menu;
}
private buildPanelView(client: Client): PanelView {
const menu = this.ensureCurrentMenu(client);
if (!menu) {
return {
actions: [],
mode: GameMode.SINGLE,
slotCount: 2,
};
}
const entries = Object.entries(menu).map(([rawLabel, value]) => ({
rawLabel,
value,
}));
if (entries.length <= 2) {
return {
actions: entries.map((entry) => ({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
})),
mode: GameMode.SINGLE,
slotCount: 2,
};
}
if (entries.length <= 4) {
return {
actions: entries.map((entry) => ({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
})),
mode: GameMode.TAG,
slotCount: 4,
};
}
const layout = resolvePanelPageLayout(entries.length, client.panelOffset || 0);
client.panelOffset = layout.pageStart;
const pageActions: PanelMenuAction[] = [];
if (layout.isFirstPage) {
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 3)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
pageActions.push({
type: 'next',
rawLabel: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
} else if (layout.isLastPage) {
pageActions.push({
type: 'prev',
rawLabel: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 3)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
} else {
pageActions.push({
type: 'prev',
rawLabel: '#{menu_prev_page}',
offset: layout.pageStarts[layout.pageIndex - 1],
});
for (const entry of entries.slice(layout.pageStart, layout.pageStart + 2)) {
pageActions.push({
type: 'entry',
rawLabel: entry.rawLabel,
value: entry.value,
});
}
pageActions.push({
type: 'next',
rawLabel: '#{menu_next_page}',
offset: layout.pageStarts[layout.pageIndex + 1],
});
}
return {
actions: pageActions,
mode: GameMode.TAG,
slotCount: 4,
};
}
private async translateLabel(client: Client, label: string) {
const locale = this.chnroute.getLocale(client.ip);
return String(await this.i18n.translate(locale, label));
}
private async renderPanel(client: Client) {
const view = this.buildPanelView(client);
if (!view.actions.length) {
client.disconnect();
return;
}
await client.send(
new YGOProStocJoinGame().fromPartial({
info: {
...DefaultHostinfo,
mode: view.mode,
},
}),
);
await client.send(
new YGOProStocTypeChange().fromPartial({
type: NetPlayerType.OBSERVER | 0x10,
}),
);
for (let i = 0; i < view.slotCount; i++) {
const action = view.actions[i];
const translated = action
? await this.translateLabel(client, action.rawLabel)
: '';
await client.send(
new YGOProStocHsPlayerEnter().fromPartial({
name: translated.slice(0, 20),
pos: i,
}),
);
}
}
private async handlePanelKick(client: Client, index: number) {
const view = this.buildPanelView(client);
const selected = view.actions[index];
if (!selected) {
await this.renderPanel(client);
return;
}
if (selected.type === 'next' || selected.type === 'prev') {
client.panelOffset = selected.offset;
await this.renderPanel(client);
return;
}
if (typeof selected.value === 'string') {
await this.dispatchJoinGameFromPanel(client, selected.value);
return;
}
const nextMenuKeys = Object.keys(selected.value);
if (!nextMenuKeys.length) {
await this.backFromPanel(client);
return;
}
client.panelMenuPath = [...(client.panelMenuPath || []), selected.rawLabel];
client.panelOffset = 0;
await this.renderPanel(client);
}
private async backFromPanel(client: Client) {
const currentPath = [...(client.panelMenuPath || [])];
if (!currentPath.length) {
client.disconnect();
return;
}
currentPath.pop();
client.panelMenuPath = currentPath;
client.panelOffset = 0;
await this.renderPanel(client);
}
private async dispatchJoinGameFromPanel(client: Client, pass: string) {
const joinMsg = new YGOProCtosJoinGame().fromPartial({
version: client.panelJoinVersion || this.ctx.config.getInt('YGOPRO_VERSION'),
gameid: client.panelJoinGameId || 0,
pass,
});
joinMsg.bypassEstablished = true;
await this.ctx.dispatch(joinMsg, client);
}
}
declare module '../client' {
interface Client {
isInPanel?: boolean;
panelMenuPath?: string[];
panelOffset?: number;
panelJoinVersion?: number;
panelJoinGameId?: number;
}
}
......@@ -9,6 +9,7 @@ import { RandomDuelJoinHandler } from './random-duel-join-handler';
import { BadwordPlayerInfoChecker } from './badword-player-info-checker';
import { JoinBlankPassRandomDuel } from './join-blank-pass-random-duel';
import { JoinBlankPassWindbotAi } from './join-blank-pass-windbot-ai';
import { JoinBlankPassMenu } from './join-blank-pass-menu';
export const JoinHandlerModule = createAppContext<ContextState>()
.provide(ClientVersionCheck)
......@@ -18,6 +19,7 @@ export const JoinHandlerModule = createAppContext<ContextState>()
.provide(RandomDuelJoinHandler)
.provide(JoinWindbotAi)
.provide(JoinRoom)
.provide(JoinBlankPassMenu)
.provide(JoinBlankPassRandomDuel)
.provide(JoinBlankPassWindbotAi)
.provide(JoinFallback)
......
......@@ -17,3 +17,4 @@ export * from './room-event/on-room-siding-ready';
export * from './room-event/on-room-siding-start';
export * from './room-event/on-room-win';
export * from './default-hostinfo-provder';
export * from './default-hostinfo';
export * from './panel-pagination';
export type PanelPageLayout = {
pageStarts: number[];
pageIndex: number;
pageStart: number;
isFirstPage: boolean;
isLastPage: boolean;
};
function resolvePageIndex(pageStarts: number[], requestedStart: number) {
if (!pageStarts.length) {
return 0;
}
if (requestedStart <= pageStarts[0]) {
return 0;
}
for (let i = pageStarts.length - 1; i >= 0; i--) {
if (requestedStart >= pageStarts[i]) {
return i;
}
}
return 0;
}
export function buildPanelPageStarts(totalEntries: number) {
if (totalEntries <= 4) {
return [0];
}
const pageStarts = [0];
const lastPageStart = totalEntries - 3;
let cursor = 3;
while (cursor < lastPageStart) {
pageStarts.push(cursor);
cursor += 2;
}
if (pageStarts[pageStarts.length - 1] !== lastPageStart) {
pageStarts.push(lastPageStart);
}
return pageStarts;
}
export function resolvePanelPageLayout(
totalEntries: number,
requestedStart: number,
): PanelPageLayout {
const pageStarts = buildPanelPageStarts(totalEntries);
const pageIndex = resolvePageIndex(pageStarts, requestedStart);
const pageStart = pageStarts[pageIndex] || 0;
return {
pageStarts,
pageIndex,
pageStart,
isFirstPage: pageIndex === 0,
isLastPage: pageIndex === pageStarts.length - 1,
};
}
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