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

-
- {#each skillValues as skill, i} +
+ {#each skillValues as skill, i} + + {/each} +
+ +

Stats

+ +
+ + + + + - {/each} -
- -

Stats

- -
- - - - - - -
- -

Wallet

- -
-
📙
-
🗝️
-
🃏
-
🍀
-
💀
-
🔍
-
🌑
-
🖋️
-
🏘️
-
- +
+ +

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 @@