Skip to content

Commit

Permalink
Preparing for initial public release
Browse files Browse the repository at this point in the history
  • Loading branch information
colecrouter committed Jul 3, 2023
1 parent 5690a07 commit a7da8b0
Show file tree
Hide file tree
Showing 32 changed files with 677 additions and 501 deletions.
47 changes: 47 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 6 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=0.6" />
<meta name="viewport" content="width=device-width, initial-scale=0.58" />
%sveltekit.head%
</head>

Expand Down
39 changes: 33 additions & 6 deletions src/lib/Backups.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -107,7 +109,7 @@ class BackupController {

const newFiles = new Array<File>();
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);
Expand All @@ -116,7 +118,7 @@ class BackupController {
private async save() {
let files: Array<SerializedFile> = [];
for (const file of get(this.backups)) {
files.push({
files.unshift({
name: file.name,
data: await file.text(),
lastModified: file.lastModified,
Expand All @@ -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
Expand All @@ -157,6 +159,31 @@ class BackupController {
return 0;
}

public async unshift(...files: File[]): Promise<number> {
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<File | undefined> {
const backups = get(this.backups);

Expand Down
32 changes: 32 additions & 0 deletions src/lib/ItemData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,4 +203,36 @@ export const BootsColorSheetIndex = new Map<string, number>([
["Merman's Boots", 16],
["Dragonscale Boots", 17],
["Crystal Shoes", 18],
]);

export const RingsUniqueID = new Map<string, number>([
["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],
]);
122 changes: 106 additions & 16 deletions src/lib/SaveFile.ts
Original file line number Diff line number Diff line change
@@ -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<SaveFile | undefined>();
Expand Down Expand Up @@ -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) {
Expand All @@ -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<SaveFile> => {
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 <string xsi:nil="true" /> 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<Blob> => {
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 <string xsi:nil="true" /> (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('&apos;', '\'')
.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;
};
3 changes: 2 additions & 1 deletion src/routes/(backups)/backups/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
</script>

<div class="wrapper">
Expand Down
Loading

0 comments on commit a7da8b0

Please sign in to comment.