diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..6d0b24a
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,47 @@
+name: Build and Deploy to Pages
+
+on:
+ push:
+ branches: ["main"]
+
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow one concurrent deployment
+concurrency:
+ group: "pages"
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/configure-pages@v1
+ id: pages
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 16
+ cache: 'npm'
+ - run: npm ci
+ - run: npm run build
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v1
+ with:
+ path: ./build
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/deploy-pages@v1
+ id: deployment
\ No newline at end of file
diff --git a/README.md b/README.md
index 7ef383c..d68ad49 100644
--- a/README.md
+++ b/README.md
@@ -10,16 +10,13 @@ Open source web-based save editor for Stardew Valley. Contributions welcome.
- [X] Modify skills
- [X] Unlock recipes
- [X] Switch characters
-- [ ] Add/remove items
+- [X] Add/remove items
+- [X] Change character appearance
+- [X] Backup save files
- [ ] Edit animals
- [ ] Edit day/weather
-- [ ] Edit farm layout
-- [ ] Edit chests
-- [ ] Map editor
-- [ ] Change character appearance
- [ ] Change Community Center bundles
- [ ] Change quests
-- [ ] Backup save files
## Disclaimer
diff --git a/package-lock.json b/package-lock.json
index cac3bda..faa7c64 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "stardew-save-editor",
"version": "0.0.1",
"devDependencies": {
- "@sveltejs/adapter-auto": "^2.0.0",
+ "@sveltejs/adapter-static": "^2.0.2",
"@sveltejs/kit": "^1.20.5",
"@types/node": "^18.15.11",
"@types/wicg-file-system-access": "^2020.9.6",
@@ -504,16 +504,13 @@
"integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==",
"dev": true
},
- "node_modules/@sveltejs/adapter-auto": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.1.0.tgz",
- "integrity": "sha512-o2pZCfATFtA/Gw/BB0Xm7k4EYaekXxaPGER3xGSY3FvzFJGTlJlZjBseaXwYSM94lZ0HniOjTokN3cWaLX6fow==",
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-2.0.2.tgz",
+ "integrity": "sha512-9wYtf6s6ew7DHUHMrt55YpD1FgV7oWql2IGsW5BXquLxqcY9vjrqCFo0TzzDpo+ZPZkW/v77k0eOP6tsAb8HmQ==",
"dev": true,
- "dependencies": {
- "import-meta-resolve": "^3.0.0"
- },
"peerDependencies": {
- "@sveltejs/kit": "^1.0.0"
+ "@sveltejs/kit": "^1.5.0"
}
},
"node_modules/@sveltejs/kit": {
@@ -1095,16 +1092,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/import-meta-resolve": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-3.0.0.tgz",
- "integrity": "sha512-4IwhLhNNA8yy445rPjD/lWh++7hMDOml2eHtd58eG7h+qK3EryMuuRbsHGPikCoAgIkkDnckKfWSk2iDla/ejg==",
- "dev": true,
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
diff --git a/package.json b/package.json
index f9fda76..8cab057 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"dump": "node --no-warnings --loader ts-node/esm src/dump.ts -O \"{'allowImportingTsExtensions': true,}\""
},
"devDependencies": {
- "@sveltejs/adapter-auto": "^2.0.0",
+ "@sveltejs/adapter-static": "^2.0.2",
"@sveltejs/kit": "^1.20.5",
"@types/node": "^18.15.11",
"@types/wicg-file-system-access": "^2020.9.6",
diff --git a/src/app.html b/src/app.html
index f303232..de1f682 100644
--- a/src/app.html
+++ b/src/app.html
@@ -4,7 +4,7 @@
-
+
%sveltekit.head%
diff --git a/src/lib/Backups.ts b/src/lib/Backups.ts
index 24f6810..2c94daf 100644
--- a/src/lib/Backups.ts
+++ b/src/lib/Backups.ts
@@ -1,6 +1,8 @@
import { browser } from "$app/environment";
-import { get, writable } from "svelte/store";
+import { error } from "@sveltejs/kit";
+import { XMLBuilder, XMLParser } from "fast-xml-parser";
import { openDB } from "idb";
+import { get, writable } from "svelte/store";
const BACKUP_LIMIT = 5;
@@ -107,7 +109,7 @@ class BackupController {
const newFiles = new Array();
for (const file of backupFiles) {
- newFiles.push(new File([file.data], file.name, { lastModified: file.lastModified, type: 'text/text' }));
+ newFiles.unshift(new File([file.data], file.name, { lastModified: file.lastModified, type: 'text/text' }));
}
this.backups.set(newFiles);
@@ -116,7 +118,7 @@ class BackupController {
private async save() {
let files: Array = [];
for (const file of get(this.backups)) {
- files.push({
+ files.unshift({
name: file.name,
data: await file.text(),
lastModified: file.lastModified,
@@ -136,9 +138,9 @@ class BackupController {
const backups = get(this.backups);
for (const file of files) {
- // Check if the file is the same as the last one, if so, replace it
- if (backups.length > 0 && await backups.at(-1)?.text() === await file.text()) {
- backups.pop();
+ // Check if the file is the same as any of the current ones, if so, return
+ if (backups.some((backup) => backup.name === file.name && backup.lastModified === file.lastModified && backup.size === file.size)) {
+ return 0;
}
// Add the file to the array
@@ -157,6 +159,31 @@ class BackupController {
return 0;
}
+ public async unshift(...files: File[]): Promise {
+ const backups = get(this.backups);
+
+ for (const file of files) {
+ // Check if the file is the same as any of the current ones, if so, return
+ if (backups.some((backup) => backup.name === file.name && backup.lastModified === file.lastModified && backup.size === file.size)) {
+ return 0;
+ }
+
+ // Add the file to the array
+ backups.unshift(file);
+
+ // If we have too many files, remove the oldest one
+ if (backups.length > BACKUP_LIMIT) {
+ backups.shift();
+ }
+
+ this.backups.set(backups);
+ };
+
+ await this.save();
+
+ return 0;
+ }
+
public async shift(): Promise {
const backups = get(this.backups);
diff --git a/src/lib/ItemData.ts b/src/lib/ItemData.ts
index 288a4f7..3362ce9 100644
--- a/src/lib/ItemData.ts
+++ b/src/lib/ItemData.ts
@@ -203,4 +203,36 @@ export const BootsColorSheetIndex = new Map([
["Merman's Boots", 16],
["Dragonscale Boots", 17],
["Crystal Shoes", 18],
+]);
+
+export const RingsUniqueID = new Map([
+ ["Small Glow Ring", 1267],
+ ["Glow Ring", 1238],
+ ["Small Magnet Ring", 1269],
+ ["Magnet Ring", 1270],
+ ["Slime Charmer Ring", 1271],
+ ["Warrior Ring", 1272],
+ ["Vampire Ring", 1273],
+ ["Savage Ring", 1274],
+ ["Ring of Yoba", 1275],
+ ["Sturdy Ring", 1276],
+ ["Burglar's Ring", 1247],
+ ["Iridium Band", 1248],
+ ["Jukebox Ring", 1249],
+ ["Amethyst Ring", 1169],
+ ["Topaz Ring", 1281],
+ ["Aquamarine Ring", 1191],
+ ["Jade Ring", 1253],
+ ["Emerald Ring", 1254],
+ ["Ruby Ring", 1285],
+ ["Crabshell Ring", 1531],
+ ["Napalm Ring", 1562],
+ ["Thorns Ring", 1590],
+ ["Lucky Ring", 1580],
+ ["Hot Java Ring", 1581],
+ ["Protection Ring", 1612],
+ ["Soul Sapper Ring", 1613],
+ ["Phoenix Ring", 1614],
+ ["Combined Ring", 1601],
+ ["Glowstone Ring", 1609],
]);
\ No newline at end of file
diff --git a/src/lib/SaveFile.ts b/src/lib/SaveFile.ts
index f391c25..d50e064 100644
--- a/src/lib/SaveFile.ts
+++ b/src/lib/SaveFile.ts
@@ -1,4 +1,6 @@
import type { Player } from "$types/save/1.5";
+import { error } from "@sveltejs/kit";
+import { XMLParser, XMLBuilder } from "fast-xml-parser";
import { get, writable, type Readable, type Writable } from "svelte/store";
export const SaveGame = writable();
@@ -61,24 +63,10 @@ export const Character = new CharacterSelector(SaveGame);
export const Download = async (save: SaveFile, filename: string) => {
if (!save || !filename) {
- console.error('Save or filename is undefined');
- return;
- }
-
- const res = await fetch('/api/toXML', {
- method: 'POST',
- body: JSON.stringify(save),
- headers: {
- 'Content-Type': 'application/json',
- },
- });
-
- if (!res.ok) {
- console.error(res);
- return;
+ throw new Error('Save or filename is undefined');
}
- const blob = await res.blob();
+ const blob = await SaveConverter.toXML(save);
// If supported, use file picker
if ('showSaveFilePicker' in window) {
@@ -105,4 +93,106 @@ export const Download = async (save: SaveFile, filename: string) => {
a.click();
URL.revokeObjectURL(url);
a.remove();
+};
+
+export const SaveConverter = {
+ toJSON: async (file: File): Promise => {
+ if (!file) { throw new Error("No file provided"); }
+
+ // Big xml file, need to read it in chunks
+ // let xml = "";
+ // const reader = file.getReader();
+ // try {
+ // while (true) {
+ // const { done, value } = await reader.read();
+ // if (done) break;
+ // xml += value.toString();
+ // }
+ // } catch (e) {
+ // console.error(e);
+ // throw error(400, `Unable to parse file: ${(e as Error).message}`);
+ // }
+
+ const xml = await file.text();
+
+ const parser = new XMLParser({ ignoreAttributes: false, allowBooleanAttributes: true });
+ const json = parser.parse(xml) as unknown;
+ if (!isSaveFile(json)) { throw error(400, "Invalid save file"); }
+
+ const gameVersion = json.SaveGame.gameVersion as string | undefined;
+ if (!["1.5"].some((v) => gameVersion?.startsWith(v))) {
+ throw new Error(`Unsupported game version: ${gameVersion}`);
+ }
+
+ // Get an array of player and farmhands
+ // We have to apply a handful of changes to each of them, so it's easier to do it in a loop, rather than doing them separately
+ const players = [json.SaveGame.player, ...json.SaveGame.locations.GameLocation.find((loc) => loc.name === "Farm")?.buildings?.Building.map((b) => b.indoors?.farmhand!).filter((f) => f) ?? []];
+
+ // Type safety enhancements
+ // 1. Inventory, switch into undefined
+ players.forEach((player) => player.items.Item = player.items.Item.map((item) => JSON.stringify(item) === '{"@_xsi:nil":"true"}' ? undefined : item));
+ // 2. For some reason, if your character knows only 1 crafting or cooking recipe, it will be an object, not an array
+ players.forEach((player) => {
+ if (player.craftingRecipes?.item && !Array.isArray(player.craftingRecipes.item)) {
+ player.craftingRecipes.item = [player.craftingRecipes.item];
+ }
+ if (player.cookingRecipes?.item && !Array.isArray(player.cookingRecipes.item)) {
+ player.cookingRecipes.item = [player.cookingRecipes.item];
+ }
+ });
+
+ return json;
+ },
+ toXML: async (json: SaveFile): Promise => {
+ if (!json) { throw new Error("No file provided"); }
+ if (!json || typeof json !== 'object' || 'gameVersion'! in json) { throw new Error("Not valid save file"); }
+
+ // Get an array of player and farmhands
+ // We have to apply a handful of changes to each of them, so it's easier to do it in a loop, rather than doing them separately
+ const players = [json.SaveGame.player, ...json.SaveGame.locations.GameLocation.find((loc) => loc.name === "Farm")?.buildings?.Building.map((b) => b.indoors?.farmhand!).filter((f) => f) ?? []];
+
+ // Undo type safety enhancements
+ // 1. Inventory, switch undefined into (for farmhands, too)
+ // @ts-expect-error
+ players.forEach((player) => player.items.Item = player.items.Item.map((item) => item === null ? { '@_xsi:nil': 'true' } : item)); // Need to check for null, because undefined gets converted to null when JSON is stringified
+ // 2. For some reason, if your character knows only 1 crafting or cooking recipe, it will be an object, not an array (we probably don't need to undo this)
+ players.forEach((player) => {
+ for (const recipe of [player.craftingRecipes, player.cookingRecipes]) {
+ if (recipe?.item && !Array.isArray(recipe.item)) {
+ recipe.item = [recipe.item];
+ }
+ }
+ });
+
+ // Copy name to Name, and stack to Stack for every item in the inventory
+ players.forEach((player) => [player.pantsItem, player.shirtItem, player.boots, player.leftRing, player.rightRing, ...player.items.Item].forEach((item) => {
+ if (item) {
+ // @ts-expect-error
+ item.Name = item.name;
+ // @ts-expect-error
+ item.Stack = item.stack;
+ // @ts-expect-error
+ item.Stackable = item.stackable;
+ }
+ }));
+
+ const builder = new XMLBuilder({ attributeNamePrefix: '@_', ignoreAttributes: false, suppressUnpairedNode: false, suppressEmptyNode: true, suppressBooleanAttributes: false });
+ const raw = builder.build(json) as string;
+ const xml = raw
+ .split('------WebKitFormBoundary')[0]
+ .trim()
+ .replaceAll(''', '\'')
+ .replaceAll('/>', ' />');
+ const blob = new Blob([xml], { type: 'text/text' });
+
+ return blob;
+ }
+};
+
+const isSaveFile = (obj: unknown): obj is SaveFile => {
+ return typeof obj === "object"
+ && obj !== null
+ && "SaveGame" in obj
+ && typeof obj.SaveGame === "object"
+ && obj.SaveGame !== null;
};
\ No newline at end of file
diff --git a/src/routes/(backups)/backups/+layout.svelte b/src/routes/(backups)/backups/+layout.svelte
index d93299e..df2c9bf 100644
--- a/src/routes/(backups)/backups/+layout.svelte
+++ b/src/routes/(backups)/backups/+layout.svelte
@@ -5,9 +5,10 @@
import { get } from 'svelte/store';
import SidebarButton from '../../SidebarButton.svelte';
import { tooltip } from '$lib/Tooltip';
+ import { browser } from '$app/environment';
// https://github.com/sveltejs/kit/issues/5434
- page.subscribe((p) => p.url.pathname === '/' && get(SaveGame) && goto('/inventory'));
+ page.subscribe((p) => browser && p.url.pathname === '/' && get(SaveGame) && goto('/inventory'));
diff --git a/src/routes/(backups)/backups/+page.svelte b/src/routes/(backups)/backups/+page.svelte
index 6e53f9b..91b2724 100644
--- a/src/routes/(backups)/backups/+page.svelte
+++ b/src/routes/(backups)/backups/+page.svelte
@@ -9,7 +9,7 @@
{#if $backups.length > 0}
- {#each [...$backups].reverse() as backup, i}
+ {#each $backups as backup, i}
{
if (!c) return;
diff --git a/src/routes/(edit)/(list)/crafting/+page.svelte b/src/routes/(edit)/(list)/crafting/+page.svelte
index fc5aa8a..2a5d9c4 100644
--- a/src/routes/(edit)/(list)/crafting/+page.svelte
+++ b/src/routes/(edit)/(list)/crafting/+page.svelte
@@ -8,7 +8,7 @@
export let data: PageData;
const recipes = data.recipes;
- let recipesUnlocked: KV[];
+ let recipesUnlocked: KV[] = [];
Character.character.subscribe((c) => {
if (!c) return;
diff --git a/src/routes/(edit)/+layout.svelte b/src/routes/(edit)/+layout.svelte
index ac13b09..94c913c 100644
--- a/src/routes/(edit)/+layout.svelte
+++ b/src/routes/(edit)/+layout.svelte
@@ -8,11 +8,12 @@
import SidebarButton from '../SidebarButton.svelte';
import Router from './Router.svelte';
import { tooltip } from '$lib/Tooltip';
+ import { browser } from '$app/environment';
export let data: LayoutData;
// If the save changes for whatever reason, go back to the main screen
- const unsub = page.subscribe(() => get(SaveGame) == undefined && goto('/'));
+ const unsub = page.subscribe(() => browser && get(SaveGame) == undefined && goto('/'));
onDestroy(() => unsub());
// Set the context for the item data for components to use
diff --git a/src/routes/(edit)/+layout.ts b/src/routes/(edit)/+layout.ts
index 40aa5ce..c94b24e 100644
--- a/src/routes/(edit)/+layout.ts
+++ b/src/routes/(edit)/+layout.ts
@@ -1,6 +1,4 @@
-import type { ObjectInformation, BigCraftable, Boots, Clothing, Furniture, Hat, Weapon, Tool, ItemInformation } from "../../types/items";
-
-export const ssr = false;
+import type { ItemInformation } from "$types/items";
// Prefetch dumped data
export const load = async ({ fetch }) => {
diff --git a/src/routes/(edit)/[other]/+page.svelte b/src/routes/(edit)/bundles/+page.svelte
similarity index 100%
rename from src/routes/(edit)/[other]/+page.svelte
rename to src/routes/(edit)/bundles/+page.svelte
diff --git a/src/routes/(edit)/character/+page.svelte b/src/routes/(edit)/character/+page.svelte
index 3e102e8..6b12bc7 100644
--- a/src/routes/(edit)/character/+page.svelte
+++ b/src/routes/(edit)/character/+page.svelte
@@ -1,23 +1,23 @@
-
- Skills
+{#if player}
+
+ Skills
-
+
+ Wallet
+
+
+
📙
+
🗝️
+
🃏
+
🍀
+
💀
+
🔍
+
🌑
+
🖋️
+
🏘️
+
+
+{/if}
diff --git a/src/routes/(upload)/+layout.svelte b/src/routes/(upload)/+layout.svelte
index bc725f0..5e57bde 100644
--- a/src/routes/(upload)/+layout.svelte
+++ b/src/routes/(upload)/+layout.svelte
@@ -5,9 +5,10 @@
import { get } from 'svelte/store';
import SidebarButton from '../SidebarButton.svelte';
import { tooltip } from '$lib/Tooltip';
+ import { browser } from '$app/environment';
// https://github.com/sveltejs/kit/issues/5434
- page.subscribe((p) => p.url.pathname === '/' && get(SaveGame) && goto('/inventory'));
+ page.subscribe((p) => browser && p.url.pathname === '/' && get(SaveGame) && goto('/inventory'));
diff --git a/src/routes/(upload)/+page.svelte b/src/routes/(upload)/+page.svelte
index cc1856d..fcd3c8d 100644
--- a/src/routes/(upload)/+page.svelte
+++ b/src/routes/(upload)/+page.svelte
@@ -1,11 +1,7 @@