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() {