From 079c7d2dd76e0f3e581a51318bccd6252002f6e7 Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Tue, 15 Apr 2025 07:46:25 +0000 Subject: [PATCH] Preact: Support tournaments Missing features: - Popping out brackets - Showing the bracket after the tournament ends - Disable validate button for random teams New and improved: - Hover effect on tournament bar (also backported to 2013 client) - Slightly better animations for expanding/collapsing tours - Better scrolling around a bracket - "Grab" cursor - Selecting text is no longer completely banned (although it's still hard to do on desktop because of the whole drag scrolling thing) - Completely native scrolling on mobile - Scroll bars appear as normal, and regular methods of scrolling like arrow keys and scroll wheel also work as normal --- eslint-ps-standard.mjs | 7 +- package-lock.json | 8 + package.json | 1 + .../preactalpha.template.html | 2 + play.pokemonshowdown.com/src/battle-log.ts | 7 +- play.pokemonshowdown.com/src/client-main.ts | 1 + .../src/panel-chat-tournament.tsx | 787 ++++++++++++++++++ play.pokemonshowdown.com/src/panel-chat.tsx | 12 +- play.pokemonshowdown.com/src/panel-ladder.tsx | 2 +- .../src/panel-mainmenu.tsx | 37 +- play.pokemonshowdown.com/style/client.css | 6 + play.pokemonshowdown.com/style/client2.css | 98 +-- play.pokemonshowdown.com/testclient-beta.html | 4 +- 13 files changed, 894 insertions(+), 78 deletions(-) create mode 100644 play.pokemonshowdown.com/src/panel-chat-tournament.tsx diff --git a/eslint-ps-standard.mjs b/eslint-ps-standard.mjs index 327d6dfb3..038b29472 100644 --- a/eslint-ps-standard.mjs +++ b/eslint-ps-standard.mjs @@ -68,7 +68,7 @@ export const defaultRules = { "@stylistic/max-len": ["warn", { "code": 120, "tabWidth": 0, // DO NOT EDIT DIRECTLY: see bottom of file for source - "ignorePattern": "^\\s*(?:\\/\\/ \\s*)?(?:(?:export )?(?:let |const |readonly )?[a-zA-Z0-9_$.]+(?: \\+?=>? )|[a-zA-Z0-9$]+: \\[?|(?:return |throw )?(?:new )?(?:[a-zA-Z0-9$.]+\\()?)?(?:Utils\\.html|(?:this\\.)?(?:room\\.)?tr|\\$\\()?['\"`/]", + "ignorePattern": "^\\s*(?:\\/\\/ \\s*)?(?:(?:export )?(?:let |const |readonly )?[a-zA-Z0-9_$.]+(?: \\+?=>? )|[a-zA-Z0-9$]+: \\[?|(?:return |throw )?(?:new )?(?:[a-zA-Z0-9$.]+\\()?)?(?:[A-Za-z0-9.]+|\\$\\()?['\"`/]", }], "prefer-const": ["warn", { "destructuring": "all" }], @@ -422,9 +422,8 @@ SOURCE FOR IGNOREPATTERN (compile with https://regexfree.k55.io/ ) )? ( - Utils\.html - | - (this\.)?(room\.)?tr + # tagged template + [A-Za-z0-9\.]+ | \$\( )? diff --git a/package-lock.json b/package-lock.json index 03485df93..062a679dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@stylistic/eslint-plugin": "^4.0.1", + "@types/d3": "^3.5.53", "@types/jquery": "^3.5.3", "@types/mocha": "^5.2.6", "eslint": "^9.20.1", @@ -1901,6 +1902,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@types/d3": { + "version": "3.5.53", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-3.5.53.tgz", + "integrity": "sha512-8yKQA9cAS6+wGsJpBysmnhlaaxlN42Qizqkw+h2nILSlS+MAG2z4JdO6p+PJrJ+ACvimkmLJL281h157e52psQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", diff --git a/package.json b/package.json index 8fbd38cdf..a24dc7b2b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "devDependencies": { "@stylistic/eslint-plugin": "^4.0.1", + "@types/d3": "^3.5.53", "@types/jquery": "^3.5.3", "@types/mocha": "^5.2.6", "eslint": "^9.20.1", diff --git a/play.pokemonshowdown.com/preactalpha.template.html b/play.pokemonshowdown.com/preactalpha.template.html index f807b86b3..b99fdf64f 100644 --- a/play.pokemonshowdown.com/preactalpha.template.html +++ b/play.pokemonshowdown.com/preactalpha.template.html @@ -99,6 +99,7 @@ https://psim.us/dev + @@ -126,5 +127,6 @@ https://psim.us/dev + diff --git a/play.pokemonshowdown.com/src/battle-log.ts b/play.pokemonshowdown.com/src/battle-log.ts index 93091e2d5..d11e588d9 100644 --- a/play.pokemonshowdown.com/src/battle-log.ts +++ b/play.pokemonshowdown.com/src/battle-log.ts @@ -1037,7 +1037,7 @@ export class BattleLog { this.innerElem.appendChild(this.preemptElem.firstChild); } - static escapeFormat(formatid: string): string { + static escapeFormat(formatid = ''): string { let atIndex = formatid.indexOf('@@@'); if (atIndex >= 0) { return this.escapeFormat(formatid.slice(0, atIndex)) + @@ -1051,7 +1051,7 @@ export class BattleLog { } return this.escapeHTML(formatid); } - static formatName(formatid: string): string { + static formatName(formatid = ''): string { let atIndex = formatid.indexOf('@@@'); if (atIndex >= 0) { return this.formatName(formatid.slice(0, atIndex)) + @@ -1066,7 +1066,8 @@ export class BattleLog { return formatid; } - static escapeHTML(str: string, jsEscapeToo?: boolean) { + static escapeHTML(str: string | number, jsEscapeToo?: boolean) { + if (typeof str === 'number') str = `${str}`; if (typeof str !== 'string') return ''; str = str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); if (jsEscapeToo) str = str.replace(/\\/g, '\\\\').replace(/'/g, '\\\''); diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index adfc9a5fe..29d381274 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -65,6 +65,7 @@ class PSPrefs extends PSStreamModel { * Uses 1 and 0 instead of true/false for JSON packing reasons. */ ignore: { [userid: string]: 1 | 0 } | null = null; + tournaments: 'hide' | 'notify' | null = null; /** * true = one panel, false = two panels, left and right */ diff --git a/play.pokemonshowdown.com/src/panel-chat-tournament.tsx b/play.pokemonshowdown.com/src/panel-chat-tournament.tsx new file mode 100644 index 000000000..7b8722252 --- /dev/null +++ b/play.pokemonshowdown.com/src/panel-chat-tournament.tsx @@ -0,0 +1,787 @@ +import preact from "../js/lib/preact"; +import { Dex, toRoomid } from "./battle-dex"; +import { BattleLog } from "./battle-log"; +import { PSModel, type PSSubscription } from "./client-core"; +import { PS, type RoomID, type Team } from "./client-main"; +import { TeamForm } from "./panel-mainmenu"; +import type { Args } from "./battle-text-parser"; +import type { ChatRoom } from "./panel-chat"; +// we check window.d3 before using it, so d3 doesn't need to be loaded before this file +import * as d3 from 'd3'; + +interface TournamentTreeBracketNode { + parent?: TournamentTreeBracketNode; + children: TournamentTreeBracketNode[]; + x: number; + y: number; + state: string; + team: string; + room: string; + result: string; + score: [number, number]; +} +interface TournamentTreeBracketData { + type: 'tree'; + users: string[]; + rootNode: TournamentTreeBracketNode; +} +interface TournamentTableBracketData { + type: 'table'; + users: string[]; + tableContents: { + state: string, + room: string, + result: string, + score: [number, number], + }[][]; + tableHeaders: { + rows: string[], + cols: string[], + }; + scores: number[]; +} +type TournamentInfo = { + format?: string, + teambuilderFormat?: string, + generator?: string, + isActive?: boolean, + isJoined?: boolean, + isStarted?: boolean, + challenging?: string | null, + challenged?: string | null, + challenges?: string[], + challengeBys?: string[], + bracketData?: TournamentTreeBracketData | TournamentTableBracketData, +}; +export class ChatTournament extends PSModel { + info: TournamentInfo = {}; + updates: TournamentInfo = {}; + room: ChatRoom; + boxVisible = false; + selectedChallenge = 0; + joinLeave: { join: string[], leave: string[], messageId: string } | null = null; + constructor(room: ChatRoom) { + super(); + this.room = room; + } + tryAdd(line: string) { + if (PS.prefs.tournaments === 'hide') return false; + this.room.add(line); + return true; + } + static arrayToPhrase(array: string[], finalSeparator = 'and') { + if (array.length <= 1) + return array.join(); + return `${array.slice(0, -1).join(", ")} ${finalSeparator} ${array.slice(-1)[0]}`; + } + handleJoinLeave(action: 'join' | 'leave', name: string) { + this.joinLeave ||= { + join: [], + leave: [], + messageId: `joinleave-${Date.now()}`, + }; + if (action === 'join' && this.joinLeave['leave'].includes(name)) { + this.joinLeave['leave'].splice(this.joinLeave['leave'].indexOf(name), 1); + } else if (action === 'leave' && this.joinLeave['join'].includes(name)) { + this.joinLeave['join'].splice(this.joinLeave['join'].indexOf(name), 1); + } else { + this.joinLeave[action].push(name); + } + + let message = ChatTournament.arrayToPhrase(this.joinLeave['join']) + ' joined the tournament'; + if (this.joinLeave['join'].length && this.joinLeave['leave'].length) message += '; '; + message += ChatTournament.arrayToPhrase(this.joinLeave['leave']) + ' left the tournament'; + + this.tryAdd(`|uhtml|${this.joinLeave.messageId}|
${message}.
`); + } + tournamentName() { + if (!this.info.format || !this.info.generator) return ""; + const formatName = BattleLog.formatName(this.info.format); + const type = this.info.generator; + return `${formatName} ${type} Tournament`; + } + receiveLine(args: Args) { + const data = args.slice(2); + const notify = !PS.prefs.tournaments || PS.prefs.tournaments === 'notify'; + let cmd = args[1].toLowerCase(); + if (args[0] === 'tournaments') { + switch (cmd) { + case 'info': + const tournaments = JSON.parse(data.join('|')); + let buf = `
`; + if (tournaments.length <= 0) { + buf += `No tournaments are currently running.`; + } else { + buf += `
    `; + for (const tournament of tournaments) { + const formatName = BattleLog.formatName(tournament.format); + buf += `
  • `; + buf += BattleLog.html`${tournament.room}`; + buf += BattleLog.html`: ${formatName} ${tournament.generator}${tournament.isStarted ? " (Started)" : ""}`; + buf += `
  • `; + } + buf += `
`; + } + buf += '
'; + this.tryAdd(`|html|${buf}`); + break; + + default: + return true; + } + } else if (args[0] === 'tournament') { + switch (cmd) { + case 'create': { + this.info.format = args[2]; + this.info.generator = args[3]; + const formatName = BattleLog.formatName(args[2]); + const type = args[3]; + const buf = BattleLog.html`
${this.tournamentName()} created.
`; + if (!this.tryAdd(`|html|${buf}`)) { + const hiddenBuf = BattleLog.html`
${this.tournamentName()} created (and hidden).
`; + this.room.add(`|html|${hiddenBuf}`); + } + if (notify) { + this.room.notify({ + title: "Tournament created", + body: `Room: ${this.room.title}\nFormat: ${formatName}\nType: ${type}`, + id: 'tournament-create', + }); + } + break; + } + + case 'join': + case 'leave': { + this.handleJoinLeave(cmd, args[2]); + break; + } + + case 'replace': { + this.tryAdd(`||${args[3]} has joined the tournament, replacing ${args[4]}.`); + break; + } + + case 'start': + this.room.dismissNotification('tournament-create'); + if (!this.info.isJoined) { + this.boxVisible = false; + } else if (this.info.teambuilderFormat?.startsWith('gen5') && !Dex.loadedSpriteData['bw']) { + Dex.loadSpriteData('bw'); + } + let participants = data[0] ? ` (${data[0]} players)` : ""; + this.room.add(`|html|
The tournament has started!${participants}
`); + break; + + case 'disqualify': + this.tryAdd(BattleLog.html`|html|
${data[0]} has been disqualified from the tournament.
`); + break; + + case 'autodq': + if (data[0] === 'off') { + this.tryAdd(`|html|
The tournament's automatic disqualify timer has been turned off.
`); + } else if (data[0] === 'on') { + let minutes = Math.round(parseInt(data[1]) / 1000 / 60); + this.tryAdd(BattleLog.html`|html|
The tournament's automatic disqualify timer has been set to ${minutes} minute${minutes === 1 ? "" : "s"}.
`); + } else { + let seconds = Math.floor(parseInt(data[1]) / 1000); + PS.alert(`Please respond to the tournament within ${seconds} seconds or you may be automatically disqualified.`); + if (notify) { + this.room.notify({ + title: "Tournament Automatic Disqualification Warning", + body: `Room: ${this.room.title}\nSeconds: ${seconds}`, + id: 'tournament-autodq-warning', + }); + } + } + break; + + case 'autostart': + if (data[0] === 'off') { + this.tryAdd(`|html|
The tournament's automatic start is now off.
`); + } else if (data[0] === 'on') { + let minutes = (parseInt(data[1]) / 1000 / 60); + this.tryAdd(BattleLog.html`|html|
The tournament will automatically start in ${minutes} minute${minutes === 1 ? "" : "s"}.
`); + } + break; + + case 'scouting': + if (data[0] === 'allow') { + this.tryAdd(`|html|
Scouting is now allowed (Tournament players can watch other tournament battles)
`); + } else if (data[0] === 'disallow') { + this.tryAdd(`|html|
Scouting is now banned (Tournament players can't watch other tournament battles)
`); + } + break; + + case 'update': + Object.assign(this.updates, JSON.parse(data.join('|'))); + break; + + case 'updateend': + const info = { ...this.info, ...this.updates }; + if (!info.isActive) { + if (!info.isStarted || info.isJoined) + this.boxVisible = true; + info.isActive = true; + } + + if ('format' in this.updates || 'teambuilderFormat' in this.updates) { + if (!info.teambuilderFormat) info.teambuilderFormat = info.format; + } + + if (info.isStarted && info.isJoined) { + // Update the challenges + if ('challenges' in this.updates) { + if (info.challenges?.length) { + this.boxVisible = true; + if (!this.info.challenges?.length) { + // app.playNotificationSound(); + if (notify) { + this.room.notify({ + title: "Tournament challenges available", + body: `Room: ${this.room.title}`, + id: 'tournament-challenges', + }); + } + } + } + } + + if ('challenged' in this.updates) { + if (info.challenged) { + this.boxVisible = true; + if (!this.info.challenged) { + if (notify) { + this.room.notify({ + title: `Tournament challenge from ${info.challenged}`, + body: `Room: ${this.room.title}`, + id: 'tournament-challenged', + }); + } + } + } + } + } + + this.info = info; + this.updates = {}; + break; + + case 'battlestart': { + const roomid = toRoomid(data[2]).toLowerCase(); + this.tryAdd(`|uhtml|tournament-${roomid}|`); + break; + } + + case 'battleend': { + let result = "drawn"; + if (data[2] === 'win') + result = "won"; + else if (data[2] === 'loss') + result = "lost"; + const message = `${BattleLog.escapeHTML(data[0])} has ${result} the match ${BattleLog.escapeHTML(data[3].split(',').join(' - '))} against ${BattleLog.escapeHTML(data[1])}${data[4] === 'fail' ? " but the tournament does not support drawing, so it did not count" : ""}.`; + const roomid = toRoomid(data[5]); + this.tryAdd(`|uhtml|tournament-${roomid}|`); + break; + } + + case 'end': + let endData = JSON.parse(data.join('|')); + + // todo: show a bracket + + this.info.format = endData.format; + this.info.generator = endData.generator; + this.room.add(BattleLog.html`|html|
Congratulations to ${ChatTournament.arrayToPhrase(endData.results[0])} for winning the ${this.tournamentName()}!
`); + if (endData.results[1]) { + this.tryAdd(BattleLog.html`|html|
Runner${endData.results[1].length > 1 ? "s" : ""}-up: ${ChatTournament.arrayToPhrase(endData.results[1])}
`); + } + + // Fallthrough + + case 'forceend': + this.room.dismissNotification('tournament-create'); + this.info = {}; + this.updates = {}; + + this.info.isActive = false; + this.boxVisible = false; + + if (cmd === 'forceend') + this.room.add(`|html|
The tournament was forcibly ended.
`); + break; + + case 'error': { + let appendError = (message: string) => { + this.tryAdd(`|html|
${BattleLog.sanitizeHTML(message)}
`); + }; + + switch (data[0]) { + case 'BracketFrozen': + case 'AlreadyStarted': + appendError("The tournament has already started."); + break; + + case 'BracketNotFrozen': + case 'NotStarted': + appendError("The tournament hasn't started yet."); + break; + + case 'UserAlreadyAdded': + appendError("You are already in the tournament."); + break; + + case 'AltUserAlreadyAdded': + appendError("One of your alts is already in the tournament."); + break; + + case 'UserNotAdded': + appendError(`${data[1] && data[1] === PS.user.userid ? "You aren't" : "This user isn't"} in the tournament.`); + break; + + case 'NotEnoughUsers': + appendError("There aren't enough users."); + break; + + case 'InvalidAutoDisqualifyTimeout': + case 'InvalidAutoStartTimeout': + appendError("That isn't a valid timeout value."); + break; + + case 'InvalidMatch': + appendError("That isn't a valid tournament matchup."); + break; + + case 'UserNotNamed': + appendError("You must have a name in order to join the tournament."); + break; + + case 'Full': + appendError("The tournament is already at maximum capacity for users."); + break; + + case 'AlreadyDisqualified': + appendError(`${data[1] && data[1] === PS.user.userid ? "You have" : "This user has"} already been disqualified.`); + break; + + case 'Banned': + appendError("You are banned from entering tournaments."); + break; + + default: + appendError("Unknown error: " + data[0]); + break; + } + break; + } + + default: + return true; + } + } + } +} + +export class TournamentBox extends preact.Component<{ tour: ChatTournament, left?: number }> { + subscription!: PSSubscription; + override componentDidMount(): void { + this.subscription = this.props.tour.subscribe(() => { + this.forceUpdate(); + }); + } + override componentWillUnmount(): void { + this.subscription.unsubscribe(); + } + selectChallengeUser(ev: Event) { + const target = ev.target as HTMLSelectElement; + if (target.tagName !== 'SELECT') return; + const selectedIndex = target.selectedIndex; + if (selectedIndex < 0) return; + this.props.tour.selectedChallenge = selectedIndex; + this.forceUpdate(); + } + acceptChallenge = (ev: Event, format: string, team?: Team) => { + const tour = this.props.tour; + const room = tour.room; + const packedTeam = team ? team.packedTeam : ''; + PS.send(`|/utm ${packedTeam}`); + if (tour.info.challenged) { + room.send(`/tournament acceptchallenge`); + } else if (tour.info.challenges?.length) { + const target = tour.info.challenges[tour.selectedChallenge] || tour.info.challenges[0]; + room.send(`/tournament challenge ${target}`); + } + room.update(null); + }; + validate = (ev: Event, format: string, team?: Team) => { + const room = this.props.tour.room; + const packedTeam = team ? team.packedTeam : ''; + PS.send(`|/utm ${packedTeam}`); + room.send(`/tournament vtm`); + room.update(null); + }; + toggleBoxVisibility = () => { + this.props.tour.boxVisible = !this.props.tour.boxVisible; + this.forceUpdate(); + }; + renderTournamentTools() { + const tour = this.props.tour; + const info = tour.info; + if (!info.isJoined) { + if (info.isStarted) return null; + return
+

+ {} + +

+
; + } + + // joined + const noMatches = !info.challenges?.length && !info.challengeBys?.length && !info.challenging && !info.challenged; + return
+ + {(info.isJoined && !info.challenging && !info.challenged && !info.challenges?.length) && ( + + )} {} + {!!(!info.isStarted && info.isJoined) && ( + + )} + {(info.isStarted && noMatches) && ( +
Waiting for battles to become available...
+ )} + {!!info.challenges?.length &&
+
vs. {info.challenges[tour.selectedChallenge]}
+ + {info.challenges.length > 1 && + + } +
} + {!!info.challengeBys?.length &&
+ {info.challenges?.length ? "Or wait" : "Waiting"} for {ChatTournament.arrayToPhrase(info.challengeBys, "or")} {} + to challenge you. +
} + {!!info.challenging &&
+
Waiting for {info.challenging}...
+ +
} + {!!info.challenged &&
+
vs. {info.challenged}
+ +
} +
+
; + } + override render() { + const tour = this.props.tour; + const info = tour.info; + return
+ +
+ + {this.renderTournamentTools()} +
+
; + } +} + +export class TournamentBracket extends preact.Component<{ + data: TournamentTreeBracketData | TournamentTableBracketData | undefined, +}> { + subscription!: PSSubscription; + renderTableBracket(data: TournamentTableBracketData) { + if (data.tableContents.length === 0) + return null; + + return + + + {data.tableHeaders.cols.map(name => )} + + {data.tableHeaders.rows.map((name, r) => + + {data.tableContents[r].map(cell => cell ? ( + + ) : ( + + ))} + + )} +
{name}
{name} + {cell.state === 'unavailable' ? ( + "Unavailable" + ) : cell.state === 'available' ? ( + "Waiting" + ) : cell.state === 'challenging' ? ( + "Challenging" + ) : cell.state === 'inprogress' ? ( + In-progress + ) : cell.state === 'finished' ? ( + cell.score.join(" - ") + ) : null} + {data.scores[r]}
; + } + dragging: { + x: number, + y: number, + } | null = null; + onMouseDown = (ev: MouseEvent) => { + const elem = this.base!; + const canScrollVertically = elem.scrollHeight > elem.clientHeight; + const canScrollHorizontally = elem.scrollWidth > elem.clientWidth; + if (!canScrollVertically && !canScrollHorizontally) return; + + ev.preventDefault(); + // in case mouse moves outside the element + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + this.dragging = { + x: ev.pageX, + y: ev.pageY, + }; + elem.style.cursor = 'grabbing'; + }; + onMouseMove = (ev: MouseEvent) => { + if (!this.dragging) return; + const dx = ev.pageX - this.dragging.x; + const dy = ev.pageY - this.dragging.y; + this.dragging.x = ev.pageX; + this.dragging.y = ev.pageY; + const elem = this.base!; + elem.scrollLeft -= dx; + elem.scrollTop -= dy; + }; + onMouseUp = (ev: MouseEvent) => { + if (!this.dragging) return; + this.dragging = null; + const elem = this.base!; + elem.style.cursor = 'grab'; + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + }; + override componentWillUnmount(): void { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + } + override componentDidUpdate() { + const elem = this.base!; + const canScrollVertically = elem.scrollHeight > elem.clientHeight; + const canScrollHorizontally = elem.scrollWidth > elem.clientWidth; + if (!canScrollVertically && !canScrollHorizontally) { + elem.style.cursor = 'default'; + } else { + elem.style.cursor = 'grab'; + } + } + override componentDidMount() { + this.componentDidUpdate(); + } + render() { + const data = this.props.data; + return
+ {data?.type === 'table' ? this.renderTableBracket(data) : + data?.type === 'tree' ? : + null} +
; + } +} +export class TournamentTreeBracket extends preact.Component<{ + data: TournamentTreeBracketData, +}> { + d3Loaded = true; + generateTreeBracket(data: TournamentTreeBracketData) { + const div = document.createElement('div'); + div.className = 'tournament-bracket-tree'; + + if (!data.rootNode) { + const users = data.users; + if (users?.length) { + div.innerHTML = BattleLog.html`${users.length} user${users.length !== 1 ? 's' : ''}:
${users.join(", ")}`; + } else { + div.innerHTML = BattleLog.html`0 users`; + } + return div; + } + if (!window.d3) { + this.d3Loaded = false; + div.innerHTML = `d3 not loaded yet`; + return div; + } + this.d3Loaded = true; + + let name = PS.user.name; + let nodeSize: any = { + width: 150, height: 20, + radius: 5, + separationX: 30, separationY: 15, + }; + + let nodesByDepth = []; + let stack = [{ node: data.rootNode, depth: 0 }]; + while (stack.length > 0) { + let frame = stack.pop()!; + + if (!nodesByDepth[frame.depth]) + nodesByDepth.push(0); + ++nodesByDepth[frame.depth]; + + if (!frame.node.children) frame.node.children = []; + for (const child of frame.node.children) { + stack.push({ node: child, depth: frame.depth + 1 }); + } + } + let maxDepth = nodesByDepth.length; + let maxWidth = 0; + for (const nodes of nodesByDepth) { + if (nodes > maxWidth) + maxWidth = nodes; + } + + nodeSize.realWidth = nodeSize.width + nodeSize.radius * 2; + nodeSize.realHeight = nodeSize.height + nodeSize.radius * 2; + nodeSize.smallRealHeight = nodeSize.height / 2 + nodeSize.radius * 2; + let size = { + width: nodeSize.realWidth * maxDepth + nodeSize.separationX * maxDepth, + height: nodeSize.realHeight * (maxWidth + 0.5) + nodeSize.separationY * maxWidth, + }; + + let tree = d3.layout.tree() + .size([size.height, size.width - nodeSize.realWidth - nodeSize.separationX]) + .separation(() => 1) + .children(node => ( + node.children.length === 0 ? null! : node.children + )); + let nodes = tree.nodes(data.rootNode); + let links = tree.links(nodes); + + let layoutRoot = d3.select(div) + .append('svg:svg').attr('width', size.width).attr('height', size.height) + .append('svg:g') + .attr('transform', `translate(${-(nodeSize.realWidth + nodeSize.separationX) / 2},0)`); + + let diagonalLink = d3.svg.diagonal() + .source(link => ({ + x: link.source.x, y: link.source.y + nodeSize.realWidth / 2, + })) + .target(link => ({ + x: link.target.x, y: link.target.y - nodeSize.realWidth / 2, + })) + .projection(link => [ + size.width - link.y, link.x, + ]); + layoutRoot.selectAll('path.tournament-bracket-tree-link').data(links).enter() + .append('svg:path') + .attr('d', diagonalLink as any) + .classed('tournament-bracket-tree-link', true) + .classed('tournament-bracket-tree-link-active', link => ( + link.source.state === 'finished' && link.source.team === link.target.team + )); + + let nodeGroup = layoutRoot.selectAll('g.tournament-bracket-tree-node').data(nodes).enter() + .append('svg:g').classed('tournament-bracket-tree-node', true).attr('transform', node => ( + `translate(${size.width - node.y},${node.x})` + )); + nodeGroup.append('svg:rect') + .attr('rx', nodeSize.radius) + .attr('x', -nodeSize.realWidth / 2).attr('width', nodeSize.realWidth) + .each(function (this: EventTarget, node) { + let elem = d3.select(this); + if (node.children.length === 0) + elem.attr('y', -nodeSize.smallRealHeight / 2).attr('height', nodeSize.smallRealHeight); + else + elem.attr('y', -nodeSize.realHeight / 2).attr('height', nodeSize.realHeight); + if (node.team === name) elem.attr('stroke-dasharray', '5,5'); + }); + nodeGroup.each(function (this: EventTarget, node) { + let elem = d3.select(this); + if (node.children.length === 0) { + elem.classed('tournament-bracket-tree-node-team', true); + elem.append('svg:text').text(node.team || "Unavailable"); + } else { + elem.classed('tournament-bracket-tree-node-match', true); + elem.classed('tournament-bracket-tree-node-match-' + node.state, true); + if (node.state === 'unavailable') + elem.append('svg:text').text("Unavailable"); + else { + let teams = elem.append('svg:text').attr('y', -nodeSize.realHeight / 5) + .classed('tournament-bracket-tree-node-match-teams', true); + let teamA = teams.append('svg:tspan').classed('tournament-bracket-tree-node-match-team', true) + .text(node.children[0].team); + teams.append('svg:tspan').text(" vs "); + let teamB = teams.append('svg:tspan').classed('tournament-bracket-tree-node-match-team', true) + .text(node.children[1].team); + + let score = elem.append('svg:text').attr('y', nodeSize.realHeight / 5); + if (node.state === 'available') + score.text("Waiting"); + else if (node.state === 'challenging') + score.text("Challenging"); + else if (node.state === 'inprogress') + score.append('svg:a').attr('xlink:href', toRoomid(node.room).toLowerCase()).classed('ilink', true).text("In-progress").on('click', () => { + const ev = d3.event as MouseEvent; + if (ev.metaKey || ev.ctrlKey) return; + ev.preventDefault(); + ev.stopPropagation(); + let roomid = (ev.currentTarget as Element).getAttribute('href'); + PS.join(roomid as RoomID); + }); + else if (node.state === 'finished') { + if (node.result === 'win') { + teamA.classed('tournament-bracket-tree-node-match-team-win', true); + teamB.classed('tournament-bracket-tree-node-match-team-loss', true); + } else if (node.result === 'loss') { + teamA.classed('tournament-bracket-tree-node-match-team-loss', true); + teamB.classed('tournament-bracket-tree-node-match-team-win', true); + } else { + teamA.classed('tournament-bracket-tree-node-match-team-draw', true); + teamB.classed('tournament-bracket-tree-node-match-team-draw', true); + } + + elem.classed('tournament-bracket-tree-node-match-result-' + node.result, true); + score.text(node.score.join(" - ")); + } + } + } + + if (node.parent?.state === 'finished') { + if (node.parent.result === 'draw') + elem.classed('tournament-bracket-tree-node-draw', true); + else if (node.team === node.parent.team) + elem.classed('tournament-bracket-tree-node-win', true); + else + elem.classed('tournament-bracket-tree-node-loss', true); + } + }); + + return div; + }; + override componentDidMount() { + this.base!.appendChild(this.generateTreeBracket(this.props.data)); + } + override shouldComponentUpdate(props: { data: TournamentTreeBracketData }) { + if (props.data === this.props.data && this.d3Loaded) return false; + this.base!.replaceChild(this.generateTreeBracket(props.data), this.base!.children[0]); + return false; + } + render() { + return
; + } +} diff --git a/play.pokemonshowdown.com/src/panel-chat.tsx b/play.pokemonshowdown.com/src/panel-chat.tsx index 13be75adc..f88ed6383 100644 --- a/play.pokemonshowdown.com/src/panel-chat.tsx +++ b/play.pokemonshowdown.com/src/panel-chat.tsx @@ -18,6 +18,7 @@ import type { Args } from "./battle-text-parser"; import { PSLoginServer } from "./client-connection"; import type { BattleRoom } from "./panel-battle"; import { BattleChoiceBuilder } from "./battle-choices"; +import { ChatTournament, TournamentBox } from "./panel-chat-tournament"; declare const formatText: any; // from js/server/chat-formatter.js @@ -44,6 +45,7 @@ export class ChatRoom extends PSRoom { /** n.b. this will be null outside of battle rooms */ battle: Battle | null = null; log: BattleLog | null = null; + tour: ChatTournament | null = null; joinLeave: { join: string[], leave: string[], messageId: string } | null = null; @@ -84,6 +86,11 @@ export class ChatRoom extends PSRoom { this.renameUser(args[1], args[2]); break; + case 'tournament': case 'tournaments': + this.tour ||= new ChatTournament(this); + this.tour.receiveLine(args); + return; + case 'c': if (`${args[2]} `.startsWith('/challenge ')) { this.updateChallenge(args[1], args[2].slice(11)); @@ -92,6 +99,7 @@ export class ChatRoom extends PSRoom { // falls through case 'c:': this.joinLeave = null; + if (this.tour) this.tour.joinLeave = null; this.subtleNotify(); break; } @@ -808,10 +816,10 @@ class ChatPanel extends PSRoomPanel { : null; return -
- + {challengeTo || challengeFrom && [challengeTo, challengeFrom]} + {room.tour && } diff --git a/play.pokemonshowdown.com/src/panel-ladder.tsx b/play.pokemonshowdown.com/src/panel-ladder.tsx index 26ed65431..e7425df75 100644 --- a/play.pokemonshowdown.com/src/panel-ladder.tsx +++ b/play.pokemonshowdown.com/src/panel-ladder.tsx @@ -132,7 +132,7 @@ class LadderFormatPanel extends PSRoomPanel { const room = this.props.room; return

- {BattleLog.formatName(room.format!)} Top + {BattleLog.formatName(room.format)} Top {room.searchValue ? ` - '${room.searchValue}'` : " 500"}

; } diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx index e8b22fc11..aca66a539 100644 --- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx +++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx @@ -644,28 +644,43 @@ class TeamDropdown extends preact.Component<{ format: string }> { } export class TeamForm extends preact.Component<{ - children: preact.ComponentChildren, class?: string, format?: string, teamFormat?: string, + children: preact.ComponentChildren, class?: string, format?: string, teamFormat?: string, hideFormat?: boolean, onSubmit: ((e: Event, format: string, team?: Team) => void) | null, + onValidate?: ((e: Event, format: string, team?: Team) => void) | null, }> { - override state = { format: '[Gen 7] Random Battle' }; - changeFormat = (e: Event) => { - this.setState({ format: (e.target as HTMLButtonElement).value }); + override state = { format: `[Gen ${Dex.gen}] Random Battle` }; + changeFormat = (ev: Event) => { + this.setState({ format: (ev.target as HTMLButtonElement).value }); }; - submit = (e: Event) => { - e.preventDefault(); - const format = this.base!.querySelector('button[name=format]')!.value; + submit = (ev: Event) => { + ev.preventDefault(); + const format = this.state.format; const teamKey = this.base!.querySelector('button[name=team]')!.value; const team = teamKey ? PS.teams.byKey[teamKey] : undefined; - this.props.onSubmit?.(e, format, team); + this.props.onSubmit?.(ev, format, team); + }; + handleClick = (ev: Event) => { + let target = ev.target as HTMLButtonElement | null; + while (target && target !== this.base) { + if (target.tagName === 'BUTTON' && target.name === 'validate') { + ev.preventDefault(); + const format = this.state.format; + const teamKey = this.base!.querySelector('button[name=team]')!.value; + const team = teamKey ? PS.teams.byKey[teamKey] : undefined; + this.props.onSubmit?.(ev, format, team); + return; + } + target = target.parentNode as HTMLButtonElement | null; + } }; render() { - return
-

+ return + {!this.props.hideFormat &&

-

+

}