diff --git a/package-lock.json b/package-lock.json index 68d97c0d..65c35f68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "htm": "^3.1.1", "immer": "^9.0.21", "preact": "^10.16.0", + "superjson": "^1.13.1", "tone": "^14.8.49" }, "devDependencies": { @@ -4173,6 +4174,20 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/core-js-compat": { "version": "3.31.1", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.31.1.tgz", @@ -6682,6 +6697,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-what": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", + "integrity": "sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -9667,6 +9693,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superjson": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-1.13.1.tgz", + "integrity": "sha512-AVH2eknm9DEd3qvxM4Sq+LTCkSXE2ssfh1t11MHMXyYXFQyQ1HLgVvV+guLTsaQnJU3gnaVo34TohHPulY/wLg==", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/supertap": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/supertap/-/supertap-3.0.1.tgz", diff --git a/package.json b/package.json index 5efc3ae7..b1aa2313 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "bugs": "https://github.com/oskarrough/slaytheweb/issues", "type": "module", "scripts": { - "start": "vite dev", + "dev": "vite dev", "lint": "eslint src tests --fix", "test": "ava", "test:watch": "ava --watch", @@ -34,6 +34,7 @@ "htm": "^3.1.1", "immer": "^9.0.21", "preact": "^10.16.0", + "superjson": "^1.13.1", "tone": "^14.8.49" }, "release-it": { diff --git a/public/web_modules/devalue.js b/public/web_modules/devalue.js new file mode 100644 index 00000000..dee62522 --- /dev/null +++ b/public/web_modules/devalue.js @@ -0,0 +1,363 @@ +const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$'; +const unsafe_chars = /[<>\b\f\n\r\t\0\u2028\u2029]/g; +const reserved = + /^(?:do|if|in|for|int|let|new|try|var|byte|case|char|else|enum|goto|long|this|void|with|await|break|catch|class|const|final|float|short|super|throw|while|yield|delete|double|export|import|native|return|switch|throws|typeof|boolean|default|extends|finally|package|private|abstract|continue|debugger|function|volatile|interface|protected|transient|implements|instanceof|synchronized)$/; + +/** @type {Record} */ +const escaped = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\0': '\\0', + '\u2028': '\\u2028', + '\u2029': '\\u2029' +}; +const object_proto_names = Object.getOwnPropertyNames(Object.prototype) + .sort() + .join('\0'); + +class DevalueError extends Error { + /** + * @param {string} message + * @param {string[]} keys + */ + constructor(message, keys) { + super(message); + this.name = 'DevalueError'; + this.path = keys.join(''); + } +} + +/** + * Turn a value into the JavaScript that creates an equivalent value + * @param {any} value + */ +export function devalue(value) { + const counts = new Map(); + + /** @type {string[]} */ + const keys = []; + + /** @param {any} thing */ + function walk(thing) { + if (typeof thing === 'function') { + throw new DevalueError(`Cannot stringify a function`, keys); + } + + if (counts.has(thing)) { + counts.set(thing, counts.get(thing) + 1); + return; + } + + counts.set(thing, 1); + + if (!is_primitive(thing)) { + const type = get_type(thing); + + switch (type) { + case 'Number': + case 'BigInt': + case 'String': + case 'Boolean': + case 'Date': + case 'RegExp': + return; + + case 'Array': + /** @type {any[]} */ (thing).forEach((value, i) => { + keys.push(`[${i}]`); + walk(value); + keys.pop(); + }); + break; + + case 'Set': + Array.from(thing).forEach(walk); + break; + + case 'Map': + for (const [key, value] of thing) { + keys.push( + `.get(${is_primitive(key) ? stringify_primitive(key) : '...'})` + ); + walk(value); + keys.pop(); + } + break; + + default: + const proto = Object.getPrototypeOf(thing); + + if ( + proto !== Object.prototype && + proto !== null && + Object.getOwnPropertyNames(proto).sort().join('\0') !== + object_proto_names + ) { + throw new DevalueError( + `Cannot stringify arbitrary non-POJOs`, + keys + ); + } + + if (Object.getOwnPropertySymbols(thing).length > 0) { + throw new DevalueError( + `Cannot stringify POJOs with symbolic keys`, + keys + ); + } + + for (const key in thing) { + keys.push(`.${key}`); + walk(thing[key]); + keys.pop(); + } + } + } + } + + walk(value); + + const names = new Map(); + + Array.from(counts) + .filter((entry) => entry[1] > 1) + .sort((a, b) => b[1] - a[1]) + .forEach((entry, i) => { + names.set(entry[0], get_name(i)); + }); + + /** + * @param {any} thing + * @returns {string} + */ + function stringify(thing) { + if (names.has(thing)) { + return names.get(thing); + } + + if (is_primitive(thing)) { + return stringify_primitive(thing); + } + + const type = get_type(thing); + + switch (type) { + case 'Number': + case 'String': + case 'Boolean': + return `Object(${stringify(thing.valueOf())})`; + + case 'RegExp': + return `new RegExp(${stringify_string(thing.source)}, "${ + thing.flags + }")`; + + case 'Date': + return `new Date(${thing.getTime()})`; + + case 'Array': + const members = /** @type {any[]} */ (thing).map((v, i) => + i in thing ? stringify(v) : '' + ); + const tail = thing.length === 0 || thing.length - 1 in thing ? '' : ','; + return `[${members.join(',')}${tail}]`; + + case 'Set': + case 'Map': + return `new ${type}([${Array.from(thing).map(stringify).join(',')}])`; + + default: + const obj = `{${Object.keys(thing) + .map((key) => `${safe_key(key)}:${stringify(thing[key])}`) + .join(',')}}`; + const proto = Object.getPrototypeOf(thing); + if (proto === null) { + return Object.keys(thing).length > 0 + ? `Object.assign(Object.create(null),${obj})` + : `Object.create(null)`; + } + + return obj; + } + } + + const str = stringify(value); + + if (names.size) { + /** @type {string[]} */ + const params = []; + + /** @type {string[]} */ + const statements = []; + + /** @type {string[]} */ + const values = []; + + names.forEach((name, thing) => { + params.push(name); + + if (is_primitive(thing)) { + values.push(stringify_primitive(thing)); + return; + } + + const type = get_type(thing); + + switch (type) { + case 'Number': + case 'String': + case 'Boolean': + values.push(`Object(${stringify(thing.valueOf())})`); + break; + + case 'RegExp': + values.push(thing.toString()); + break; + + case 'Date': + values.push(`new Date(${thing.getTime()})`); + break; + + case 'Array': + values.push(`Array(${thing.length})`); + /** @type {any[]} */ (thing).forEach((v, i) => { + statements.push(`${name}[${i}]=${stringify(v)}`); + }); + break; + + case 'Set': + values.push(`new Set`); + statements.push( + `${name}.${Array.from(thing) + .map((v) => `add(${stringify(v)})`) + .join('.')}` + ); + break; + + case 'Map': + values.push(`new Map`); + statements.push( + `${name}.${Array.from(thing) + .map(([k, v]) => `set(${stringify(k)}, ${stringify(v)})`) + .join('.')}` + ); + break; + + default: + values.push( + Object.getPrototypeOf(thing) === null ? 'Object.create(null)' : '{}' + ); + Object.keys(thing).forEach((key) => { + statements.push( + `${name}${safe_prop(key)}=${stringify(thing[key])}` + ); + }); + } + }); + + statements.push(`return ${str}`); + + return `(function(${params.join(',')}){${statements.join( + ';' + )}}(${values.join(',')}))`; + } else { + return str; + } +} + +/** @param {number} num */ +function get_name(num) { + let name = ''; + + do { + name = chars[num % chars.length] + name; + num = ~~(num / chars.length) - 1; + } while (num >= 0); + + return reserved.test(name) ? `${name}0` : name; +} + +/** @param {any} thing */ +function is_primitive(thing) { + return Object(thing) !== thing; +} + +/** @param {any} thing */ +function stringify_primitive(thing) { + if (typeof thing === 'string') return stringify_string(thing); + if (thing === void 0) return 'void 0'; + if (thing === 0 && 1 / thing < 0) return '-0'; + const str = String(thing); + if (typeof thing === 'number') return str.replace(/^(-)?0\./, '$1.'); + if (typeof thing === 'bigint') return thing + 'n'; + return str; +} + +/** @param {any} thing */ +function get_type(thing) { + return Object.prototype.toString.call(thing).slice(8, -1); +} + +/** @param {string} c */ +function escape_unsafe_char(c) { + return escaped[c] || c; +} + +/** @param {string} str */ +function escape_unsafe_chars(str) { + return str.replace(unsafe_chars, escape_unsafe_char); +} + +/** @param {string} key */ +function safe_key(key) { + return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key) + ? key + : escape_unsafe_chars(JSON.stringify(key)); +} + +/** @param {string} key */ +function safe_prop(key) { + return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key) + ? `.${key}` + : `[${escape_unsafe_chars(JSON.stringify(key))}]`; +} + +/** @param {string} str */ +function stringify_string(str) { + let result = '"'; + + for (let i = 0; i < str.length; i += 1) { + const char = str.charAt(i); + const code = char.charCodeAt(0); + + if (char === '"') { + result += '\\"'; + } else if (char in escaped) { + result += escaped[char]; + } else if (code >= 0xd800 && code <= 0xdfff) { + const next = str.charCodeAt(i + 1); + + // If this is the beginning of a [high, low] surrogate pair, + // add the next two characters, otherwise escape + if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) { + result += char + str[++i]; + } else { + result += `\\u${code.toString(16).toUpperCase()}`; + } + } else { + result += char; + } + } + + result += '"'; + return result; +} + + diff --git a/src/content/cards.js b/src/content/cards.js index 312c2824..0f4c5e1c 100644 --- a/src/content/cards.js +++ b/src/content/cards.js @@ -12,7 +12,6 @@ export default [ image: 'the-angel-of-death.jpg', upgrade() { this.damage = 9 - this.upgraded = true this.name = 'Strike+' this.description = 'Deal 9 Damage.' }, @@ -27,7 +26,6 @@ export default [ description: 'Gain 5 Block.', upgrade() { this.block = 8 - this.upgraded = true this.name = 'Defend+' this.description = 'Gain 8 Block.' }, @@ -45,7 +43,6 @@ export default [ description: 'Deal 8 damage. Apply 2 Vulnerable.', upgrade() { this.damage = 10 - this.upgraded = true this.name = 'Bash+' this.powers.vulnerable = 3 this.description = 'Deal 10 Damage. Apply 3 Vulnerable' @@ -82,7 +79,6 @@ export default [ image: 'vernal-equinox.jpg', upgrade() { this.damage = 11 - this.upgraded = true this.name = 'Cleave+' this.description = 'Deal 11 Damage to all enemies.' }, @@ -99,7 +95,6 @@ export default [ upgrade() { this.damage = 7 this.block = 7 - this.upgraded = true this.name = 'Iron Wave+' this.description = 'Deal 7 Damage. Gain 7 Block.' }, @@ -117,7 +112,6 @@ export default [ image: 'manicule.jpg', upgrade() { this.damage = 8 - this.upgraded = true this.name = 'Sucker Punch+' this.powers.weak = 2 this.description = 'Deal 8 Damage. Apply 2 Weak' @@ -365,7 +359,6 @@ export default [ image: 'alice-holds-the-white-king.jpg', upgrade() { this.damage = 42 - this.upgraded = true this.name = 'Bludgeon+' this.description = 'Deal 42 Damage.' }, @@ -471,7 +464,6 @@ export default [ description: 'Apply 5 poison.', image: '6.jpg', upgrade() { - this.upgraded = true this.name = 'Deadly Poison+' this.powers.poison = 7 this.description = 'Apply 7 Poison' diff --git a/src/game/actions.js b/src/game/actions.js index b9e7bc20..c9b5836d 100644 --- a/src/game/actions.js +++ b/src/game/actions.js @@ -45,7 +45,7 @@ import {isDungeonCompleted} from './utils-state.js' * This is the big object of game state. Everything starts here. * @returns {State} */ -function createNewGame() { +function createNewState() { return { turn: 1, deck: [], @@ -162,7 +162,13 @@ function discardHand(state) { }) } -// Discard a single card from your hand. +/** + * Discard a single card from your hand. + * @param {State} state + * @param {object} props + * @param {CARD} props.card + * @returns {State} + */ function removeCard(state, {card}) { return produce(state, (draft) => { draft.deck = state.deck.filter((c) => c.id !== card.id) @@ -177,7 +183,8 @@ function removeCard(state, {card}) { */ function upgradeCard(state, {card}) { return produce(state, (draft) => { - draft.deck.find((c) => c.id === card.id).upgrade() + const index = draft.deck.findIndex((c) => c.id === card.id) + draft.deck[index] = createCard(card.name, true) }) } @@ -632,7 +639,7 @@ const allActions = { addRegenEqualToAllDamage, addStarterDeck, applyCardPowers, - createNewGame, + createNewState, dealDamageEqualToBlock, dealDamageEqualToVulnerable, dealDamageEqualToWeak, diff --git a/src/game/backend.js b/src/game/backend.js index 3212c5c7..d4cf5f57 100644 --- a/src/game/backend.js +++ b/src/game/backend.js @@ -15,16 +15,21 @@ const apiUrl = 'https://api.slaytheweb.cards/api/runs' /** * Saves a "game" object into a remote database. * @param {object} game - * @param {string} name + * @param {string=} name * @returns {Promise} */ export async function postRun(game, name) { + // Make sure we have an end time. + if (!game.state.endedAt) game.state.endedAt = new Date().getTime() + + /** @type {Run} */ const run = { name: name || 'Unknown entity', win: isDungeonCompleted(game.state), state: game.state, past: game.past, } + return fetch(apiUrl, { method: 'POST', headers: { diff --git a/src/game/cards.js b/src/game/cards.js index 88b25328..84f9a213 100644 --- a/src/game/cards.js +++ b/src/game/cards.js @@ -22,12 +22,19 @@ export const CardTargets = { } /** - * @typedef {object} CARDPOWERS + * @typedef {object} CardPowers * @prop {number=} regen * @prop {number=} vulnerable * @prop {number=} weak */ +/** + * @typedef {Object} CardAction - allows the card to run all defined actions + * @prop {string} type - name of the action to call. See game/actions.js + * @prop {Object} [parameter] - props to pass to the action + * @prop {Array<{type: string}>} [conditions] - list of conditions + */ + /** * All cards extend this class. * @typedef CARD @@ -42,20 +49,11 @@ export const CardTargets = { * @prop {CardTargets} target - a special "target" string to specify which targets the card affects. * color = [RED, GREEN, BLUE, PURPLE, COLORLESS, CURSE] * rarity = [BASIC, SPECIAL, COMMON, UNCOMMON, RARE, CURSE] - * @prop {boolean} exhaust - whether the card will exhaust when played. - * - * @prop {CARDPOWERS} [powers] - Cards can apply POWERS with the `powers` object. Powers are hardcoded in the game actions, but feel free to add more. + * @prop {boolean=} exhaust - whether the card will exhaust when played. + * @prop {boolean=} upgraded + * @prop {CardPowers} [powers] - Cards can apply POWERS with the `powers` object. Powers are hardcoded in the game actions, but feel free to add more. * @prop {Array} [actions] - Cards can _optionally_ define a list of `actions`. These actions will be run, in defined order, when the card is played. * @prop {Array<{type: string}>} [conditions] - In the same way, you can define a list of `conditions` that have to pass for the card to be playable. You can even add conditions directly on your actions. - * @prop {Function} [use] - * @prop {*} upgrade - */ - -/** - * @typedef {Object} CardAction - allows the card to run all defined actions - * @prop {string} type - name of the action to call. See game/actions.js - * @prop {Object} [parameter] - props to pass to the action - * @prop {Array<{type: string}>} [conditions] - list of conditions */ export class Card { @@ -73,36 +71,27 @@ export class Card { this.conditions = props.conditions this.actions = props.actions this.image = props.image - this.upgraded = false - if (props.upgrade) this.upgrade = props.upgrade + this.upgraded = props.upgraded this.exhaust = props.exhaust } - upgrade() { - if (this.upgraded) return - // Here you can upgrade the card. Like this.damage = this.damage * 2 - } -} - -/** - * Returns the POJO card definition. Not to be confused with createCard(). - * @param {string} name - * @returns {CARD} - */ -function findCard(name) { - return cards.find((card) => card.name === name) } /** * Creates a new card. Turns a plain object card into a class-based one. + * Very important, we clone the object. Otherwise, all cards would share the same object. * We do this so we can define the cards without using class syntax. * @param {string} name - exact name of the Card * @returns {CARD} */ - -export function createCard(name) { - const baseCard = findCard(name) - if (!baseCard) throw new Error(`Card not found: ${name}`) - return new Card(baseCard) +export function createCard(name, shouldUpgrade) { + let card = cards.find((card) => card.name === name) + if (!card) throw new Error(`Card not found: ${name}`) + card = {...card} + if (shouldUpgrade) { + card.upgrade() + card.upgraded = true + } + return new Card(card) } /** diff --git a/src/game/new-game.js b/src/game/new-game.js index 3c0460d9..1ab8f5ea 100644 --- a/src/game/new-game.js +++ b/src/game/new-game.js @@ -23,7 +23,7 @@ export default function createNewGame(debug = false) { // Adds a dungeon, starter deck and draws cards. function createNewState() { - let state = actions.createNewGame() + let state = actions.createNewState() state = actions.setDungeon(state) state = actions.addStarterDeck(state) state = actions.drawCards(state) diff --git a/src/ui/game-screen.js b/src/ui/game-screen.js index 4dca7d67..4fcec521 100644 --- a/src/ui/game-screen.js +++ b/src/ui/game-screen.js @@ -1,4 +1,3 @@ -// Third party dependencies import {html, Component, useState} from './lib.js' import gsap from './animations.js' // @ts-ignore @@ -8,9 +7,10 @@ import Flip from 'https://slaytheweb-assets.netlify.app/gsap/Flip.js' import createNewGame from '../game/new-game.js' import {createCard, getCardRewards} from '../game/cards.js' import {getCurrRoom, isCurrRoomCompleted, isDungeonCompleted} from '../game/utils-state.js' -import * as backend from '../game/backend.js' // UI Components +import {saveToUrl, loadFromUrl} from './save-load.js' +import * as backend from '../game/backend.js' import Cards from './cards.js' import Map from './map.js' import {Overlay, OverlayWithButton} from './overlays.js' @@ -29,8 +29,6 @@ Object.keys(realSfx).forEach((key) => { sfx[key] = () => null }) -const load = () => JSON.parse(decodeURIComponent(window.location.hash.split('#')[1])) - export default class App extends Component { get didWin() { return isCurrRoomCompleted(this.state) @@ -66,25 +64,22 @@ export default class App extends Component { sfx.startGame() // If there is a saved game state, use it. - const savedGameState = window.location.hash && load() - if (savedGameState) { - this.game.state = savedGameState - this.setState(savedGameState, this.dealCards) - } + const savedGameState = window.location.hash && loadFromUrl() + if (savedGameState) this.restoreGame(savedGameState) this.enableConsole() } + restoreGame(oldState) { + this.game.state = oldState + this.setState(oldState, this.dealCards) + } enableConsole() { // Enable a "console" in the browser. - console.log(`Welcome to the Slay The Web Console. Some examples: -stw.game.state.player.maxHealth = 999; stw.update() -stw.game.enqueue({type: 'drawCards', amount: 2}) -stw.update() -stw.dealCards()`) // @ts-ignore window.stw = { game: this.game, update: this.update.bind(this), + saveGame: (state) => saveToUrl(state || this.state), createCard, dealCards: this.dealCards.bind(this), iddqd() { @@ -94,7 +89,17 @@ stw.dealCards()`) // console.log(this.game.state) }) }, + submitGame() { + backend.postRun(this.game) + }, + help() { + console.log(`Welcome to the Slay The Web Console. Some examples: +stw.game.enqueue({type: 'drawCards', amount: 2}) +stw.update() +stw.dealCards()`) + } } + stw.help() } update(callback) { this.game.dequeue() diff --git a/src/ui/map.js b/src/ui/map.js index 99b89aa2..bff02500 100644 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -36,7 +36,6 @@ export class SlayMap extends Component { super() this.disableScatter = props.disableScatter this.didDrawPaths = false - console.log(props) } componentDidMount() { @@ -137,6 +136,7 @@ export class SlayMap extends Component { if (!dungeon.graph) throw new Error('No graph to render. This should not happen?', dungeon) const edgesFromCurrentNode = dungeon.graph[y][x].edges + // console.log('edges from current map node', edgesFromCurrentNode) return html` diff --git a/src/ui/menu.js b/src/ui/menu.js index 5f3b383d..b24b99ef 100644 --- a/src/ui/menu.js +++ b/src/ui/menu.js @@ -1,7 +1,7 @@ import {html} from './lib.js' import History from './history.js' +import {saveToUrl} from './save-load.js' -const save = (state) => (window.location.hash = encodeURIComponent(JSON.stringify(state))) const abandonGame = () => (window.location = window.location.origin) export default function Menu({game, gameState, onUndo}) { @@ -12,7 +12,7 @@ export default function Menu({game, gameState, onUndo}) {