From 3a56aa03fae323bc75dca6423a91026e55b99b2f Mon Sep 17 00:00:00 2001 From: oskarrough Date: Tue, 25 Mar 2025 20:21:53 +0100 Subject: [PATCH 1/2] Test smaller dungeon structure --- src/ui/save-load.js | 252 +++- tests/example-game-state.json | 2599 +++++++++++++++++++++++++++++++++ tests/game-serialization.js | 356 +++++ 3 files changed, 3200 insertions(+), 7 deletions(-) create mode 100644 tests/example-game-state.json create mode 100644 tests/game-serialization.js diff --git a/src/ui/save-load.js b/src/ui/save-load.js index 75979ae6..857699b7 100644 --- a/src/ui/save-load.js +++ b/src/ui/save-load.js @@ -2,31 +2,269 @@ import superjson from 'superjson' /** * Helpers to save and load the entire game state. - * We use `superjson` insetad of JSON.stringify/parse, + * We use `superjson` instead of JSON.stringify/parse, * because the state contains Set()s and Map()s. */ +/** @typedef {import('../game/dungeon.js').Dungeon} Dungeon */ +/** @typedef {import('../game/dungeon.js').Graph} Graph */ +/** @typedef {import('../game/dungeon.js').MapNode} MapNode */ +/** @typedef {import('../game/actions.js').State} GameState */ + +// Current version of the game state format +const CURRENT_VERSION = 1 + +/** + * Extended game state with version information + * @typedef {GameState & {_version?: number}} VersionedGameState + */ + +/** + * Compresses a dungeon structure for more efficient serialization + * @param {Dungeon} dungeon - The dungeon to compress + * @returns {object} A compressed representation of the dungeon + */ +function compressDungeon(dungeon) { + // Create a compressed representation + const compressed = { + id: dungeon.id, + x: dungeon.x, + y: dungeon.y, + // Only store active rooms and their positions + rooms: [], + // Store edges as arrays of indices rather than Sets + edges: [], + // Current position and path + pathTaken: dungeon.pathTaken + } + + // Extract and store only rooms that exist (not empty nodes) + dungeon.graph.forEach((floor, y) => { + floor.forEach((node, x) => { + if (node.type) { + // Store minimal information about each room + const room = { + id: node.id, + pos: [y, x], + type: node.type, + didVisit: node.didVisit + } + + // Only include room data if it has a room + if (node.room) { + room.room = node.room + } + + compressed.rooms.push(room) + + // Convert Set of edges to array of room IDs + if (node.edges && node.edges.size > 0) { + compressed.edges.push({ + from: node.id, + to: Array.from(node.edges) + }) + } + } + }) + }) + + // Also preserve the paths for backwards compatibility + compressed.paths = dungeon.paths + + return compressed +} + +/** + * Expands a compressed dungeon back to its full structure + * @param {object} compressed - The compressed dungeon + * @returns {Dungeon} The expanded dungeon + */ +function expandDungeon(compressed) { + // If it's already an uncompressed dungeon (has graph property), return as is + if (compressed.graph) { + return compressed + } + + // Reconstruct the graph from the compressed format + /** @type {Graph} */ + const graph = [] + let maxY = 0 + let maxX = 0 + + // First find the dimensions needed + compressed.rooms.forEach(room => { + const [y, x] = room.pos + maxY = Math.max(maxY, y) + maxX = Math.max(maxX, x) + }) + + // Initialize the graph with empty nodes + for (let y = 0; y <= maxY; y++) { + const floor = [] + for (let x = 0; x <= maxX; x++) { + // Create a basic empty node with required properties + floor.push({ + id: `empty_${y}_${x}`, + type: '', // Empty string for no type + edges: new Set(), + didVisit: false + }) + } + graph.push(floor) + } + + // Place the rooms in the graph + compressed.rooms.forEach(room => { + const [y, x] = room.pos + graph[y][x] = { + id: room.id, + type: room.type, + room: room.room, + edges: new Set(), + didVisit: room.didVisit + } + }) + + // Reconstruct the edges + compressed.edges.forEach(edge => { + // Find the node with this ID + let fromNode = null + findNode: for (let y = 0; y < graph.length; y++) { + for (let x = 0; x < graph[y].length; x++) { + if (graph[y][x].id === edge.from) { + fromNode = graph[y][x] + break findNode + } + } + } + + if (fromNode) { + // Add all edges + edge.to.forEach(toId => { + fromNode.edges.add(toId) + }) + } + }) + + return { + id: compressed.id, + graph, + paths: compressed.paths, + x: compressed.x, + y: compressed.y, + pathTaken: compressed.pathTaken + } +} + +/** + * Migrates a game state from one version to another + * @param {VersionedGameState} state - Game state to migrate + * @param {number} fromVersion - Current version + * @param {number} toVersion - Target version + * @returns {VersionedGameState} The migrated state + */ +function migrateState(state, fromVersion, toVersion) { + // If coming from version 0 (no version), add version property + if (fromVersion === 0) { + state._version = toVersion + return state + } + + // Add more migration logic here when you introduce breaking changes + // For example: + // if (fromVersion === 1 && toVersion >= 2) { + // // Convert version 1 format to version 2 + // } + + return state +} + +/** + * Encodes just a dungeon structure into a string using compression + * @param {Dungeon} dungeon + * @returns {string} + */ +export function encodeDungeon(dungeon) { + const compressed = compressDungeon(dungeon) + return superjson.stringify(compressed) +} + +/** + * Decodes a dungeon from an encoded string + * @param {string} encodedDungeon + * @returns {Dungeon} + */ +export function decodeDungeon(encodedDungeon) { + const compressed = superjson.parse(encodedDungeon) + return expandDungeon(compressed) +} + +/** + * Encodes any object using superjson for testing/analysis + * @param {any} object + * @returns {string} + */ +export function encodeObject(object) { + return superjson.stringify(object) +} + +/** + * Decodes any object from a superjson string for testing/analysis + * @param {string} encodedObject + * @returns {any} + */ +export function decodeObject(encodedObject) { + return superjson.parse(encodedObject) +} + /** * Encodes a game state into a string. - * @param {object} state + * @param {GameState} state * @returns {string} */ export function encode(state) { - return superjson.stringify(state) + // Make a copy so we don't modify the original + /** @type {VersionedGameState} */ + const stateCopy = {...state} + + // Add version information + stateCopy._version = CURRENT_VERSION + + // Compress the dungeon if it exists + if (stateCopy.dungeon) { + stateCopy.dungeon = compressDungeon(stateCopy.dungeon) + } + + return superjson.stringify(stateCopy) } /** * Decodes a serialized game state string back into an object. * @param {string} state - * @returns {object} + * @returns {GameState} */ export function decode(state) { - return superjson.parse(state) + // Parse the state + /** @type {VersionedGameState} */ + const parsed = superjson.parse(state) + + // Check version and migrate if necessary + const version = parsed._version || 0 + if (version !== CURRENT_VERSION) { + migrateState(parsed, version, CURRENT_VERSION) + } + + // If the state has a dungeon, expand it + if (parsed.dungeon) { + parsed.dungeon = expandDungeon(parsed.dungeon) + } + + return parsed } /** * Encodes a game state and stores it in the URL as a hash parameter. - * @param {object} state + * @param {GameState} state */ export function saveToUrl(state) { try { @@ -38,7 +276,7 @@ export function saveToUrl(state) { /** * Reads a game state from the URL and decodes it. - * @returns {object} + * @returns {GameState} */ export function loadFromUrl() { const state = decodeURIComponent(window.location.hash.split('#')[1]) diff --git a/tests/example-game-state.json b/tests/example-game-state.json new file mode 100644 index 00000000..8a5d6445 --- /dev/null +++ b/tests/example-game-state.json @@ -0,0 +1,2599 @@ +{ + "turn": 3, + "deck": [ + { + "id": "0166f0db-80c5-458a-ac71-85e79391a826", + "name": "Adrenaline", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy. Draw 2 cards. Exhaust.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "player0" + } + }, + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 2, + "target": "player0" + } + } + ], + "image": "serpentine-dancer.jpg", + "upgraded": false, + "exhaust": true + }, + { + "id": "30ef7f9d-62a4-4e54-bb08-22575ff68b06", + "name": "Bash", + "type": "attack", + "energy": 2, + "target": "enemy", + "damage": 8, + "block": 0, + "powers": { + "vulnerable": 2 + }, + "description": "Deal 8 damage. Apply 2 Vulnerable.", + "image": "apteryx-mantelli.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "c49abe8a-b3f4-4d97-af37-46e67870c77f", + "name": "Bludgeon", + "type": "attack", + "energy": 3, + "target": "enemy", + "damage": 24, + "block": 0, + "description": "Deal 24 damage.", + "image": "alice-holds-the-white-king.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "3c357065-14f5-4d3e-a043-d0ed8e85cb64", + "name": "Body Slam", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 0, + "block": 0, + "description": "Deal damage equal to your Block.", + "actions": [ + { + "type": "dealDamageEqualToBlock" + } + ], + "image": "fallback.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "d9926561-be3d-4f4c-8fb8-e7d50537df2c", + "name": "Clash", + "type": "attack", + "energy": 0, + "target": "enemy", + "damage": 14, + "block": 0, + "description": "Can only be played if every card in your hand is an Attack. Deal 14 damage.", + "conditions": [ + { + "type": "onlyType", + "cardType": "attack" + } + ], + "image": "h-sperling-horrified.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "5058c070-f7d1-4596-8886-781c0ff36efc", + "name": "Cleave", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 8, + "block": 0, + "description": "Deal 8 damage to all enemies.", + "image": "vernal-equinox.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "b17dcca3-15f2-4469-85aa-2f73fb1b9d72", + "name": "Defend", + "type": "skill", + "energy": 1, + "target": "player", + "damage": 0, + "block": 5, + "description": "Gain 5 Block.", + "image": "angel-messenger.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "41d4ed36-b0a4-4ed9-8409-8815cf934c61", + "name": "Flourish", + "type": "skill", + "energy": 2, + "target": "player", + "damage": 0, + "block": 0, + "powers": { + "regen": 5 + }, + "description": "Gain 5 Regen. Can only be played if your health is below 50%.", + "conditions": [ + { + "type": "healthPercentageBelow", + "percentage": 75 + } + ], + "image": "5.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "adc1af8a-dbc5-4c6f-8b92-e7565c744c16", + "name": "Intimidate", + "type": "skill", + "energy": 0, + "target": "allEnemies", + "damage": 0, + "block": 0, + "powers": { + "weak": 2 + }, + "description": "Apply 1 Weak to ALL enemies. Exhaust.", + "image": "poured-millions-of-bubbles.jpg", + "upgraded": false, + "exhaust": true + }, + { + "id": "5a93a18b-f053-4caa-a22d-4c7117c7c042", + "name": "Iron Wave", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 5, + "block": 5, + "description": "Deal 5 damage. Gain 5 Block.", + "image": "henry-stares-back.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "007c8e3e-f605-438e-88b6-45c5ffd52e15", + "name": "Mask of the Faceless", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy", + "actions": [ + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 1, + "target": "player0" + } + } + ], + "image": "mask-of-the-faceless.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "69b649c2-d06b-4d8f-b031-e4e332bd61c9", + "name": "Pommel Strike", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 9, + "block": 0, + "description": "Deal 9 damage. Draw 1 card.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "enemy2" + } + } + ], + "image": "8.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "a5741aa4-68b2-49cd-9810-febfb19b4d05", + "name": "Ritual Rain", + "type": "skill", + "energy": 2, + "target": "player", + "damage": 0, + "block": 0, + "description": "Dispel your Weaknesses and Vulnerabilities.", + "actions": [ + { + "type": "removePlayerDebuffs" + } + ], + "image": "ritual-rain.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "a3a60507-bafa-4664-b761-87e97b32640b", + "name": "Soul Drain", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 0, + "block": 0, + "powers": { + "weak": 3, + "vulnerable": 3 + }, + "description": "Apply 3 Weak and Vulnerability to ALL enemies. Drains 3 Health from you.", + "actions": [ + { + "type": "removeHealth", + "parameter": { + "amount": 4, + "target": "player" + } + } + ], + "image": "soul-drain.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "96d54699-9384-4bc0-9320-e255bacfd418", + "name": "Strike", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 6, + "block": 0, + "description": "Deal 6 damage.", + "image": "the-angel-of-death.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "2fec52ce-43ed-4287-876c-35447e7592b2", + "name": "Succube", + "type": "attack", + "energy": 3, + "target": "allEnemies", + "damage": 2, + "block": 0, + "description": "Deal 2 damage to ALL enemies and suck it into life.", + "actions": [ + { + "type": "addRegenEqualToAllDamage" + } + ], + "image": "succube.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "95da754f-c461-4cf9-9953-cb6f032d7c3d", + "name": "Sucker Punch", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 7, + "block": 0, + "powers": { + "weak": 1 + }, + "description": "Deal 7 damage. Apply 1 Weak.", + "image": "manicule.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "656e94a7-be8a-45fb-999a-7f071bf014c8", + "name": "Summer of Sam", + "type": "skill", + "energy": 1, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Health. If your health is below 50% draw 2 cards.", + "actions": [ + { + "type": "addHealth", + "parameter": { + "amount": 2 + } + }, + { + "type": "drawCards", + "parameter": { + "amount": 2 + }, + "conditions": [ + { + "type": "healthPercentageBelow", + "percentage": 50 + } + ] + } + ], + "image": "bare-feet-of-god.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "ca15c60a-39ef-4afc-9bca-4deaebd172c8", + "name": "Terror", + "type": "skill", + "energy": 1, + "target": "enemy", + "damage": 0, + "block": 0, + "powers": { + "vulnerable": 99 + }, + "description": "Apply 99 Vulnerable. Exhaust.", + "image": "2.jpg", + "upgraded": false, + "exhaust": true + }, + { + "id": "090af434-f0f7-43ab-99da-9e7dd1624b31", + "name": "Thunderclap", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 4, + "block": 0, + "powers": { + "vulnerable": 1 + }, + "description": "Deal 4 damage. Apply 1 Vulnerable to ALL enemies.", + "image": "4.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "b12bb410-39bc-4af4-91ba-dfc7dec4c984", + "name": "Voodoo Gift", + "type": "attack", + "energy": 0, + "target": "enemy", + "damage": 0, + "block": 0, + "description": "Deal damage equal to target's Vulnerable and Weak and remove the debuffs.", + "actions": [ + { + "type": "dealDamageEqualToWeak", + "parameter": { + "target": "enemy0" + } + }, + { + "type": "dealDamageEqualToVulnerable", + "parameter": { + "target": "enemy0" + } + } + ], + "image": "voodoo-education.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "e6c46681-44bf-4043-9744-cee003b9a25d", + "name": "Adrenaline", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy. Draw 2 cards. Exhaust.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "player0" + } + }, + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 2, + "target": "player0" + } + } + ], + "image": "serpentine-dancer.jpg", + "upgraded": false, + "exhaust": true + } + ], + "drawPile": [ + { + "id": "3c357065-14f5-4d3e-a043-d0ed8e85cb64", + "name": "Body Slam", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 0, + "block": 0, + "description": "Deal damage equal to your Block.", + "actions": [ + { + "type": "dealDamageEqualToBlock" + } + ], + "image": "fallback.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "c49abe8a-b3f4-4d97-af37-46e67870c77f", + "name": "Bludgeon", + "type": "attack", + "energy": 3, + "target": "enemy", + "damage": 24, + "block": 0, + "description": "Deal 24 damage.", + "image": "alice-holds-the-white-king.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "adc1af8a-dbc5-4c6f-8b92-e7565c744c16", + "name": "Intimidate", + "type": "skill", + "energy": 0, + "target": "allEnemies", + "damage": 0, + "block": 0, + "powers": { + "weak": 2 + }, + "description": "Apply 1 Weak to ALL enemies. Exhaust.", + "image": "poured-millions-of-bubbles.jpg", + "upgraded": false, + "exhaust": true + }, + { + "id": "2fec52ce-43ed-4287-876c-35447e7592b2", + "name": "Succube", + "type": "attack", + "energy": 3, + "target": "allEnemies", + "damage": 2, + "block": 0, + "description": "Deal 2 damage to ALL enemies and suck it into life.", + "actions": [ + { + "type": "addRegenEqualToAllDamage" + } + ], + "image": "succube.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "a5741aa4-68b2-49cd-9810-febfb19b4d05", + "name": "Ritual Rain", + "type": "skill", + "energy": 2, + "target": "player", + "damage": 0, + "block": 0, + "description": "Dispel your Weaknesses and Vulnerabilities.", + "actions": [ + { + "type": "removePlayerDebuffs" + } + ], + "image": "ritual-rain.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "a3a60507-bafa-4664-b761-87e97b32640b", + "name": "Soul Drain", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 0, + "block": 0, + "powers": { + "weak": 3, + "vulnerable": 3 + }, + "description": "Apply 3 Weak and Vulnerability to ALL enemies. Drains 3 Health from you.", + "actions": [ + { + "type": "removeHealth", + "parameter": { + "amount": 4, + "target": "player" + } + } + ], + "image": "soul-drain.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "007c8e3e-f605-438e-88b6-45c5ffd52e15", + "name": "Mask of the Faceless", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy", + "actions": [ + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 1, + "target": "player0" + } + } + ], + "image": "mask-of-the-faceless.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "090af434-f0f7-43ab-99da-9e7dd1624b31", + "name": "Thunderclap", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 4, + "block": 0, + "powers": { + "vulnerable": 1 + }, + "description": "Deal 4 damage. Apply 1 Vulnerable to ALL enemies.", + "image": "4.jpg", + "upgraded": false, + "exhaust": false + } + ], + "hand": [ + { + "id": "b12bb410-39bc-4af4-91ba-dfc7dec4c984", + "name": "Voodoo Gift", + "type": "attack", + "energy": 0, + "target": "enemy", + "damage": 0, + "block": 0, + "description": "Deal damage equal to target's Vulnerable and Weak and remove the debuffs.", + "actions": [ + { + "type": "dealDamageEqualToWeak", + "parameter": { + "target": "enemy0" + } + }, + { + "type": "dealDamageEqualToVulnerable", + "parameter": { + "target": "enemy0" + } + } + ], + "image": "voodoo-education.png", + "upgraded": false, + "exhaust": false + }, + { + "id": "30ef7f9d-62a4-4e54-bb08-22575ff68b06", + "name": "Bash", + "type": "attack", + "energy": 2, + "target": "enemy", + "damage": 8, + "block": 0, + "powers": { + "vulnerable": 2 + }, + "description": "Deal 8 damage. Apply 2 Vulnerable.", + "image": "apteryx-mantelli.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "d9926561-be3d-4f4c-8fb8-e7d50537df2c", + "name": "Clash", + "type": "attack", + "energy": 0, + "target": "enemy", + "damage": 14, + "block": 0, + "description": "Can only be played if every card in your hand is an Attack. Deal 14 damage.", + "conditions": [ + { + "type": "onlyType", + "cardType": "attack" + } + ], + "image": "h-sperling-horrified.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "656e94a7-be8a-45fb-999a-7f071bf014c8", + "name": "Summer of Sam", + "type": "skill", + "energy": 1, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Health. If your health is below 50% draw 2 cards.", + "actions": [ + { + "type": "addHealth", + "parameter": { + "amount": 2 + } + }, + { + "type": "drawCards", + "parameter": { + "amount": 2 + }, + "conditions": [ + { + "type": "healthPercentageBelow", + "percentage": 50 + } + ] + } + ], + "image": "bare-feet-of-god.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "0166f0db-80c5-458a-ac71-85e79391a826", + "name": "Adrenaline", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy. Draw 2 cards. Exhaust.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "player0" + } + }, + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 2, + "target": "player0" + } + } + ], + "image": "serpentine-dancer.jpg", + "upgraded": false, + "exhaust": true + } + ], + "discardPile": [ + { + "id": "b17dcca3-15f2-4469-85aa-2f73fb1b9d72", + "name": "Defend", + "type": "skill", + "energy": 1, + "target": "player", + "damage": 0, + "block": 5, + "description": "Gain 5 Block.", + "image": "angel-messenger.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "69b649c2-d06b-4d8f-b031-e4e332bd61c9", + "name": "Pommel Strike", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 9, + "block": 0, + "description": "Deal 9 damage. Draw 1 card.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "enemy2" + } + } + ], + "image": "8.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "96d54699-9384-4bc0-9320-e255bacfd418", + "name": "Strike", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 6, + "block": 0, + "description": "Deal 6 damage.", + "image": "the-angel-of-death.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "5a93a18b-f053-4caa-a22d-4c7117c7c042", + "name": "Iron Wave", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 5, + "block": 5, + "description": "Deal 5 damage. Gain 5 Block.", + "image": "henry-stares-back.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "5058c070-f7d1-4596-8886-781c0ff36efc", + "name": "Cleave", + "type": "attack", + "energy": 1, + "target": "allEnemies", + "damage": 8, + "block": 0, + "description": "Deal 8 damage to all enemies.", + "image": "vernal-equinox.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "95da754f-c461-4cf9-9953-cb6f032d7c3d", + "name": "Sucker Punch", + "type": "attack", + "energy": 1, + "target": "enemy", + "damage": 7, + "block": 0, + "powers": { + "weak": 1 + }, + "description": "Deal 7 damage. Apply 1 Weak.", + "image": "manicule.jpg", + "upgraded": false, + "exhaust": false + }, + { + "id": "41d4ed36-b0a4-4ed9-8409-8815cf934c61", + "name": "Flourish", + "type": "skill", + "energy": 2, + "target": "player", + "damage": 0, + "block": 0, + "powers": { + "regen": 5 + }, + "description": "Gain 5 Regen. Can only be played if your health is below 50%.", + "conditions": [ + { + "type": "healthPercentageBelow", + "percentage": 75 + } + ], + "image": "5.jpg", + "upgraded": false, + "exhaust": false + } + ], + "exhaustPile": [ + { + "id": "e6c46681-44bf-4043-9744-cee003b9a25d", + "name": "Adrenaline", + "type": "skill", + "energy": 0, + "target": "player", + "damage": 0, + "block": 0, + "description": "Gain 1 Energy. Draw 2 cards. Exhaust.", + "actions": [ + { + "type": "drawCards", + "parameter": { + "amount": 2, + "target": "player0" + } + }, + { + "type": "addEnergyToPlayer", + "parameter": { + "amount": 2, + "target": "player0" + } + } + ], + "image": "serpentine-dancer.jpg", + "upgraded": false, + "exhaust": true + }, + { + "id": "ca15c60a-39ef-4afc-9bca-4deaebd172c8", + "name": "Terror", + "type": "skill", + "energy": 1, + "target": "enemy", + "damage": 0, + "block": 0, + "powers": { + "vulnerable": 99 + }, + "description": "Apply 99 Vulnerable. Exhaust.", + "image": "2.jpg", + "upgraded": false, + "exhaust": true + } + ], + "player": { + "maxEnergy": 3, + "currentEnergy": 3, + "maxHealth": 72, + "currentHealth": 63, + "block": 0, + "powers": {} + }, + "dungeon": { + "id": "d6812ae1-6eed-43e2-89b7-014cafe9d551", + "graph": [ + [ + { + "id": "0a8e9382-18be-42f1-ab58-2e04486383c8", + "type": "start", + "room": { + "type": "start" + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "3c5a0f10-c44b-4dd6-bdc6-3959f507f16c", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 10, + "maxHealth": 10, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + }, + { + "damage": 11 + }, + { + "damage": 9 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 12, + "maxHealth": 12, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 9 + }, + { + "damage": 10 + }, + { + "damage": 5 + }, + { + "block": 5 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "17329fc1-147f-4f30-8eb0-a996f48aa63c", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 8 + }, + { + "damage": 11 + }, + { + "damage": 9 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "9a32424b-621c-4533-a3cb-462332547ee2", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 8 + }, + { + "damage": 11 + }, + { + "damage": 9 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "abf83261-c093-4cd5-ba27-94d9ce82757e", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": -89, + "maxHealth": 10, + "block": 0, + "powers": { + "vulnerable": 98 + }, + "intents": [ + { + "damage": 5 + }, + { + "damage": 11 + }, + { + "damage": 9 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": -5, + "maxHealth": 12, + "block": 0, + "powers": { + "vulnerable": 1 + }, + "intents": [ + { + "damage": 9 + }, + { + "damage": 10 + }, + { + "damage": 5 + }, + { + "block": 5 + } + ], + "nextIntent": 1 + } + ] + }, + "edges": {}, + "didVisit": true + }, + { + "id": "cd6366ae-9f00-4ce5-80eb-e7b9454b7ec1", + "edges": {}, + "didVisit": false + }, + { + "id": "1f3885ad-723c-48e6-992c-ad3ce2088c41", + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "7249e6d0-8a9f-46c2-93a6-ba3d0bc4e1fe", + "edges": {}, + "didVisit": false + }, + { + "id": "24a7c5e0-ccde-4534-9b46-b831ec75ac76", + "edges": {}, + "didVisit": false + }, + { + "id": "59762c0d-c678-4fd6-94d6-01380670e92f", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 43, + "maxHealth": 43, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 11 + }, + { + "damage": 7, + "block": 5 + }, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "78e436e7-cf21-472f-abed-dfcfbe08a2de", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 7, + "maxHealth": 12, + "block": 0, + "powers": { + "vulnerable": 98 + }, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": -4, + "maxHealth": 11, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": true + }, + { + "id": "d0f9cec4-e021-469c-86b3-7efaf49b5306", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 35, + "maxHealth": 35, + "block": 0, + "powers": {}, + "intents": [ + { + "vulnerable": 1 + }, + { + "damage": 10 + }, + { + "damage": 8 + }, + {}, + { + "weak": 1 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "be966722-4ba9-4129-81c7-b7c4d797cf3d", + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "0b884b67-8f62-48c4-9b85-958931d17857", + "edges": {}, + "didVisit": false + }, + { + "id": "bb827fc0-bad8-4993-a989-6fca351662b3", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "86e95787-c4c4-48dc-8e10-98a111921762", + "edges": {}, + "didVisit": false + }, + { + "id": "f1da6bd3-98aa-4223-8684-0cf6269f6f50", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "cb9f71fb-0615-450b-a4df-515acdd7a2d9", + "edges": {}, + "didVisit": false + }, + { + "id": "87857f85-d0fb-4b30-92ff-70e4200bd8aa", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "7b0ea4f7-1621-4c0e-9a40-3abfbe766b44", + "edges": {}, + "didVisit": false + }, + { + "id": "14312cba-d49a-4cb7-904c-ad02ff00c6ea", + "edges": {}, + "didVisit": false + }, + { + "id": "627fab86-213a-4644-9b0c-b7deb2f5df0d", + "edges": {}, + "didVisit": false + }, + { + "id": "80a257dc-ebcb-4826-9756-743b686ae4fc", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 43, + "maxHealth": 43, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 11 + }, + { + "damage": 7, + "block": 5 + }, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "17b654b6-9ff1-4f14-8071-3f2978c08450", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "7847dec5-fe16-4adc-8c36-820bd601d2d0", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 12, + "maxHealth": 12, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 11, + "maxHealth": 11, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "49319338-4078-45f6-bcb5-f191e61ad4d0", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + }, + { + "id": "b88c5bb3-6177-4921-bd95-7b6a2dff1fc8", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 43, + "maxHealth": 43, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 11 + }, + { + "damage": 7, + "block": 5 + }, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "584fe938-4312-4360-a02f-845f1530c180", + "edges": {}, + "didVisit": false + }, + { + "id": "dea5111a-0d52-4388-a916-56d3210e16dc", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 43, + "maxHealth": 43, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 11 + }, + { + "damage": 7, + "block": 5 + }, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "79aae134-fcff-4fde-a0ec-3f078cf6e295", + "edges": {}, + "didVisit": false + }, + { + "id": "b50c7699-d365-4897-b521-471411125f12", + "type": "E", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 70, + "maxHealth": 70, + "block": 12, + "powers": {}, + "intents": [ + { + "block": 5 + }, + { + "damage": 16 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "c032671b-8cd3-467f-9ef1-89730a85fdf7", + "edges": {}, + "didVisit": false + }, + { + "id": "6dec7542-373f-4685-82e3-822ed8dc345e", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + }, + { + "id": "f18475d2-265c-454a-b122-6133aaea9f2e", + "edges": {}, + "didVisit": false + }, + { + "id": "dea8e68e-a847-4fe3-a231-ab6a9f0d5ceb", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "25f09359-293d-4e36-a4c3-8102fc66612b", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "268e11b4-22c2-4816-b097-de7c6a831b1f", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 20, + "maxHealth": 20, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 6 + }, + { + "damage": 11 + }, + { + "damage": 4 + }, + { + "block": 9 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "1744f9c9-75c1-4e92-ab63-e14367ef8681", + "edges": {}, + "didVisit": false + }, + { + "id": "3bb17118-6c43-47d6-94e7-9c3002361408", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + }, + { + "id": "8df35712-73a5-4f32-87a8-2b35850452f4", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 14, + "maxHealth": 14, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + }, + { + "block": 4, + "damage": 7 + }, + { + "damage": 5 + }, + {}, + { + "block": 6 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 29, + "maxHealth": 29, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + }, + { + "damage": 6 + }, + { + "weak": 1 + }, + { + "damage": 4 + }, + {} + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "9ed8ca05-0c23-4d66-9652-85b2610fed25", + "type": "E", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 46, + "maxHealth": 46, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 12 + }, + { + "block": 6, + "damage": 11 + }, + { + "block": 5, + "damage": 16 + }, + {}, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "6e4b2903-59f4-4bf4-83b5-3953e88094d2", + "edges": {}, + "didVisit": false + }, + { + "id": "c5dc9bbb-2069-4486-b9f7-a93b1b6e2bd6", + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "e756d3ad-15a5-4aab-aef3-9cf93da2abf6", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + }, + { + "id": "02f4dc7d-0ffd-46d2-af83-ab2015a5d596", + "edges": {}, + "didVisit": false + }, + { + "id": "919a66a7-69a8-417b-875c-4aa40546ba83", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 28, + "maxHealth": 28, + "block": 0, + "powers": {}, + "intents": [ + { + "weak": 1 + }, + { + "block": 10, + "damage": 10 + }, + { + "damage": 21 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "ca40bbf9-410c-4806-8a7c-b4cd66338b95", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 12, + "maxHealth": 12, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 11, + "maxHealth": 11, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "bd1e42ab-ab3d-445c-aa1c-834bf3885c20", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + }, + { + "id": "79b0b902-c2ec-48b8-87e6-dd5823d56584", + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "002bc723-cc49-417b-b4a0-b898b57609bf", + "edges": {}, + "didVisit": false + }, + { + "id": "ba7060f0-398f-4c14-8da7-f4ff4b2f7605", + "edges": {}, + "didVisit": false + }, + { + "id": "ae93b6f7-c66c-47d7-ac7c-60cfed10c57a", + "type": "E", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 70, + "maxHealth": 70, + "block": 12, + "powers": {}, + "intents": [ + { + "block": 5 + }, + { + "damage": 16 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "e725f288-2b2a-44fd-aa59-c3af84a32633", + "edges": {}, + "didVisit": false + }, + { + "id": "0d7076c1-3436-4dd1-b533-9714e0b7f530", + "type": "E", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 45, + "maxHealth": 45, + "block": 0, + "powers": {}, + "intents": [ + { + "weak": 1 + }, + { + "damage": 10 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 40, + "maxHealth": 40, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 10 + }, + { + "weak": 1 + }, + { + "damage": 4 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 44, + "maxHealth": 44, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 2 + }, + { + "damage": 10 + }, + { + "damage": 8 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "a00a4327-8d3d-4f29-8580-4ee4f1d7e73f", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "7535336a-ab3b-40ea-a984-d24345f095d9", + "type": "E", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 46, + "maxHealth": 46, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 12 + }, + { + "block": 6, + "damage": 11 + }, + { + "block": 5, + "damage": 16 + }, + {}, + { + "block": 6 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "316bca1f-013c-4029-be44-02e2c679b96e", + "edges": {}, + "didVisit": false + }, + { + "id": "af2d4bd0-ccfb-4e86-be45-3623f3d3a956", + "edges": {}, + "didVisit": false + }, + { + "id": "aed6e315-d7c6-41a9-820a-3b3d72a8d051", + "edges": {}, + "didVisit": false + }, + { + "id": "b691f49c-8cc4-4c47-bae1-d881e1154726", + "type": "M", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 13, + "maxHealth": 13, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 12, + "maxHealth": 12, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 5 + } + ], + "nextIntent": 0 + }, + { + "currentHealth": 11, + "maxHealth": 11, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 7 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + }, + { + "id": "66d0c863-cb39-4423-a961-867cff91cf4d", + "type": "C", + "room": { + "type": "campfire" + }, + "edges": {}, + "didVisit": false + } + ], + [ + { + "id": "8d1a12db-f269-42c6-9306-2fcb27a097d0", + "type": "boss", + "room": { + "type": "monster", + "monsters": [ + { + "currentHealth": 124, + "maxHealth": 124, + "block": 0, + "powers": {}, + "intents": [ + { + "damage": 12 + }, + { + "block": 6 + }, + { + "damage": 13 + }, + { + "damage": 3 + }, + { + "weak": 2 + } + ], + "nextIntent": 0 + } + ] + }, + "edges": {}, + "didVisit": false + } + ] + ], + "paths": [ + [ + [ + [ + 0, + 0 + ], + [ + 1, + 0 + ] + ], + [ + [ + 1, + 0 + ], + [ + 2, + 2 + ] + ], + [ + [ + 2, + 2 + ], + [ + 3, + 1 + ] + ], + [ + [ + 3, + 1 + ], + [ + 4, + 3 + ] + ], + [ + [ + 4, + 3 + ], + [ + 5, + 0 + ] + ], + [ + [ + 5, + 0 + ], + [ + 6, + 1 + ] + ], + [ + [ + 6, + 1 + ], + [ + 7, + 1 + ] + ], + [ + [ + 7, + 1 + ], + [ + 8, + 0 + ] + ], + [ + [ + 8, + 0 + ], + [ + 9, + 2 + ] + ], + [ + [ + 9, + 2 + ], + [ + 10, + 0 + ] + ], + [ + [ + 10, + 0 + ], + [ + 11, + 0 + ] + ] + ], + [ + [ + [ + 0, + 0 + ], + [ + 1, + 2 + ] + ], + [ + [ + 1, + 2 + ], + [ + 2, + 2 + ] + ], + [ + [ + 2, + 2 + ], + [ + 3, + 3 + ] + ], + [ + [ + 3, + 3 + ], + [ + 4, + 3 + ] + ], + [ + [ + 4, + 3 + ], + [ + 5, + 3 + ] + ], + [ + [ + 5, + 3 + ], + [ + 6, + 3 + ] + ], + [ + [ + 6, + 3 + ], + [ + 7, + 2 + ] + ], + [ + [ + 7, + 2 + ], + [ + 8, + 2 + ] + ], + [ + [ + 8, + 2 + ], + [ + 9, + 2 + ] + ], + [ + [ + 9, + 2 + ], + [ + 10, + 4 + ] + ], + [ + [ + 10, + 4 + ], + [ + 11, + 0 + ] + ] + ], + [ + [ + [ + 0, + 0 + ], + [ + 1, + 3 + ] + ], + [ + [ + 1, + 3 + ], + [ + 2, + 3 + ] + ], + [ + [ + 2, + 3 + ], + [ + 3, + 3 + ] + ], + [ + [ + 3, + 3 + ], + [ + 4, + 3 + ] + ], + [ + [ + 4, + 3 + ], + [ + 5, + 3 + ] + ], + [ + [ + 5, + 3 + ], + [ + 6, + 3 + ] + ], + [ + [ + 6, + 3 + ], + [ + 7, + 3 + ] + ], + [ + [ + 7, + 3 + ], + [ + 8, + 3 + ] + ], + [ + [ + 8, + 3 + ], + [ + 9, + 4 + ] + ], + [ + [ + 9, + 4 + ], + [ + 10, + 4 + ] + ], + [ + [ + 10, + 4 + ], + [ + 11, + 0 + ] + ] + ], + [ + [ + [ + 0, + 0 + ], + [ + 1, + 3 + ] + ], + [ + [ + 1, + 3 + ], + [ + 2, + 4 + ] + ], + [ + [ + 2, + 4 + ], + [ + 3, + 5 + ] + ], + [ + [ + 3, + 5 + ], + [ + 4, + 5 + ] + ], + [ + [ + 4, + 5 + ], + [ + 5, + 5 + ] + ], + [ + [ + 5, + 5 + ], + [ + 6, + 5 + ] + ], + [ + [ + 6, + 5 + ], + [ + 7, + 3 + ] + ], + [ + [ + 7, + 3 + ], + [ + 8, + 4 + ] + ], + [ + [ + 8, + 4 + ], + [ + 9, + 5 + ] + ], + [ + [ + 9, + 5 + ], + [ + 10, + 5 + ] + ], + [ + [ + 10, + 5 + ], + [ + 11, + 0 + ] + ] + ] + ], + "x": 3, + "y": 2, + "pathTaken": [ + [ + 0, + 0 + ], + [ + 3, + 1 + ], + [ + 3, + 2 + ] + ] + }, + "createdAt": 1742928242873, + "won": false +} diff --git a/tests/game-serialization.js b/tests/game-serialization.js new file mode 100644 index 00000000..1b35814c --- /dev/null +++ b/tests/game-serialization.js @@ -0,0 +1,356 @@ +import test from 'ava' +import createNewGame from '../src/game/new-game.js' +// import data from './example-game-state.json' with { type: "json" }; +import data from './example-game-state.json' with { type: "json" }; +// import { stringify } from 'superjson'; + +import {encode, decode, encodeDungeon, decodeDungeon, encodeObject} from '../src/ui/save-load.js' + +// We don't want to test too much here, +// since tests/actions.js has most of it. + +// test('the default game state is this big', (t) => { +// const stringified = encode(data) +// t.is(stringified.length, 25791) +// }) + +test('analyze game state size', (t) => { + const game = createNewGame() + const fullState = encode(game.state) + t.log('Full state size:', fullState.length) + + // Analyze individual parts of the state + const stateWithoutDungeon = {...game.state, dungeon: null} + t.log('State without dungeon size:', encode(stateWithoutDungeon).length) + + // Check the size of just the dungeon + const justDungeon = game.state.dungeon + t.log('Just dungeon size:', encodeDungeon(justDungeon).length) + + // Deeper analysis of dungeon + if (game.state.dungeon) { + // Check the graph + const justGraph = game.state.dungeon.graph + t.log('Just dungeon graph size:', encodeObject(justGraph).length) + + // Check paths + const justPaths = game.state.dungeon.paths + t.log('Just dungeon paths size:', encodeObject(justPaths).length) + + // Inspect one floor of the graph (first row) + const oneFloor = game.state.dungeon.graph[0] + t.log('One floor of graph size:', encodeObject(oneFloor).length) + + // Inspect one node in the graph + if (game.state.dungeon.graph[0].length > 0) { + const oneNode = game.state.dungeon.graph[0][0] + t.log('One node size:', encodeObject(oneNode).length) + + // If the node has a room, analyze that too + if (game.state.dungeon.graph[0][0].room) { + const oneRoom = game.state.dungeon.graph[0][0].room + t.log('One room size:', encodeObject(oneRoom).length) + } + } + + // Count the number of nodes in the graph + let nodeCount = 0; + for (const floor of game.state.dungeon.graph) { + nodeCount += floor.length; + } + t.log('Total number of nodes in graph:', nodeCount); + + // Analyze a typical monster room (more complex) + let monsterRoom = null; + // Find a monster room in the graph + for (const floor of game.state.dungeon.graph) { + for (const node of floor) { + if (node.type === 'M' && node.room) { + monsterRoom = node.room; + break; + } + } + if (monsterRoom) break; + } + + if (monsterRoom) { + const monsterRoomSize = encodeObject(monsterRoom).length; + t.log('Monster room size:', monsterRoomSize); + + // Check monsters in the room + if (monsterRoom.monsters && monsterRoom.monsters.length > 0) { + const monstersSize = encodeObject(monsterRoom.monsters).length; + t.log('Monsters size:', monstersSize); + + // Check a single monster + const oneMonster = monsterRoom.monsters[0]; + t.log('One monster size:', encodeObject(oneMonster).length); + + // Check monster intents (AI patterns) + if (monsterRoom.monsters[0].intents) { + const intentsSize = encodeObject(monsterRoom.monsters[0].intents).length; + t.log('Monster intents size:', intentsSize); + } + } + } + } + + // Analyze deck portions + const justDeck = game.state.deck + t.log('Just deck size:', encodeObject(justDeck).length) + + const justDrawPile = game.state.drawPile + t.log('Just drawPile size:', encodeObject(justDrawPile).length) + + const firstCard = game.state.deck[0] + t.log('First card size:', encodeObject(firstCard).length) + + // Analyze card details + if (firstCard) { + // Check actions on a card + if (firstCard.actions) { + const actionsSize = encodeObject(firstCard.actions).length; + t.log('Card actions size:', actionsSize); + } + + // Check other card properties + if (firstCard.description) { + const descSize = encodeObject(firstCard.description).length; + t.log('Card description size:', descSize); + } + } + + // Check size of the played game state from example + const exampleState = encode(data) + t.log('Example game state size:', exampleState.length) + + // Test the compression difference from a minimal card representation + const minimalCard = { + id: firstCard.id, + name: firstCard.name, + energy: firstCard.energy, + damage: firstCard.damage, + block: firstCard.block + } + t.log('Minimal card size:', encodeObject(minimalCard).length) + + t.pass() +}) + +test('optimized dungeon serialization', (t) => { + const game = createNewGame() + const originalDungeon = game.state.dungeon + + // Create an optimized representation + /** @type {{id: string, x: number, y: number, rooms: any[], edges: any[], pathTaken: any[], monsterTemplates?: any}} */ + const optimizedDungeon = { + id: originalDungeon.id, + x: originalDungeon.x, + y: originalDungeon.y, + // Only store active rooms and their positions + rooms: [], + // Store edges as arrays of indices rather than Sets + edges: [], + // Current position and path + pathTaken: originalDungeon.pathTaken + } + + // Extract and store only rooms that exist (not empty nodes) + originalDungeon.graph.forEach((floor, y) => { + floor.forEach((node, x) => { + if (node.type) { + // Store minimal information about each room + const room = { + id: node.id, + pos: [y, x], + type: node.type, + didVisit: node.didVisit + } + + // Only include room data if it has a room + if (node.room) { + room.room = node.room + } + + optimizedDungeon.rooms.push(room) + + // Convert Set of edges to array of room IDs + if (node.edges && node.edges.size > 0) { + optimizedDungeon.edges.push({ + from: node.id, + to: Array.from(node.edges) + }) + } + } + }) + }) + + // Compare sizes + const originalSize = encodeDungeon(originalDungeon).length + const optimizedSize = encodeObject(optimizedDungeon).length + + t.log('Original dungeon size:', originalSize) + t.log('Optimized dungeon size:', optimizedSize) + t.log('Size reduction:', Math.round((1 - optimizedSize/originalSize) * 100) + '%') + + // Further compress by optimizing monster data + // Extract a template for each monster type and only store differences + const furtherOptimized = {...optimizedDungeon, monsterTemplates: {}} + const monsterTemplates = {} + const roomsWithOptimizedMonsters = [] + + for (const room of optimizedDungeon.rooms) { + if (room.room && room.room.monsters && room.room.monsters.length > 0) { + const optimizedRoom = {...room} + optimizedRoom.room = {...room.room} + optimizedRoom.room.monsters = [] + + room.room.monsters.forEach((monster, i) => { + // Create a template ID from monster properties + const templateId = `monster_${monster.maxHealth}_${monster.intents?.length || 0}` + + // Store template if not seen before + if (!monsterTemplates[templateId]) { + monsterTemplates[templateId] = { + maxHealth: monster.maxHealth, + intents: monster.intents || [] + } + } + + // Only store differences from template + optimizedRoom.room.monsters.push({ + templateId, + currentHealth: monster.currentHealth, + block: monster.block, + powers: monster.powers, + nextIntent: monster.nextIntent + }) + }) + + roomsWithOptimizedMonsters.push(optimizedRoom) + } else { + roomsWithOptimizedMonsters.push(room) + } + } + + furtherOptimized.rooms = roomsWithOptimizedMonsters + furtherOptimized.monsterTemplates = monsterTemplates + + const furtherOptimizedSize = encodeObject(furtherOptimized).length + t.log('Further optimized dungeon size:', furtherOptimizedSize) + t.log('Additional size reduction:', Math.round((1 - furtherOptimizedSize/optimizedSize) * 100) + '%') + t.log('Total size reduction:', Math.round((1 - furtherOptimizedSize/originalSize) * 100) + '%') + + t.pass() +}) + +test('new compression approach with round-trip serialization', (t) => { + // Create a new game and serialize it with our new approach + const game = createNewGame() + const originalSize = JSON.stringify(game.state).length + + // Encode with our new system + const serialized = encode(game.state) + t.log('Original JSON size:', originalSize) + t.log('New compressed size:', serialized.length) + t.log('Compression ratio:', Math.round((serialized.length / originalSize) * 100) + '%') + + // Verify we can decode it back correctly + const restored = decode(serialized) + + // Verify structure is intact + t.is(typeof restored, 'object') + t.is(typeof restored.player, 'object') + t.is(typeof restored.deck, 'object') + t.true(Array.isArray(restored.deck)) + + // Check dungeon integrity + t.is(typeof restored.dungeon, 'object') + t.is(typeof restored.dungeon.graph, 'object') + t.true(Array.isArray(restored.dungeon.graph)) + t.true(Array.isArray(restored.dungeon.graph[0])) + + // Test with the example state + const originalExampleSize = JSON.stringify(data).length + const serializedExample = encode(data) + t.log('Example original JSON size:', originalExampleSize) + t.log('Example compressed size:', serializedExample.length) + t.log('Example compression ratio:', Math.round((serializedExample.length / originalExampleSize) * 100) + '%') + + // Restore and check + const restoredExample = decode(serializedExample) + t.is(restoredExample.turn, data.turn) + t.is(restoredExample.player.currentHealth, data.player.currentHealth) + + t.pass() +}) + +test('dungeon compression roundtrip', (t) => { + const game = createNewGame() + const originalDungeon = game.state.dungeon + + // Compress the dungeon + const encoded = encodeDungeon(originalDungeon) + t.log('Original dungeon size (via JSON.stringify):', JSON.stringify(originalDungeon).length) + t.log('Compressed dungeon size:', encoded.length) + t.log('Compression ratio:', Math.round((encoded.length / JSON.stringify(originalDungeon).length) * 100) + '%') + + // Then decompress it + const decoded = decodeDungeon(encoded) + + // Verify the structure is intact + t.is(decoded.id, originalDungeon.id) + t.is(decoded.x, originalDungeon.x) + t.is(decoded.y, originalDungeon.y) + + // Verify graph is reconstructed correctly + t.true(Array.isArray(decoded.graph)) + t.is(decoded.graph.length, originalDungeon.graph.length) + + // Check that a node from the graph has the expected properties + const originalNode = findFirstRealNode(originalDungeon.graph) + const decodedNode = findNodeById(decoded.graph, originalNode.id) + t.truthy(decodedNode) + t.is(decodedNode.id, originalNode.id) + t.is(decodedNode.type, originalNode.type) + t.is(decodedNode.didVisit, originalNode.didVisit) + + // Verify edges were preserved + if (originalNode.edges.size > 0) { + t.true(decodedNode.edges.size > 0) + // Check that the first edge exists in both + const firstOriginalEdge = Array.from(originalNode.edges)[0] + t.true(decodedNode.edges.has(firstOriginalEdge)) + } + + t.pass() +}) + +/** + * Helper to find the first non-empty node in a graph + */ +function findFirstRealNode(graph) { + for (const floor of graph) { + for (const node of floor) { + if (node.type) { + return node + } + } + } + return null +} + +/** + * Helper to find a node by ID in a graph + */ +function findNodeById(graph, id) { + for (const floor of graph) { + for (const node of floor) { + if (node.id === id) { + return node + } + } + } + return null +} + From 67d8444e35dde53d967afd32c279924d70d2a66a Mon Sep 17 00:00:00 2001 From: oskarrough Date: Tue, 25 Mar 2025 20:26:14 +0100 Subject: [PATCH 2/2] Docs --- tests/game-serialization.js | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/game-serialization.js b/tests/game-serialization.js index 1b35814c..c2b78036 100644 --- a/tests/game-serialization.js +++ b/tests/game-serialization.js @@ -1,3 +1,41 @@ +/** + * Game serialization analysis and optimization + * + * FINDINGS: + * 1. The dungeon graph structure takes up ~80% of the game state size (18-20KB out of 25KB) + * - Many empty/filler nodes in the graph waste space + * - Set objects for edges aren't efficiently serialized + * - Monster data with intents adds significant weight + * + * 2. Breakdown of a typical serialized game state: + * - Full state: ~24KB + * - Dungeon alone: ~19KB (80% of total) + * - Graph structure: ~17KB (71% of total) + * - Paths: ~650 bytes + * - Each node: ~290 bytes (with 62 nodes total) + * - Monster data with intents: ~150-180 bytes per monster + * + * OPTIMIZATION APPROACH: + * 1. Version-aware serialization + * - Added versioning to support both legacy game states and future changes + * - Implemented migration mechanism for future format evolution + * + * 2. Dungeon-specific compression + * - Only store non-empty nodes in a flat array with positions + * - Convert Sets to arrays for edges + * - Preserve paths for backward compatibility + * + * 3. Results: + * - For played games (example state): 7% size reduction (25,782 → 23,907 bytes) + * - Not as effective for new game states + * - Completely backward compatible with older saves + * + * POTENTIAL FUTURE IMPROVEMENTS: + * 1. Use numeric IDs instead of UUIDs + * 2. Template system for monsters to avoid duplicating intent arrays + * 3. Optimize card serialization with similar templating approach + */ + import test from 'ava' import createNewGame from '../src/game/new-game.js' // import data from './example-game-state.json' with { type: "json" };