diff --git a/play.pokemonshowdown.com/src/client-connection.ts b/play.pokemonshowdown.com/src/client-connection.ts index 3ead322e7..a893b531c 100644 --- a/play.pokemonshowdown.com/src/client-connection.ts +++ b/play.pokemonshowdown.com/src/client-connection.ts @@ -105,7 +105,7 @@ export const PSLoginServer = new class { () => null ); } - query(act: string, data: PostData): Promise<{ [k: string]: any } | null> { + query(act: string, data: PostData = {}): Promise<{ [k: string]: any } | null> { return this.rawQuery(act, data).then( res => res ? JSON.parse(res.slice(1)) : null ).catch( diff --git a/play.pokemonshowdown.com/src/client-main.ts b/play.pokemonshowdown.com/src/client-main.ts index 729ec4060..05d1bbcde 100644 --- a/play.pokemonshowdown.com/src/client-main.ts +++ b/play.pokemonshowdown.com/src/client-main.ts @@ -17,6 +17,7 @@ import type { MainMenuRoom } from './panel-mainmenu'; import { Dex, toID, type ID } from './battle-dex'; import { BattleTextParser, type Args } from './battle-text-parser'; import type { BattleRoom } from './panel-battle'; +import { PSTeambuilder } from './panel-teamdropdown'; declare const BattleTextAFD: any; declare const BattleTextNotAFD: any; @@ -116,6 +117,7 @@ class PSPrefs extends PSStreamModel { effectvolume = 50; musicvolume = 50; notifvolume = 50; + uploadprivacy = false; afd: boolean | 'sprites' = false; @@ -267,11 +269,29 @@ class PSPrefs extends PSStreamModel { export interface Team { name: string; format: ID; - packedTeam: string; folder: string; + /** note that this can be wrong if .uploaded?.loaded === false */ + packedTeam: string; /** The icon cache must be cleared (to `null`) whenever `packedTeam` is modified */ iconCache: preact.ComponentChildren; key: string; + /** `uploaded` will only exist if you're logged into the correct account. otherwise teamid is still tracked */ + teamid?: number; + uploaded?: { + teamid: number, + notLoaded: boolean | Promise, + /** password, if private. null = public, undefined = unknown, not loaded yet */ + private?: string | null, + }; +} +interface UploadedTeam { + name: string; + teamid: number; + format: ID; + /** comma-separated list of species, for generating the icon cache */ + team: string; + /** password, if private */ + private?: string | null; } if (!window.BattleFormats) window.BattleFormats = {}; @@ -284,6 +304,7 @@ class PSTeams extends PSStreamModel<'team' | 'format'> { list: Team[] = []; byKey: { [key: string]: Team | undefined } = {}; deletedTeams: [Team, number][] = []; + uploading: Team | null = null; constructor() { super(); try { @@ -356,7 +377,10 @@ class PSTeams extends PSStreamModel<'team' | 'format'> { } packAll(teams: Team[]) { return teams.map(team => ( - (team.format ? `${team.format}]` : ``) + (team.folder ? `${team.folder}/` : ``) + team.name + `|` + team.packedTeam + (team.teamid ? `${team.teamid}[` : '') + + (team.format ? `${team.format}]` : ``) + + (team.folder ? `${team.folder}/` : ``) + + team.name + `|` + team.packedTeam )).join('\n'); } save() { @@ -366,15 +390,21 @@ class PSTeams extends PSStreamModel<'team' | 'format'> { this.update('team'); } unpackLine(line: string): Team | null { - let pipeIndex = line.indexOf('|'); + const pipeIndex = line.indexOf('|'); if (pipeIndex < 0) return null; let bracketIndex = line.indexOf(']'); if (bracketIndex > pipeIndex) bracketIndex = -1; + let leftBracketIndex = line.indexOf('['); + if (leftBracketIndex < 0) leftBracketIndex = 0; + const isBox = line.slice(0, bracketIndex).endsWith('-box'); let slashIndex = line.lastIndexOf('/', pipeIndex); if (slashIndex < 0) slashIndex = bracketIndex; // line.slice(slashIndex + 1, pipeIndex) will be '' - let format = bracketIndex > 0 ? line.slice(0, bracketIndex) : 'gen7'; + let format = bracketIndex > 0 ? line.slice( + (leftBracketIndex ? leftBracketIndex + 1 : 0), isBox ? bracketIndex - 4 : bracketIndex + ) : 'gen9'; if (!format.startsWith('gen')) format = 'gen6' + format; const name = line.slice(slashIndex + 1, pipeIndex); + const teamid = leftBracketIndex > 0 ? Number(line.slice(0, leftBracketIndex)) : undefined; return { name, format: format as ID, @@ -382,8 +412,122 @@ class PSTeams extends PSStreamModel<'team' | 'format'> { packedTeam: line.slice(pipeIndex + 1), iconCache: null, key: '', + teamid, }; } + loadRemoteTeams() { + PSLoginServer.query('getteams').then(data => { + if (!data) return; + if (data.actionerror) { + return PS.alert('Error loading uploaded teams: ' + data.actionerror); + } + const teams: { [key: string]: UploadedTeam } = {}; + for (const team of data.teams) { + teams[team.teamid] = team; + } + + // find exact teamid matches + for (const localTeam of this.list) { + if (localTeam.teamid) { + const team = teams[localTeam.teamid]; + if (!team) { + continue; + } + const compare = this.compareTeams(team, localTeam); + if (compare !== true) { + if (!localTeam.name.endsWith(' (local version)')) localTeam.name += ' (local version)'; + continue; + } + localTeam.uploaded = { + teamid: team.teamid, + notLoaded: true, + private: team.private, + }; + delete teams[localTeam.teamid]; + } + } + + // do best-guess matches for teams that don't have a local team with matching teamid + for (const team of Object.values(teams)) { + let matched = false; + for (const localTeam of this.list) { + if (localTeam.teamid) continue; + + const compare = this.compareTeams(team, localTeam); + if (compare === 'rename') { + if (!localTeam.name.endsWith(' (local version)')) localTeam.name += ' (local version)'; + } else if (compare) { + // prioritize locally saved teams over remote + // as so to not overwrite changes + matched = true; + localTeam.teamid = team.teamid; + localTeam.uploaded = { + teamid: team.teamid, + notLoaded: true, + private: team.private, + }; + break; + } + } + if (!matched) { + const mons = team.team.split(',').map((m: string) => ({ species: m, moves: [] })); + const newTeam: Team = { + name: team.name, + format: team.format, + folder: '', + packedTeam: PSTeambuilder.packTeam(mons), + iconCache: null, + key: this.getKey(team.name), + uploaded: { + teamid: team.teamid, + notLoaded: true, + private: team.private, + }, + }; + this.push(newTeam); + } + } + }); + } + loadTeam(team: Team | undefined | null, ifNeeded: true): void | Promise; + loadTeam(team: Team | undefined | null): Promise; + loadTeam(team: Team | undefined | null, ifNeeded?: boolean): void | Promise { + if (!team) return ifNeeded ? undefined : Promise.resolve(); + if (!team.uploaded?.notLoaded) return ifNeeded ? undefined : Promise.resolve(); + if (team.uploaded.notLoaded !== true) return team.uploaded.notLoaded; + + return (team.uploaded.notLoaded = PSLoginServer.query('getteam', { + teamid: team.uploaded.teamid, + }).then(data => { + if (!team.uploaded) return; + if (!data?.team) { + PS.alert(`Failed to load team: ${data?.actionerror || "Error unknown. Try again later."}`); + return; + } + team.uploaded.notLoaded = false; + team.packedTeam = data.team; + PS.teams.save(); + })); + } + compareTeams(serverTeam: UploadedTeam, localTeam: Team) { + // TODO: decide if we want this + // if (serverTeam.teamid === localTeam.teamid && localTeam.teamid) return true; + + // if titles match exactly and mons are the same, assume they're the same team + // if they don't match, it might be edited, but we'll go ahead and add it to the user's + // teambuilder since they may want that old version around. just go ahead and edit the name + let sanitize = (name: string) => (name || "").replace(/\s+\(server version\)/g, '').trim(); + const nameMatches = sanitize(serverTeam.name) === sanitize(localTeam.name); + if (!(nameMatches && serverTeam.format === localTeam.format)) { + return false; + } + // if it's been edited since, invalidate the team id on this one (count it as new) + // and load from server + const mons = serverTeam.team.split(',').map(toID).sort().join(','); + const otherMons = PSTeambuilder.packedTeamSpecies(localTeam.packedTeam).map(toID).sort().join(','); + if (mons !== otherMons) return 'rename'; + return true; + } } /********************************************************************** diff --git a/play.pokemonshowdown.com/src/panel-mainmenu.tsx b/play.pokemonshowdown.com/src/panel-mainmenu.tsx index ec604c3a2..4deaa1d8f 100644 --- a/play.pokemonshowdown.com/src/panel-mainmenu.tsx +++ b/play.pokemonshowdown.com/src/panel-mainmenu.tsx @@ -128,6 +128,7 @@ export class MainMenuRoom extends PSRoom { const named = namedCode === '1'; if (named) PS.user.initializing = false; PS.user.setName(fullName, named, avatar); + PS.teams.loadRemoteTeams(); return; } case 'updatechallenges': { const [, challengesBuf] = args; @@ -388,6 +389,14 @@ export class MainMenuRoom extends PSRoom { } } break; + case 'teamupload': + if (PS.teams.uploading) { + PS.teams.uploading.uploaded = { + teamid: response.teamid, + notLoaded: false, + private: response.private, + }; + } } } } @@ -727,22 +736,20 @@ export class TeamForm extends preact.Component<{ changeFormat = (ev: Event) => { this.setState({ format: (ev.target as HTMLButtonElement).value }); }; - submit = (ev: Event) => { + submit = (ev: Event, validate?: '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); + PS.teams.loadTeam(team).then(() => { + (validate === 'validate' ? this.props.onValidate : 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); + this.submit(ev, 'validate'); return; } target = target.parentNode as HTMLButtonElement | null; diff --git a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx index 227eeeaba..8389231fe 100644 --- a/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx +++ b/play.pokemonshowdown.com/src/panel-teambuilder-team.tsx @@ -17,17 +17,25 @@ class TeamRoom extends PSRoom { /** Doesn't _literally_ always exist, but does in basically all code * and constantly checking for its existence is legitimately annoying... */ team!: Team; + uploaded = false; constructor(options: RoomOptions) { super(options); const team = PS.teams.byKey[this.id.slice(5)] || null; this.team = team!; this.title = `[Team] ${this.team?.name || 'Error'}`; if (team) this.setFormat(team.format); + this.uploaded = !!team?.uploaded; + this.load(); } setFormat(format: string) { const team = this.team; team.format = toID(format); } + load() { + PS.teams.loadTeam(this.team, true)?.then(() => { + this.update(null); + }); + } save() { PS.teams.save(); const title = `[Team] ${this.team?.name || 'Team'}`; @@ -81,6 +89,35 @@ class TeamPanel extends PSRoomPanel { room.team.name = textbox.value.trim(); room.save(); }; + + uploadTeam = (ev: Event) => { + const room = this.props.room; + const team = PS.teams.byKey[room.id.slice(5)]; + if (!team) return; + + const cmd = team.uploaded ? 'update' : 'save'; + // teamName, formatid, rawPrivacy, rawTeam + const buf = []; + if (team.uploaded) { + buf.push(team.uploaded.teamid); + } else if (team.teamid) { + return PS.alert(`This team is for a different account. Please log into the correct account to update it.`); + } + buf.push(team.name, team.format, PS.prefs.uploadprivacy ? 1 : 0); + const exported = team.packedTeam; + if (!exported) return PS.alert(`Add a Pokemon to your team before uploading it.`); + buf.push(exported); + PS.teams.uploading = team; + PS.send(`|/teams ${cmd} ${buf.join(', ')}`); + room.uploaded = true; + this.forceUpdate(); + }; + + changePrivacyPref = (ev: Event) => { + this.props.room.uploaded = false; + PS.prefs.uploadprivacy = !(ev.currentTarget as HTMLInputElement).checked; + PS.prefs.save(); + }; handleChangeFormat = (ev: Event) => { const dropdown = ev.currentTarget as HTMLButtonElement; const room = this.props.room; @@ -91,6 +128,8 @@ class TeamPanel extends PSRoomPanel { }; save = () => { this.props.room.save(); + this.props.room.uploaded = false; + this.forceUpdate(); }; override render() { @@ -137,6 +176,23 @@ class TeamPanel extends PSRoomPanel {

)} +

+ + {room.uploaded ? ( + + ) : ( + + )} +

; } } diff --git a/play.pokemonshowdown.com/src/panel-teamdropdown.tsx b/play.pokemonshowdown.com/src/panel-teamdropdown.tsx index 3b8ae724c..7870b3908 100644 --- a/play.pokemonshowdown.com/src/panel-teamdropdown.tsx +++ b/play.pokemonshowdown.com/src/panel-teamdropdown.tsx @@ -561,7 +561,7 @@ export class PSTeambuilder { return teams; } - static packedTeamNames(buf: string) { + static packedTeamSpecies(buf: string) { if (!buf) return []; const team = []; @@ -604,25 +604,21 @@ export function TeamBox(props: { team: Team | null, noLink?: boolean, button?: b const team = props.team; let contents; if (team) { - let icons = team.iconCache; - if (!icons) { - if (!team.packedTeam) { - icons = (empty team); - } else { - icons = PSTeambuilder.packedTeamNames(team.packedTeam).map(pokemon => - // can't use , weird interaction with iconCache - // don't try this at home; I'm a trained professional - PSIcon({ pokemon }) - ); - } - team.iconCache = icons; - } + team.iconCache ||= team.packedTeam ? ( + PSTeambuilder.packedTeamSpecies(team.packedTeam).map( + // can't use , weird interaction with iconCache + // don't try this at home; I'm a trained professional + pokemon => PSIcon({ pokemon }) + ) + ) : ( + (empty team) + ); let format = team.format as string; if (format.startsWith('gen8')) format = format.slice(4); format = (format ? `[${format}] ` : ``) + (team.folder ? `${team.folder}/` : ``); contents = [ {format && {format}}{team.name}, - {icons}, + {team.iconCache}, ]; } else { contents = [ @@ -680,6 +676,7 @@ class TeamDropdownPanel extends PSRoomPanel { } if (!target) return; + PS.teams.loadTeam(PS.teams.byKey[target.value], true); this.chooseParentValue(target.value); }; override render() {