diff --git a/Assets/Img/Profile.png b/Assets/Img/Profile.png
new file mode 100644
index 0000000..d4fe288
Binary files /dev/null and b/Assets/Img/Profile.png differ
diff --git a/Pages/Bits/Navbar.php b/Pages/Bits/Navbar.php
index 78bf0ae..67d4c6a 100644
--- a/Pages/Bits/Navbar.php
+++ b/Pages/Bits/Navbar.php
@@ -3,7 +3,8 @@
$NavbarPages = [
"Home" => "/",
"Sarahtonin" => "/Pages/Sarahtonin.html",
- "Garden" => "/Pages/Garden.html"
+ "Garden" => "/Pages/Garden.html",
+ "Profile" => "/Pages/Profile.html"
];
?>
diff --git a/Pages/Profile.php b/Pages/Profile.php
new file mode 100644
index 0000000..ee61212
--- /dev/null
+++ b/Pages/Profile.php
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+ Profile
+
+
+ Manage your Garden profile, view your
+ statistics, and move your account between
+ devices.
+
+
+
+
+
+
+
+ Statistics
+
+
+
+
+ Current Dew
+
+
+
+ ...
+
+
+
+
+
+ Lifetime Dew
+
+
+
+ ...
+
+
+
+
+
+ Plants discovered
+
+
+
+ ...
+
+
+
+
+
+ Mutations discovered
+
+
+
+ ...
+
+
+
+
+
+ Garden size
+
+
+
+ ...
+
+
+
+
+
+
+
+ Account
+
+
+ Your Account Key is the secret key used
+ to identify your Garden account.
+
+
+
+ You can use the same Account Key on
+ another device to access the same
+ username, save, and leaderboard entry.
+
+
+
+
+ Anyone with your Account Key can
+ access your Garden account.
+
+ Keep it private and treat it like a
+ password.
+
+
+
+
+
+ Show Account Key
+
+
+
+
+ Do not share this key with
+ anyone you do not trust.
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Import Account
+
+
+ To use an existing Garden account on
+ this device, paste its Account Key
+ below.
+
+
+
+ Importing an Account Key will replace
+ the account currently used by this
+ browser. It will not delete the old
+ account or its server save.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Scripts/Garden/Account.js b/Scripts/Garden/Account.js
new file mode 100644
index 0000000..610c071
--- /dev/null
+++ b/Scripts/Garden/Account.js
@@ -0,0 +1,581 @@
+function StartAccount() {
+ const AccountKeySpoiler =
+ document.querySelector(
+ ".AccountKeySpoiler"
+ );
+
+ const CopyButton =
+ document.getElementById(
+ "CopyAccountKeyButton"
+ );
+
+ const ExportButton =
+ document.getElementById(
+ "ExportAccountKeyButton"
+ );
+
+ const ImportForm =
+ document.getElementById(
+ "ImportAccountForm"
+ );
+
+ const ImportFileButton =
+ document.getElementById(
+ "ImportAccountKeyFileButton"
+ );
+
+ const ImportFileInput =
+ document.getElementById(
+ "ImportAccountKeyFile"
+ );
+
+
+ AccountKeySpoiler.addEventListener(
+ "toggle",
+ RenderAccountKeySpoiler
+ );
+
+ CopyButton.addEventListener(
+ "click",
+ CopyAccountKey
+ );
+
+ ExportButton.addEventListener(
+ "click",
+ ExportAccountKey
+ );
+
+ ImportForm.addEventListener(
+ "submit",
+ SubmitAccountImport
+ );
+
+ ImportFileButton.addEventListener(
+ "click",
+ () => {
+ ImportFileInput.click();
+ }
+ );
+
+ ImportFileInput.addEventListener(
+ "change",
+ ImportAccountKeyFile
+ );
+}
+
+
+function RenderAccountKeySpoiler() {
+ const Spoiler =
+ document.querySelector(
+ ".AccountKeySpoiler"
+ );
+
+ const Summary =
+ Spoiler.querySelector(
+ "summary"
+ );
+
+ const Display =
+ document.getElementById(
+ "AccountKeyDisplay"
+ );
+
+
+ if (Spoiler.open) {
+ Display.textContent =
+ GetSaveKey();
+
+ Summary.textContent =
+ "Hide Account Key";
+
+ return;
+ }
+
+
+ /*
+ * Remove the key from the page when
+ * the spoiler is closed again.
+ */
+
+ Display.textContent =
+ "Hidden";
+
+ Summary.textContent =
+ "Show Account Key";
+}
+
+
+async function CopyAccountKey() {
+ const AccountKey =
+ GetSaveKey();
+
+ const Message =
+ document.getElementById(
+ "AccountKeyMessage"
+ );
+
+
+ try {
+ if (
+ navigator.clipboard !==
+ undefined
+ ) {
+ await navigator.clipboard.writeText(
+ AccountKey
+ );
+ } else {
+ CopyTextFallback(
+ AccountKey
+ );
+ }
+
+ Message.textContent =
+ "Account Key copied.";
+ } catch (Error) {
+ console.error(
+ "Couldn't copy Account Key:",
+ Error
+ );
+
+ try {
+ CopyTextFallback(
+ AccountKey
+ );
+
+ Message.textContent =
+ "Account Key copied.";
+ } catch (FallbackError) {
+ console.error(
+ "Couldn't copy Account Key:",
+ FallbackError
+ );
+
+ Message.textContent =
+ "Couldn't copy Account Key.";
+ }
+ }
+}
+
+
+function CopyTextFallback(
+ Text
+) {
+ const TextArea =
+ document.createElement(
+ "textarea"
+ );
+
+ TextArea.value =
+ Text;
+
+ TextArea.setAttribute(
+ "readonly",
+ ""
+ );
+
+ TextArea.style.position =
+ "fixed";
+
+ TextArea.style.opacity =
+ "0";
+
+
+ document.body.appendChild(
+ TextArea
+ );
+
+ TextArea.select();
+
+
+ const Success =
+ document.execCommand(
+ "copy"
+ );
+
+
+ TextArea.remove();
+
+
+ if (!Success) {
+ throw new Error(
+ "Copy command failed."
+ );
+ }
+}
+
+
+function ExportAccountKey() {
+ const AccountKey =
+ GetSaveKey();
+
+ const Message =
+ document.getElementById(
+ "AccountKeyMessage"
+ );
+
+
+ try {
+ const File =
+ new Blob(
+ [
+ AccountKey +
+ "\n"
+ ],
+ {
+ type:
+ "text/plain;charset=utf-8"
+ }
+ );
+
+ const FileUrl =
+ URL.createObjectURL(
+ File
+ );
+
+ const Link =
+ document.createElement(
+ "a"
+ );
+
+ Link.href =
+ FileUrl;
+
+ Link.download =
+ "SarahtoninGardenAccountKey.txt";
+
+
+ document.body.appendChild(
+ Link
+ );
+
+ Link.click();
+
+ Link.remove();
+
+
+ URL.revokeObjectURL(
+ FileUrl
+ );
+
+
+ Message.textContent =
+ "Account Key exported.";
+ } catch (Error) {
+ console.error(
+ "Couldn't export Account Key:",
+ Error
+ );
+
+ Message.textContent =
+ "Couldn't export Account Key.";
+ }
+}
+
+
+async function SubmitAccountImport(
+ Event
+) {
+ Event.preventDefault();
+
+ const Input =
+ document.getElementById(
+ "ImportAccountKeyInput"
+ );
+
+ await ImportAccountKey(
+ Input.value
+ );
+}
+
+
+async function ImportAccountKeyFile(
+ Event
+) {
+ const File =
+ Event.target.files[0];
+
+ if (File === undefined) {
+ return;
+ }
+
+
+ const Message =
+ document.getElementById(
+ "ImportAccountMessage"
+ );
+
+
+ try {
+ const AccountKey =
+ await File.text();
+
+ const Input =
+ document.getElementById(
+ "ImportAccountKeyInput"
+ );
+
+ Input.value =
+ AccountKey.trim();
+
+ await ImportAccountKey(
+ AccountKey
+ );
+ } catch (Error) {
+ console.error(
+ "Couldn't read Account Key file:",
+ Error
+ );
+
+ Message.textContent =
+ "Couldn't read that Account Key file.";
+ } finally {
+ /*
+ * Allows the same file to be
+ * selected again later.
+ */
+
+ Event.target.value =
+ "";
+ }
+}
+
+
+async function ImportAccountKey(
+ RawAccountKey
+) {
+ const Message =
+ document.getElementById(
+ "ImportAccountMessage"
+ );
+
+ const AccountKey =
+ NormalizeAccountKey(
+ RawAccountKey
+ );
+
+
+ if (
+ !IsValidAccountKey(
+ AccountKey
+ )
+ ) {
+ Message.textContent =
+ "That isn't a valid Account Key.";
+
+ return;
+ }
+
+
+ const CurrentAccountKey =
+ GetSaveKey();
+
+
+ if (
+ AccountKey ===
+ CurrentAccountKey
+ ) {
+ Message.textContent =
+ "You're already using that account.";
+
+ return;
+ }
+
+
+ Message.textContent =
+ "Checking account...";
+
+
+ /*
+ * Check that the key actually belongs
+ * to an existing Garden account before
+ * replacing this browser's key.
+ *
+ * This prevents a typo from silently
+ * creating a completely new account.
+ */
+
+ let AccountExists;
+
+ try {
+ AccountExists =
+ await CheckAccountKeyExists(
+ AccountKey
+ );
+ } catch (Error) {
+ console.error(
+ "Couldn't verify Account Key:",
+ Error
+ );
+
+ Message.textContent =
+ "Couldn't verify that account right now.";
+
+ return;
+ }
+
+
+ if (!AccountExists) {
+ Message.textContent =
+ "No Garden account exists for that key.";
+
+ return;
+ }
+
+
+ const Confirmed =
+ window.confirm(
+ "Import this Garden account?\n\n" +
+ "This browser will stop using its current " +
+ "account and switch to the imported one.\n\n" +
+ "The current account and its server save " +
+ "will not be deleted."
+ );
+
+
+ if (!Confirmed) {
+ Message.textContent =
+ "Import cancelled.";
+
+ return;
+ }
+
+
+ /*
+ * Try to synchronize the current
+ * browser save one last time before
+ * switching away from it.
+ */
+
+ const CurrentSave =
+ ReadLocalSave();
+
+
+ if (CurrentSave !== null) {
+ Message.textContent =
+ "Saving current account...";
+
+ try {
+ await WriteRemoteSave(
+ CurrentSave
+ );
+ } catch (Error) {
+ console.error(
+ "Couldn't synchronize current account:",
+ Error
+ );
+
+ const SwitchAnyway =
+ window.confirm(
+ "The current account couldn't be " +
+ "synchronized with the server.\n\n" +
+ "Switch accounts anyway?"
+ );
+
+
+ if (!SwitchAnyway) {
+ Message.textContent =
+ "Import cancelled.";
+
+ return;
+ }
+ }
+ }
+
+
+ /*
+ * Change identity first, then remove
+ * the cached save.
+ *
+ * On reload, LoadGame() sees no local
+ * save and downloads the imported
+ * account's save from the server.
+ */
+
+ localStorage.setItem(
+ SaveKeyName,
+ AccountKey
+ );
+
+ localStorage.removeItem(
+ LocalSaveName
+ );
+
+
+ Message.textContent =
+ "Account imported. Reloading...";
+
+
+ location.reload();
+}
+
+
+function NormalizeAccountKey(
+ AccountKey
+) {
+ return AccountKey
+ .trim()
+ .toLowerCase();
+}
+
+
+function IsValidAccountKey(
+ AccountKey
+) {
+ return /^[a-f0-9]{64}$/.test(
+ AccountKey
+ );
+}
+
+
+async function CheckAccountKeyExists(
+ AccountKey
+) {
+ const Response =
+ await fetch(
+ ApiUrl + "/Load.php",
+ {
+ method: "POST",
+
+ headers: {
+ "Content-Type":
+ "application/json"
+ },
+
+ body: JSON.stringify({
+ SaveKey:
+ AccountKey
+ })
+ }
+ );
+
+
+ if (!Response.ok) {
+ throw new Error(
+ "Load API returned HTTP " +
+ Response.status
+ );
+ }
+
+
+ const Result =
+ await Response.json();
+
+
+ if (!Result.Success) {
+ throw new Error(
+ Result.Error ??
+ "Account lookup failed."
+ );
+ }
+
+
+ return (
+ Result.Exists === true
+ );
+}
+
+
+document.addEventListener(
+ "DOMContentLoaded",
+ StartAccount
+);
diff --git a/Scripts/Garden/Profile.js b/Scripts/Garden/Profile.js
new file mode 100644
index 0000000..660993c
--- /dev/null
+++ b/Scripts/Garden/Profile.js
@@ -0,0 +1,268 @@
+let ProfileSave = null;
+
+
+async function StartProfile() {
+ await RenderProfileUsername();
+
+ try {
+ ProfileSave =
+ await LoadGame();
+
+ RenderProfileStatistics();
+ } catch (Error) {
+ console.error(
+ "Couldn't load profile statistics:",
+ Error
+ );
+
+ RenderProfileStatisticsError();
+ }
+
+
+ const UsernameForm =
+ document.getElementById(
+ "ProfileUsernameForm"
+ );
+
+ UsernameForm.addEventListener(
+ "submit",
+ SubmitProfileUsername
+ );
+}
+
+
+async function RenderProfileUsername() {
+ const Input =
+ document.getElementById(
+ "ProfileUsernameInput"
+ );
+
+ const Message =
+ document.getElementById(
+ "ProfileUsernameMessage"
+ );
+
+ try {
+ const Profile =
+ await GetProfile();
+
+ if (!Profile.Success) {
+ throw new Error(
+ Profile.Error ??
+ "Couldn't load profile."
+ );
+ }
+
+ if (
+ Profile.Username !== null
+ ) {
+ Input.value =
+ Profile.Username;
+ }
+
+ Message.textContent = "";
+ } catch (Error) {
+ console.error(
+ "Couldn't load username:",
+ Error
+ );
+
+ Message.textContent =
+ "Couldn't load username.";
+ }
+}
+
+
+async function SubmitProfileUsername(
+ Event
+) {
+ Event.preventDefault();
+
+ const Input =
+ document.getElementById(
+ "ProfileUsernameInput"
+ );
+
+ const Message =
+ document.getElementById(
+ "ProfileUsernameMessage"
+ );
+
+ const Username =
+ Input.value.trim();
+
+
+ if (
+ Username.length < 3 ||
+ Username.length > 24
+ ) {
+ Message.textContent =
+ "Username must be between 3 and 24 characters.";
+
+ return;
+ }
+
+
+ Message.textContent =
+ "Saving...";
+
+
+ try {
+ const Result =
+ await SetUsername(
+ Username
+ );
+
+ if (!Result.Success) {
+ Message.textContent =
+ Result.Error ??
+ "Couldn't change username.";
+
+ return;
+ }
+
+ Input.value =
+ Username;
+
+ Message.textContent =
+ "Username saved.";
+ } catch (Error) {
+ console.error(
+ "Couldn't change username:",
+ Error
+ );
+
+ Message.textContent =
+ "Couldn't change username.";
+ }
+}
+
+
+function RenderProfileStatistics() {
+ const CurrentDew =
+ ProfileSave
+ ?.Currency
+ ?.Dew ??
+ 0;
+
+ const LifetimeDew =
+ ProfileSave
+ ?.Statistics
+ ?.CurrencyEarned
+ ?.Dew ??
+ CurrentDew;
+
+ const PlantsDiscovered =
+ ProfileSave
+ ?.Discoveries
+ ?.Plants
+ ?.length ??
+ 0;
+
+ const MutationsDiscovered =
+ ProfileSave
+ ?.Discoveries
+ ?.Mutations
+ ?.length ??
+ 0;
+
+ const GardenWidth =
+ ProfileSave
+ ?.Garden
+ ?.Width ??
+ 0;
+
+ const GardenHeight =
+ ProfileSave
+ ?.Garden
+ ?.Height ??
+ 0;
+
+
+ SetProfileStatistic(
+ "ProfileCurrentDew",
+ FormatProfileNumber(
+ CurrentDew
+ )
+ );
+
+ SetProfileStatistic(
+ "ProfileLifetimeDew",
+ FormatProfileNumber(
+ LifetimeDew
+ )
+ );
+
+ SetProfileStatistic(
+ "ProfilePlantsDiscovered",
+ FormatProfileNumber(
+ PlantsDiscovered
+ )
+ );
+
+ SetProfileStatistic(
+ "ProfileMutationsDiscovered",
+ FormatProfileNumber(
+ MutationsDiscovered
+ )
+ );
+
+ SetProfileStatistic(
+ "ProfileGardenSize",
+ GardenWidth +
+ "×" +
+ GardenHeight
+ );
+}
+
+
+function RenderProfileStatisticsError() {
+ const StatisticIds = [
+ "ProfileCurrentDew",
+ "ProfileLifetimeDew",
+ "ProfilePlantsDiscovered",
+ "ProfileMutationsDiscovered",
+ "ProfileGardenSize"
+ ];
+
+ for (
+ const StatisticId
+ of StatisticIds
+ ) {
+ SetProfileStatistic(
+ StatisticId,
+ "?"
+ );
+ }
+}
+
+
+function SetProfileStatistic(
+ ElementId,
+ Value
+) {
+ const Element =
+ document.getElementById(
+ ElementId
+ );
+
+ if (Element === null) {
+ return;
+ }
+
+ Element.textContent =
+ Value;
+}
+
+
+function FormatProfileNumber(
+ Number
+) {
+ return Number.toLocaleString();
+}
+
+
+document.addEventListener(
+ "DOMContentLoaded",
+ StartProfile
+);
diff --git a/Scripts/Garden/Save.js b/Scripts/Garden/Save.js
index 374d5ad..2ea285d 100644
--- a/Scripts/Garden/Save.js
+++ b/Scripts/Garden/Save.js
@@ -145,18 +145,21 @@ async function WriteRemoteSave(SaveData) {
}
-async function ReadRemoteSave() {
+async function ReadRemoteSave(
+ SaveKey = GetSaveKey()
+) {
const Response = await fetch(
ApiUrl + "/Load.php",
{
method: "POST",
headers: {
- "Content-Type": "application/json"
+ "Content-Type":
+ "application/json"
},
body: JSON.stringify({
- SaveKey: GetSaveKey()
+ SaveKey: SaveKey
})
}
);
@@ -168,7 +171,8 @@ async function ReadRemoteSave() {
);
}
- const Result = await Response.json();
+ const Result =
+ await Response.json();
if (!Result.Success) {
throw new Error(
@@ -184,7 +188,6 @@ async function ReadRemoteSave() {
return Result.SaveData;
}
-
async function SaveGame(SaveData) {
SaveData.Revision++;
SaveData.LastSavedAt = Date.now();