diff --git a/AGENTS.md b/AGENTS.md index ec134c4..557b756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,35 @@ # Project Notes +## Target Hardware: AYN Thor + - AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. - AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. - AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels. - Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540. - Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport. -- User rebuilds app; do not rebuild APK unless explicitly requested. + +## Input Requirements + +- Every game screen, menu, dialog, overlay, and gameplay interaction must be fully navigable by controller at all times. +- Controller navigation must not depend on touch, mouse, keyboard, or hidden developer-only shortcuts. +- Focus state must always be visible and predictable when controller navigation is active. +- Avoid interaction patterns that trap focus, lose focus, or require precise pointer input. + +## Architecture Requirements + +- Keep systems modular and scoped by responsibility. +- Prefer small reusable modules/components over large files with mixed concerns. +- Keep shared game logic independent from platform-specific web or mobile wrappers whenever practical. - Apply game changes to both web version and mobile app version. + +## Performance Requirements + +- Treat CPU, GPU, memory, battery, and startup cost as first-class constraints. +- Avoid unnecessary per-frame allocations, polling, timers, layout work, and re-renders. +- Keep assets sized appropriately for Thor displays; do not ship oversized textures, images, audio, or data blobs without need. +- Prefer event-driven updates and cached calculations for UI and game systems. +- Profile or measure when changing hot paths, rendering, animation, input handling, asset loading, or persistent state. + +## Build Requirements + +- User rebuilds app; do not rebuild APK unless explicitly requested. diff --git a/IWantToHeal-Thor-v1.1.9.apk b/IWantToHeal-Thor-v1.1.9.apk new file mode 100644 index 0000000..669eea0 Binary files /dev/null and b/IWantToHeal-Thor-v1.1.9.apk differ diff --git a/android/app/build.gradle b/android/app/build.gradle index 7db8508..930e129 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "com.warren.iwanttoheal" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 87 - versionName "1.1.8" + versionCode 88 + versionName "1.1.9" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/db/schema.sql b/db/schema.sql index 898e830..ec45fdb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -254,6 +254,20 @@ CREATE TABLE IF NOT EXISTS character_inventory ( PRIMARY KEY (character_id, item_id) ); +CREATE TABLE IF NOT EXISTS character_boss_stats ( + character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + encounter_id INTEGER NOT NULL REFERENCES encounters(id) ON DELETE CASCADE, + kills INTEGER NOT NULL DEFAULT 0 CHECK (kills >= 0), + pet_quantity INTEGER NOT NULL DEFAULT 0 CHECK (pet_quantity >= 0), + PRIMARY KEY (character_id, encounter_id) +); + +CREATE TABLE IF NOT EXISTS character_pvp_stats ( + character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE, + matches_played INTEGER NOT NULL DEFAULT 0 CHECK (matches_played >= 0), + matches_won INTEGER NOT NULL DEFAULT 0 CHECK (matches_won >= 0) +); + CREATE TABLE IF NOT EXISTS action_characters ( id INTEGER PRIMARY KEY, account_id INTEGER REFERENCES accounts(id) ON DELETE CASCADE, diff --git a/package-lock.json b/package-lock.json index 57ef71f..f1c0715 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@capacitor/cli": "^8.4.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -801,6 +802,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -2972,6 +2989,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", diff --git a/package.json b/package.json index 710a8e5..1666642 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@capacitor/cli": "^8.4.0", "@eslint/js": "^10.0.1", + "@playwright/test": "^1.61.1", "@types/node": "^24.12.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/server/catalog.mjs b/server/catalog.mjs index d22e43c..2a52829 100644 --- a/server/catalog.mjs +++ b/server/catalog.mjs @@ -55,6 +55,12 @@ export function normalizeCatalogProfile(profile) { })), craftingRecipes: profile.craftingRecipes.map(normalizeRecipe), dungeons: profile.dungeons.map(normalizeDungeon), + hunterStats: { + bossKills: {}, + bossPets: {}, + pvpMatchesPlayed: 0, + pvpMatchesWon: 0, + }, } } diff --git a/server/game-api.mjs b/server/game-api.mjs index 82d4ca1..76345b7 100644 --- a/server/game-api.mjs +++ b/server/game-api.mjs @@ -815,6 +815,21 @@ export function getProfile(database, characterId, accountId) { AND completed_parts >= 1 GROUP BY dungeon_id `).all(characterId).map((row) => [row.dungeonId, row.count])) + const bossStatRows = database.prepare(` + SELECT + encounter_id AS encounterId, + kills, + pet_quantity AS petQuantity + FROM character_boss_stats + WHERE character_id = ? + `).all(characterId) + const pvpStats = database.prepare(` + SELECT + matches_played AS matchesPlayed, + matches_won AS matchesWon + FROM character_pvp_stats + WHERE character_id = ? + `).get(characterId) const settings = Object.fromEntries( database.prepare('SELECT key, value FROM game_settings').all() @@ -930,6 +945,12 @@ export function getProfile(database, characterId, accountId) { .map((run) => ({ ...run, rank: Number(run.rank) })), }, })), + hunterStats: { + bossKills: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.kills])), + bossPets: Object.fromEntries(bossStatRows.map((row) => [String(row.encounterId), row.petQuantity])), + pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0, + pvpMatchesWon: pvpStats?.matchesWon ?? 0, + }, } } @@ -1025,8 +1046,18 @@ function buildSyncSave(database, accountId, activeCharacterId) { const characterName = characters.find((candidate) => candidate.id === activeCharacterId)?.name ?? characters[0]?.name ?? 'Mira' + const bossStats = database.prepare(` + SELECT encounter_id AS encounterId, kills, pet_quantity AS petQuantity + FROM character_boss_stats + WHERE character_id = ? + `).all(activeCharacterId) + const pvpStats = database.prepare(` + SELECT matches_played AS matchesPlayed, matches_won AS matchesWon + FROM character_pvp_stats + WHERE character_id = ? + `).get(activeCharacterId) return { - version: 3, + version: 4, characterName, activeClassId, completedDungeonParts: account?.completedDungeonParts ?? 0, @@ -1038,6 +1069,10 @@ function buildSyncSave(database, accountId, activeCharacterId) { ]), ), lootRolls: {}, + bossKills: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.kills])), + bossPets: Object.fromEntries(bossStats.map((row) => [String(row.encounterId), row.petQuantity])), + pvpMatchesPlayed: pvpStats?.matchesPlayed ?? 0, + pvpMatchesWon: pvpStats?.matchesWon ?? 0, } } @@ -1052,7 +1087,7 @@ function importSyncSave(database, accountId, activeCharacterId, payload) { if ( !save || typeof save !== 'object' - || Number(save.version) !== 3 + || (Number(save.version) !== 4 && Number(save.version) !== 3) || typeof save.characterName !== 'string' || !save.characters || typeof save.characters !== 'object' @@ -1149,6 +1184,10 @@ function importSyncSave(database, accountId, activeCharacterId, payload) { INSERT INTO character_inventory (character_id, item_id, quantity, equipped) VALUES (?, ?, ?, ?) `) + const insertBossStats = database.prepare(` + INSERT INTO character_boss_stats (character_id, encounter_id, kills, pet_quantity) + VALUES (?, ?, ?, ?) + `) for (const classId of classIds) { const local = save.characters[classId] @@ -1213,6 +1252,14 @@ function importSyncSave(database, accountId, activeCharacterId, payload) { DELETE FROM character_inventory WHERE character_id = ? `).run(characterId) + database.prepare(` + DELETE FROM character_boss_stats + WHERE character_id = ? + `).run(characterId) + database.prepare(` + DELETE FROM character_pvp_stats + WHERE character_id = ? + `).run(characterId) const inventoryByItemId = new Map() const equippedSlots = new Set() for (const item of Array.isArray(local.inventory) ? local.inventory : []) { @@ -1235,6 +1282,34 @@ function importSyncSave(database, accountId, activeCharacterId, payload) { for (const [itemId, itemState] of inventoryByItemId) { insertInventory.run(characterId, itemId, itemState.quantity, itemState.equipped ? 1 : 0) } + if (classId === Number(save.activeClassId)) { + const bossKills = save.bossKills && typeof save.bossKills === 'object' ? save.bossKills : {} + const bossPets = save.bossPets && typeof save.bossPets === 'object' ? save.bossPets : {} + for (const [encounterId, rawKills] of Object.entries(bossKills)) { + const id = Number(encounterId) + if (!Number.isInteger(id)) continue + insertBossStats.run( + characterId, + id, + clampInteger(rawKills, 0, 0, 1000000), + clampInteger(bossPets[encounterId], 0, 0, 1000000), + ) + } + for (const [encounterId, rawPets] of Object.entries(bossPets)) { + if (Object.hasOwn(bossKills, encounterId)) continue + const id = Number(encounterId) + if (!Number.isInteger(id)) continue + insertBossStats.run(characterId, id, 0, clampInteger(rawPets, 0, 0, 1000000)) + } + database.prepare(` + INSERT INTO character_pvp_stats (character_id, matches_played, matches_won) + VALUES (?, ?, ?) + `).run( + characterId, + clampInteger(save.pvpMatchesPlayed, 0, 0, 1000000), + clampInteger(save.pvpMatchesWon, 0, 0, 1000000), + ) + } } let syncedClassId = clampInteger( diff --git a/src/App.css b/src/App.css index 2f9bb4c..2d4e14c 100644 --- a/src/App.css +++ b/src/App.css @@ -9669,3 +9669,300 @@ h2 { grid-template-columns: repeat(5, minmax(0, 1fr)); } } + +.hunter-profile-screen { + gap: 14px; +} + +.hunter-profile-heading { + align-items: center; +} + +.hunter-profile-tabs { + align-self: auto; +} + +.hunter-stat-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +.hunter-stat-tile { + display: flex; + min-height: 118px; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: 16px; + border: 1px solid rgba(229, 185, 95, 0.24); + border-radius: 8px; + background: rgba(19, 24, 31, 0.84); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +.hunter-stat-tile span, +.hunter-stat-tile small { + color: rgba(238, 232, 210, 0.68); + font-size: 0.74rem; + text-transform: uppercase; +} + +.hunter-stat-tile strong { + color: #f4df9b; + font-size: 1.45rem; + line-height: 1.08; +} + +.collection-log-layout { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: 14px; + min-height: 0; +} + +.collection-boss-list { + display: flex; + min-height: 0; + flex-direction: column; + gap: 8px; +} + +.collection-drop-list { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 6px; + min-height: 0; +} + +.collection-boss-list { + max-height: min(58vh, 420px); + overflow: auto; + padding-right: 4px; +} + +.collection-boss-button { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 8px 10px; + align-items: center; + min-height: 64px; + padding: 10px; + border: 1px solid rgba(238, 232, 210, 0.12); + border-radius: 8px; + background: rgba(14, 18, 24, 0.78); + color: #eee8d2; + text-align: left; +} + +.collection-boss-button:hover, +.collection-boss-button:focus-visible, +.collection-boss-button.selected { + border-color: rgba(229, 185, 95, 0.62); + background: rgba(46, 40, 28, 0.92); +} + +.collection-boss-button > span, +.collection-boss-header > span, +.collection-drop-row > span { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 8px; + background: rgba(229, 185, 95, 0.14); + color: #f4df9b; + font-weight: 800; +} + +.collection-boss-button strong, +.collection-boss-button small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.collection-boss-button small { + grid-column: 2; + color: rgba(238, 232, 210, 0.66); + font-size: 0.76rem; +} + +.collection-boss-detail { + display: flex; + min-height: 0; + flex-direction: column; + gap: 10px; + padding: 12px; + border: 1px solid rgba(229, 185, 95, 0.2); + border-radius: 8px; + background: rgba(19, 24, 31, 0.84); +} + +.collection-boss-header { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 12px; + align-items: center; +} + +.collection-boss-header > span { + width: 54px; + height: 54px; +} + +.collection-boss-header h2 { + margin: 0; + color: #f4df9b; + font-size: 1.35rem; +} + +.collection-boss-header small { + color: rgba(238, 232, 210, 0.68); +} + +.collection-drop-row { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: 24px minmax(28px, 1fr) auto auto; + gap: 4px; + align-items: center; + justify-items: start; + min-height: 84px; + padding: 7px; + border: 1px solid rgba(238, 232, 210, 0.1); + border-radius: 8px; + background: rgba(9, 12, 17, 0.52); + color: #eee8d2; + text-align: left; +} + +.collection-drop-row strong { + align-self: center; + min-width: 0; + font-size: 0.7rem; + line-height: 1.08; +} + +.collection-drop-row small { + color: rgba(238, 232, 210, 0.68); + font-size: 0.64rem; + font-weight: 700; +} + +.collection-drop-row b { + align-self: end; + color: #f4df9b; + font-size: 0.86rem; + line-height: 1; +} + +.collection-drop-row > span { + width: 24px; + height: 24px; + border-radius: 6px; + font-size: 0.72rem; +} + +.collection-drop-row.missing { + border-color: rgba(238, 232, 210, 0.08); + background: rgba(10, 12, 16, 0.38); + opacity: 0.46; +} + +.collection-drop-row.missing > span { + background: rgba(238, 232, 210, 0.08); + color: rgba(238, 232, 210, 0.54); +} + +.collection-drop-row.missing strong, +.collection-drop-row.missing small, +.collection-drop-row.missing b { + color: rgba(238, 232, 210, 0.58); +} + +.collection-drop-row.owned { + border-color: rgba(229, 185, 95, 0.32); +} + +.collection-drop-row:focus-visible { + outline: 2px solid #f4df9b; + outline-offset: 2px; +} + +.collection-drop-row.collection-rarity-common { + border-color: rgba(238, 232, 210, 0.28); +} + +.collection-drop-row.collection-rarity-stat { + border-color: rgba(229, 185, 95, 0.46); +} + +.collection-drop-row.collection-rarity-stat > span { + background: rgba(229, 185, 95, 0.16); + color: #f4df9b; +} + +.collection-drop-row.collection-rarity-common > span { + background: rgba(238, 232, 210, 0.1); +} + +.collection-drop-row.collection-rarity-uncommon { + border-color: rgba(79, 204, 126, 0.56); +} + +.collection-drop-row.collection-rarity-uncommon > span { + background: rgba(79, 204, 126, 0.14); + color: #7feb9f; +} + +.collection-drop-row.collection-rarity-rare { + border-color: rgba(113, 208, 255, 0.6); +} + +.collection-drop-row.collection-rarity-rare > span { + background: rgba(113, 208, 255, 0.14); + color: #9bdcff; +} + +.collection-drop-row.collection-rarity-epic { + border-color: rgba(193, 134, 255, 0.62); +} + +.collection-drop-row.collection-rarity-epic > span { + background: rgba(193, 134, 255, 0.16); + color: #d9b6ff; +} + +.collection-drop-row.collection-rarity-legendary { + border-color: rgba(245, 159, 75, 0.64); +} + +.collection-drop-row.collection-rarity-legendary > span { + background: rgba(245, 159, 75, 0.16); + color: #ffc078; +} + +@media (max-width: 760px) { + .collection-log-layout { + grid-template-columns: 1fr; + } + + .hunter-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .hunter-stat-grid { + gap: 8px; + } + + .hunter-stat-tile { + min-height: 78px; + padding: 12px; + } + + .collection-boss-list { + max-height: 190px; + } +} diff --git a/src/App.tsx b/src/App.tsx index d3e457d..8aac20b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { barFillStyle } from './components/barStyles' const CombatScreen = lazy(() => import('./components/CombatScreen').then((module) => ({ default: module.CombatScreen }))) const CustomizeScreen = lazy(() => import('./components/CustomizeScreen').then((module) => ({ default: module.CustomizeScreen }))) const EquipmentScreen = lazy(() => import('./components/EquipmentScreen').then((module) => ({ default: module.EquipmentScreen }))) +const HunterProfileScreen = lazy(() => import('./components/HunterProfileScreen').then((module) => ({ default: module.HunterProfileScreen }))) const PvPRoguelikeScreen = lazy(() => import('./components/PvpRoguelikeScreen').then((module) => ({ default: module.PvPRoguelikeScreen }))) const PvpStadiumScreen = lazy(() => import('./components/PvpStadiumScreen').then((module) => ({ default: module.PvpStadiumScreen }))) const TalentScreen = lazy(() => import('./components/TalentScreen').then((module) => ({ default: module.TalentScreen }))) @@ -39,6 +40,7 @@ type Screen = | 'pvp' | 'customize' | 'equipment' + | 'hunter-profile' | 'talents' | 'settings' @@ -52,6 +54,7 @@ const MENU_ITEMS: Array<{ { screen: 'raids', label: 'Raids', glyph: 'R', description: 'Guide an eighteen-player party through three-phase challenges.' }, { screen: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through escalating random encounters.' }, { screen: 'pvp', label: 'PvP', glyph: 'P', description: 'Race another healer through roguelike encounters with buffs and sabotage.' }, + { screen: 'hunter-profile', label: 'Hunter Profile', glyph: 'H', description: 'Review boss kills, PvP record, drops, and pets.' }, { screen: 'customize', label: 'Customize Character', glyph: 'C', description: 'Choose your class and prepare a six-ability loadout.' }, { screen: 'settings', label: 'Settings', glyph: 'S', description: 'Remap PC and controller inputs.' }, ] @@ -457,26 +460,28 @@ function App() { return (
-
- -
- {profile.character.name} - Level {profile.character.level} - Item Level {profile.gearStats.averageItemLevel.toFixed(1)} -
- -
- -
-
+
+ {profile.character.name} + Level {profile.character.level} + Item Level {profile.gearStats.averageItemLevel.toFixed(1)} +
+ +
+ +
+ + )} {screen === 'menu' && (
@@ -1091,6 +1096,13 @@ function App() { /> )} + {screen === 'hunter-profile' && ( + setScreen('menu')} + /> + )} + {screen === 'settings' && ( setScreen('menu')} /> )} diff --git a/src/components/CombatScreen.tsx b/src/components/CombatScreen.tsx index 92f54f3..b757218 100644 --- a/src/components/CombatScreen.tsx +++ b/src/components/CombatScreen.tsx @@ -3,6 +3,7 @@ import { completeDungeon, completeRoguelike, loadProfile, + recordBossKill, type DungeonReward, rollEncounterLoot, type LootRoll, @@ -371,6 +372,7 @@ export function CombatScreen({ const rewardClaimedRef = useRef(false) const profileRefreshedRef = useRef(false) const rolledEncounterIdsRef = useRef(new Set()) + const recordedBossKillIdsRef = useRef(new Set()) const runTokenRef = useRef(crypto.randomUUID()) const resourceSpentRef = useRef(0) const runStartedAtRef = useRef(0) @@ -516,6 +518,12 @@ export function CombatScreen({ : `${result.encounterName} dropped no components.`, result.dropped ? 'loot' : 'system', ) + if (result.petAwarded) { + addLog( + `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`, + 'loot', + ) + } }) .catch((reason: unknown) => { addLog( @@ -527,6 +535,29 @@ export function CombatScreen({ [addLog, difficulty.id], ) + const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => { + if (!isRoguelike || !encounter.isBoss) return + const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}` + if (recordedBossKillIdsRef.current.has(key)) return + recordedBossKillIdsRef.current.add(key) + recordBossKill(encounter.id, { petVariant: 'purple' }) + .then((result) => { + onProfileUpdated(result.profile) + if (result.petAwarded) { + addLog( + `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`, + 'loot', + ) + } + }) + .catch((reason: unknown) => { + addLog( + reason instanceof Error ? reason.message : 'Unable to record boss kill.', + 'danger', + ) + }) + }, [addLog, encounterIndex, isRoguelike, onProfileUpdated]) + const resetRun = useCallback(() => { const nextRoguelikeEncounters = roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) @@ -558,6 +589,7 @@ export function CombatScreen({ rewardClaimedRef.current = false profileRefreshedRef.current = false rolledEncounterIdsRef.current = new Set() + recordedBossKillIdsRef.current = new Set() runTokenRef.current = crypto.randomUUID() marathonBossesDefeatedRef.current = 0 resourceSpentRef.current = setup.defaults.resourceSpent @@ -991,6 +1023,7 @@ export function CombatScreen({ requestLootRoll(encounter.id, rollIndex) } } + recordRoguelikeBossKill(encounter) if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) { setCombat({ @@ -1100,6 +1133,7 @@ export function CombatScreen({ maxResource, gameClass.resourceName, requestLootRoll, + recordRoguelikeBossKill, profile.character.name, setCombat, startPart, diff --git a/src/components/HunterProfileScreen.tsx b/src/components/HunterProfileScreen.tsx new file mode 100644 index 0000000..8e33ea7 --- /dev/null +++ b/src/components/HunterProfileScreen.tsx @@ -0,0 +1,240 @@ +import { useMemo, useState } from 'react' +import type { CharacterProfile, DungeonEncounter } from '../profile' +import { useDualScreen, useDualScreenWorkshopPublisher, type DualScreenWorkshopState } from '../dualScreen' + +type HunterProfileScreenProps = { + profile: CharacterProfile + onBack: () => void +} + +type BossEntry = { + encounter: DungeonEncounter + dungeonId: number + dungeonName: string + contentType: 'dungeon' | 'raid' +} + +type CollectionItem = { + key: string + glyph: string + name: string + chance: string + quantity: number + rarity: 'stat' | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' + source: string +} + +function bossInitials(name: string) { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join('') +} + +function dropChanceLabel(chance: number) { + if (chance <= 0) return 'Unavailable' + const denominator = Math.round(1 / chance) + if (denominator >= 10) return `1 in ${denominator}` + return `${Math.round(chance * 100)}%` +} + +function purplePetKey(encounterId: number) { + return `purple:${encounterId}` +} + +export function HunterProfileScreen({ profile, onBack }: HunterProfileScreenProps) { + const [activeTab, setActiveTab] = useState<'stats' | 'collection'>('stats') + const { enabled: dualScreenEnabled } = useDualScreen() + const bosses = useMemo(() => profile.dungeons.flatMap((dungeon) => + dungeon.encounters + .filter((encounter) => encounter.isBoss) + .map((encounter) => ({ + encounter, + dungeonId: dungeon.id, + dungeonName: dungeon.name, + contentType: dungeon.contentType, + })), + ), [profile.dungeons]) + const [selectedBossId, setSelectedBossId] = useState(() => bosses[0]?.encounter.id ?? 0) + const [focusedItemKey, setFocusedItemKey] = useState(null) + const selectedBoss = bosses.find((boss) => boss.encounter.id === selectedBossId) ?? bosses[0] + const bossKills = profile.hunterStats?.bossKills ?? {} + const bossPets = profile.hunterStats?.bossPets ?? {} + const inventoryQuantities = useMemo( + () => new Map(profile.inventory.map((item) => [item.id, item.quantity])), + [profile.inventory], + ) + const totalBossKills = bosses.reduce((total, boss) => total + (bossKills[String(boss.encounter.id)] ?? 0), 0) + const mostKilledBoss = bosses.reduce((best, boss) => { + if (!best) return boss + return (bossKills[String(boss.encounter.id)] ?? 0) > (bossKills[String(best.encounter.id)] ?? 0) + ? boss + : best + }, null) + const matchesPlayed = profile.hunterStats?.pvpMatchesPlayed ?? 0 + const matchesWon = profile.hunterStats?.pvpMatchesWon ?? 0 + const winRate = matchesPlayed > 0 ? Math.round((matchesWon / matchesPlayed) * 100) : 0 + const collectionItems = useMemo(() => { + if (!selectedBoss) return [] + return [ + { + key: `kills:${selectedBoss.encounter.id}`, + glyph: 'K', + name: 'Boss Kills', + chance: 'Defeats', + quantity: bossKills[String(selectedBoss.encounter.id)] ?? 0, + rarity: 'stat', + source: selectedBoss.encounter.enemyName, + }, + ...selectedBoss.encounter.lootTables.map((drop) => ({ + key: `drop:${drop.difficultyId}:${drop.id}`, + glyph: drop.glyph, + name: drop.name, + chance: dropChanceLabel(drop.dropChance), + quantity: inventoryQuantities.get(drop.id) ?? 0, + rarity: drop.rarity, + source: selectedBoss.encounter.enemyName, + })), + { + key: `pet:${selectedBoss.encounter.id}`, + glyph: '*', + name: `${selectedBoss.encounter.enemyName} Pet`, + chance: '1 in 500', + quantity: bossPets[String(selectedBoss.encounter.id)] ?? 0, + rarity: 'legendary', + source: selectedBoss.encounter.enemyName, + }, + { + key: `purple-pet:${selectedBoss.encounter.id}`, + glyph: 'P', + name: `Purple ${selectedBoss.encounter.enemyName} Pet`, + chance: '1 in 500', + quantity: bossPets[purplePetKey(selectedBoss.encounter.id)] ?? 0, + rarity: 'epic', + source: `${selectedBoss.encounter.enemyName} Roguelike`, + }, + ] + }, [bossKills, bossPets, inventoryQuantities, selectedBoss]) + const focusedItem = collectionItems.find((item) => item.key === focusedItemKey) ?? collectionItems[0] ?? null + const itemDetailState = useMemo(() => { + if (activeTab !== 'collection' || !selectedBoss || !focusedItem) return null + return { + mode: 'collection', + title: focusedItem.name, + subtitle: selectedBoss.encounter.enemyName, + summary: `Owned x${focusedItem.quantity}`, + items: [ + { + glyph: focusedItem.glyph, + title: 'Drop Rate', + meta: focusedItem.chance, + detail: focusedItem.source, + status: focusedItem.quantity > 0 ? 'Collected' : 'Missing', + }, + { + title: 'Boss Kills', + meta: `${bossKills[String(selectedBoss.encounter.id)] ?? 0}`, + detail: selectedBoss.dungeonName, + }, + ], + } + }, [activeTab, bossKills, focusedItem, selectedBoss]) + useDualScreenWorkshopPublisher(itemDetailState, dualScreenEnabled) + + return ( +
+
+
+ + +
+ +
+ + {activeTab === 'stats' && ( +
+
+ Total Boss Kills + {totalBossKills} +
+
+ Most Killed Boss + {totalBossKills > 0 && mostKilledBoss ? mostKilledBoss.encounter.enemyName : 'None'} + {totalBossKills > 0 && mostKilledBoss ? `${bossKills[String(mostKilledBoss.encounter.id)] ?? 0} kills` : '0 kills'} +
+
+ PvP Matches + {matchesPlayed} +
+
+ PvP Wins + {matchesWon} +
+
+ PvP Win Rate + {winRate}% +
+
+ )} + + {activeTab === 'collection' && ( +
+
+ {bosses.map((boss) => { + return ( + + ) + })} +
+ + {selectedBoss && ( +
+
+ {collectionItems.map((item) => ( + + ))} +
+
+ )} +
+ )} +
+ ) +} diff --git a/src/components/PvpRoguelikeScreen.tsx b/src/components/PvpRoguelikeScreen.tsx index fd6ac34..9387ed5 100644 --- a/src/components/PvpRoguelikeScreen.tsx +++ b/src/components/PvpRoguelikeScreen.tsx @@ -9,7 +9,7 @@ import { type PartyMember, type Spell, } from '../game' -import { completeRoguelike, type DungeonReward } from '../profile' +import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile' import type { CharacterProfile, DungeonEncounter } from '../profile' import type { GameMode } from '../gameRepository' import { PartyMemberFrame } from './PartyFrames' @@ -362,6 +362,7 @@ export function PvPRoguelikeScreen({ const nextLogId = useRef(2) const elapsedTicksRef = useRef(0) const recordedRunRef = useRef(false) + const matchStatsRecordedRef = useRef(false) const rewardClaimedRef = useRef(false) const matchWinRewardClaimedRef = useRef(false) const bossRewardClaimedRef = useRef(new Set()) @@ -538,6 +539,12 @@ export function PvPRoguelikeScreen({ 'loot', ) } + if (result.petAwarded) { + addLog( + `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`, + 'loot', + ) + } }) .catch((reason: unknown) => { setRewardError( @@ -654,6 +661,7 @@ export function PvPRoguelikeScreen({ pendingLiveUpgradeRef.current = null loggedOpponentDoneRef.current = false recordedRunRef.current = false + matchStatsRecordedRef.current = false rewardClaimedRef.current = false matchWinRewardClaimedRef.current = false cpuDefeatedRef.current = false @@ -711,6 +719,7 @@ export function PvPRoguelikeScreen({ pendingLiveUpgradeRef.current = null loggedOpponentDoneRef.current = false recordedRunRef.current = false + matchStatsRecordedRef.current = false rewardClaimedRef.current = false matchWinRewardClaimedRef.current = false cpuDefeatedRef.current = false @@ -1096,6 +1105,20 @@ export function PvPRoguelikeScreen({ }) }, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status]) + useEffect(() => { + if (status !== 'won' && status !== 'lost') return + if (matchStatsRecordedRef.current) return + matchStatsRecordedRef.current = true + recordPvpMatch(status === 'won') + .then(onProfileUpdated) + .catch((reason: unknown) => { + addLog( + reason instanceof Error ? reason.message : 'Unable to record PvP match.', + 'danger', + ) + }) + }, [addLog, onProfileUpdated, status]) + useEffect(() => { if (status !== 'upgrade-choice') return window.requestAnimationFrame(() => focusFirstControl()) diff --git a/src/components/PvpStadiumScreen.tsx b/src/components/PvpStadiumScreen.tsx index c908160..80dd309 100644 --- a/src/components/PvpStadiumScreen.tsx +++ b/src/components/PvpStadiumScreen.tsx @@ -7,7 +7,7 @@ import { type PartyMember, type Spell, } from '../game' -import { completeRoguelike } from '../profile' +import { completeRoguelike, recordPvpMatch } from '../profile' import type { CharacterProfile } from '../profile' import type { GameMode } from '../gameRepository' import { PartyMemberFrame } from './PartyFrames' @@ -391,6 +391,7 @@ export function PvpStadiumScreen({ const nextLogId = useRef(2) const submittedShopRef = useRef(false) const awardedXpRef = useRef(new Set()) + const matchStatsRecordedRef = useRef(false) const queuedMatchRef = useRef(false) const roundResolvedRef = useRef(false) const loggedOpponentRoundRef = useRef('') @@ -505,6 +506,7 @@ export function PvpStadiumScreen({ queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() + matchStatsRecordedRef.current = false roundResolvedRef.current = false setPlayerSide(setup.playerSide) setCpuSide(setup.opponentSide) @@ -541,6 +543,7 @@ export function PvpStadiumScreen({ queuedMatchRef.current = true nextLogId.current = 2 awardedXpRef.current = new Set() + matchStatsRecordedRef.current = false roundResolvedRef.current = false setPlayerSide(setup.playerSide) setCpuSide(setup.opponentSide) @@ -882,6 +885,20 @@ export function PvpStadiumScreen({ return () => window.clearInterval(timer) }, [advanceBoss, cpuTakeTurn, finishRound, paused, status]) + useEffect(() => { + if (status !== 'won' && status !== 'lost') return + if (matchStatsRecordedRef.current) return + matchStatsRecordedRef.current = true + recordPvpMatch(status === 'won') + .then(onProfileUpdated) + .catch((reason: unknown) => { + addLog( + reason instanceof Error ? reason.message : 'Unable to record PvP match.', + 'danger', + ) + }) + }, [addLog, onProfileUpdated, status]) + const startNextRound = useCallback(() => { const nextRound = roundIndex + 1 const nextPlayer = createStadiumStarterSide({ diff --git a/src/components/RewardPanels.tsx b/src/components/RewardPanels.tsx index cddb341..03f33cd 100644 --- a/src/components/RewardPanels.tsx +++ b/src/components/RewardPanels.tsx @@ -100,14 +100,15 @@ export function LootRollList({ return (
{rolls.map((roll) => ( -
+
{roll.encounterName} - {roll.items.length > 0 - ? roll.items - .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`) - .join(', ') - : 'No components dropped'} + {[ + ...roll.items.map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`), + ...(roll.petAwarded + ? [`* ${roll.petAwarded.petName}${roll.petAwarded.duplicate ? ` (owned x${roll.petAwarded.quantityAfter})` : ''}`] + : []), + ].join(', ') || 'No components dropped'}
))} diff --git a/src/dualScreen.tsx b/src/dualScreen.tsx index 7332bdc..0035ffa 100644 --- a/src/dualScreen.tsx +++ b/src/dualScreen.tsx @@ -84,7 +84,7 @@ export type DualScreenCombatState = { } export type DualScreenWorkshopState = { - mode: 'class' | 'equipment' | 'crafting' | 'talents' + mode: 'class' | 'equipment' | 'crafting' | 'talents' | 'collection' title: string subtitle: string summary?: string diff --git a/src/gameRepository.ts b/src/gameRepository.ts index e426f5f..9f4a864 100644 --- a/src/gameRepository.ts +++ b/src/gameRepository.ts @@ -17,6 +17,7 @@ import type { LootRoll, Item, EquipmentSlot, + PetAward, } from './profile' export type GameMode = 'online' | 'offline' @@ -64,6 +65,14 @@ export interface GameRepository { difficultyId: number, runToken: string, ): Promise + recordBossKill( + encounterId: number, + options?: { petVariant?: 'normal' | 'purple' }, + ): Promise<{ + profile: CharacterProfile + petAwarded: PetAward | null + }> + recordPvpMatch(won: boolean): Promise } type CharacterData = { @@ -76,7 +85,7 @@ type CharacterData = { } type OfflineSave = { - version: 3 + version: 4 characterName: string activeClassId: number completedDungeonParts: number @@ -84,6 +93,10 @@ type OfflineSave = { dungeonCompletions?: Record characters: Record lootRolls: Record + bossKills: Record + bossPets: Record + pvpMatchesPlayed: number + pvpMatchesWon: number } type OnlineCache = { @@ -124,6 +137,7 @@ const catalogBundleKey = 'chronicle.catalog.bundleHash.v1' const authTokenKey = 'chronicle.authToken.v1' const offlineAccount = { id: -1, username: 'Offline' } const ABILITY_SLOT_COUNT = 6 +const BOSS_PET_DROP_RATE = 1 / 500 let activeCatalogCache: CatalogCache | null = null function clone(value: T): T { @@ -175,7 +189,7 @@ function upgradeV1Save(v1: { profile: CharacterProfile; lootRolls: Record & { version: 2 }): OfflineSave { +function upgradeV2Save(v2: Omit & { version: 2 }): OfflineSave { return normalizeSaveAbilitySlots({ ...v2, - version: 3, + version: 4, completedRaidPhases: 0, + bossKills: {}, + bossPets: {}, + pvpMatchesPlayed: 0, + pvpMatchesWon: 0, + }) +} + +function upgradeV3Save(v3: Omit & { version: 3 }): OfflineSave { + return normalizeSaveAbilitySlots({ + ...v3, + version: 4, + bossKills: {}, + bossPets: {}, + pvpMatchesPlayed: 0, + pvpMatchesWon: 0, }) } @@ -224,9 +257,12 @@ function normalizeOfflineSave(raw: unknown): OfflineSave | null { profile?: CharacterProfile lootRolls?: Record } - if (candidate.version === 3) return normalizeSaveAbilitySlots(candidate as OfflineSave) + if (candidate.version === 4) return normalizeSaveAbilitySlots(candidate as OfflineSave) + if (candidate.version === 3) { + return upgradeV3Save(candidate as Omit & { version: 3 }) + } if (candidate.version === 2) { - return upgradeV2Save(candidate as Omit & { version: 2 }) + return upgradeV2Save(candidate as Omit & { version: 2 }) } if (candidate.version === 1 && candidate.profile) { return normalizeSaveAbilitySlots(upgradeV1Save(candidate as { profile: CharacterProfile; lootRolls: Record })) @@ -241,7 +277,7 @@ function readSaveKey(key: string): OfflineSave | null { const raw = JSON.parse(serialized) const save = normalizeOfflineSave(raw) if (!save) return null - if (raw.version !== 3) { + if (raw.version !== 4) { localStorage.setItem(key, JSON.stringify(save)) } return save @@ -284,7 +320,7 @@ function readOnlineCache(): OnlineCache | null { save, dirty: Boolean(raw.dirty), } - if ((raw.save as { version?: number } | undefined)?.version !== 3) { + if ((raw.save as { version?: number } | undefined)?.version !== 4) { writeOnlineCache(cache) } return cache @@ -380,6 +416,12 @@ function buildProfile(save: OfflineSave): CharacterProfile { ...dungeon, completionCount: save.dungeonCompletions?.[String(dungeon.id)] ?? dungeon.completionCount ?? 0, })) + static_.hunterStats = { + bossKills: clone(save.bossKills ?? {}), + bossPets: clone(save.bossPets ?? {}), + pvpMatchesPlayed: save.pvpMatchesPlayed ?? 0, + pvpMatchesWon: save.pvpMatchesWon ?? 0, + } return static_ } @@ -547,7 +589,7 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS inventory: clone(profile.inventory), } return { - version: 3, + version: 4, characterName: profile.character.name, activeClassId: profile.character.classId, completedDungeonParts: profile.completedDungeonParts, @@ -557,6 +599,10 @@ function mergeProfileIntoSave(profile: CharacterProfile, existingSave?: OfflineS ), characters, lootRolls: clone(existingSave?.lootRolls ?? {}), + bossKills: clone(profile.hunterStats?.bossKills ?? existingSave?.bossKills ?? {}), + bossPets: clone(profile.hunterStats?.bossPets ?? existingSave?.bossPets ?? {}), + pvpMatchesPlayed: profile.hunterStats?.pvpMatchesPlayed ?? existingSave?.pvpMatchesPlayed ?? 0, + pvpMatchesWon: profile.hunterStats?.pvpMatchesWon ?? existingSave?.pvpMatchesWon ?? 0, } } @@ -570,6 +616,50 @@ function rollWeightedLootEntry(entries: T[]): return entries[entries.length - 1] } +function bossPetName(encounterName: string) { + return `${encounterName} Pet` +} + +function purpleBossPetName(encounterName: string) { + return `Purple ${encounterName} Pet` +} + +function maybeAwardBossPet( + save: OfflineSave, + encounter: { id: number; enemyName: string }, + variant: 'normal' | 'purple' = 'normal', +): PetAward | null { + if (Math.random() >= BOSS_PET_DROP_RATE) return null + const key = variant === 'purple' ? `purple:${encounter.id}` : String(encounter.id) + const previousQuantity = save.bossPets?.[key] ?? 0 + save.bossPets = { + ...(save.bossPets ?? {}), + [key]: previousQuantity + 1, + } + return { + encounterId: encounter.id, + petName: variant === 'purple' + ? purpleBossPetName(encounter.enemyName) + : bossPetName(encounter.enemyName), + quantity: 1, + duplicate: previousQuantity > 0, + quantityAfter: previousQuantity + 1, + } +} + +function recordBossKillInSave( + save: OfflineSave, + encounter: { id: number; enemyName: string }, + options?: { petVariant?: 'normal' | 'purple' }, +): PetAward | null { + const key = String(encounter.id) + save.bossKills = { + ...(save.bossKills ?? {}), + [key]: (save.bossKills?.[key] ?? 0) + 1, + } + return maybeAwardBossPet(save, encounter, options?.petVariant ?? 'normal') +} + function awardRoguelikeCoin( profile: CharacterProfile, sourceEncounterId: number | undefined, @@ -885,6 +975,10 @@ const serverRepository: GameRepository = { cachedOnlineLocalRepository.upgradeItem(itemId), rollEncounterLoot: (encounterId, difficultyId, runToken) => cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), + recordBossKill: (encounterId, options) => + cachedOnlineLocalRepository.recordBossKill(encounterId, options), + recordPvpMatch: (won) => + cachedOnlineLocalRepository.recordPvpMatch(won), } function emptyCharacterData(classId: number): CharacterData { @@ -1172,6 +1266,17 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { profile.maxTalentPoints, cd.talentPoints + levelsGained, ) + let petAwarded: PetAward | null = null + if (options?.lootSourceEncounterId) { + const sourceEncounter = profile.dungeons + .flatMap((candidate) => candidate.encounters) + .find((candidate) => candidate.id === options.lootSourceEncounterId && candidate.isBoss) + if (sourceEncounter) { + petAwarded = recordBossKillInSave(save, sourceEncounter, { + petVariant: 'purple', + }) + } + } const bonusItem = awardRoguelikeCoin( profile, options?.lootSourceEncounterId, @@ -1196,6 +1301,7 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { averageItemLevel: updatedProfile.gearStats.averageItemLevel, unlockedAbilities, bonusItem, + petAwarded, profile: updatedProfile, } }, @@ -1446,6 +1552,9 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { throw new Error('This encounter has no configured loot.') } const dropChance = entries[0].dropChance + const petAwarded = encounter.isBoss + ? recordBossKillInSave(save, encounter) + : null const items: LootRoll['items'] = [] const selectedQuantities = new Map() @@ -1497,12 +1606,36 @@ function createLocalRepository(store: LocalSaveStore): GameRepository { awarded: Boolean(item), duplicate: items.some((candidate) => candidate.duplicate), quantityAfter: item?.quantityAfter ?? 0, + petAwarded, } save.lootRolls[rollKey] = result save.characters[save.activeClassId].inventory = profile.inventory store.writeSave(save) return clone(result) }, + async recordBossKill(encounterId, options) { + const save = requireStoredSave(store) + const profile = buildProfile(save) + const encounter = profile.dungeons + .flatMap((dungeon) => dungeon.encounters) + .find((candidate) => candidate.id === encounterId && candidate.isBoss) + if (!encounter) throw new Error('Boss encounter not found.') + const petAwarded = recordBossKillInSave(save, encounter, { + petVariant: options?.petVariant ?? 'normal', + }) + store.writeSave(save) + return { + profile: buildProfile(save), + petAwarded, + } + }, + async recordPvpMatch(won) { + const save = requireStoredSave(store) + save.pvpMatchesPlayed = (save.pvpMatchesPlayed ?? 0) + 1 + if (won) save.pvpMatchesWon = (save.pvpMatchesWon ?? 0) + 1 + store.writeSave(save) + return buildProfile(save) + }, } } @@ -1581,6 +1714,10 @@ const cachedOnlineRepository: GameRepository = { upgradeItem: (itemId) => cachedOnlineLocalRepository.upgradeItem(itemId), rollEncounterLoot: (encounterId, difficultyId, runToken) => cachedOnlineLocalRepository.rollEncounterLoot(encounterId, difficultyId, runToken), + recordBossKill: (encounterId, options) => + cachedOnlineLocalRepository.recordBossKill(encounterId, options), + recordPvpMatch: (won) => + cachedOnlineLocalRepository.recordPvpMatch(won), } export function getGameMode(): GameMode { @@ -1627,13 +1764,17 @@ export function createOfflineCharacter(characterName: string): AuthSession { characters[cid] = emptyCharacterData(cid) } const save: OfflineSave = { - version: 3, + version: 4, characterName: name, activeClassId: 1, completedDungeonParts: 0, completedRaidPhases: 0, characters, lootRolls: {}, + bossKills: {}, + bossPets: {}, + pvpMatchesPlayed: 0, + pvpMatchesWon: 0, } writeOfflineSave(save) writeMode('offline-local') diff --git a/src/offline-catalog-meta.ts b/src/offline-catalog-meta.ts index 92e22df..15ca63f 100644 --- a/src/offline-catalog-meta.ts +++ b/src/offline-catalog-meta.ts @@ -1 +1 @@ -export const bundledCatalogHash = '07506c52bab428c439f09a9b82e39e6eff2b243fb972d664da48852059bcd937' +export const bundledCatalogHash = '2bc6b08d7c902e8f47d74cd0b8c43f5962c1a583cb1f8b42f1ece2b512ad8991' diff --git a/src/offline-starter-profile.json b/src/offline-starter-profile.json index cd27766..70f6f09 100644 --- a/src/offline-starter-profile.json +++ b/src/offline-starter-profile.json @@ -6905,5 +6905,11 @@ "full_run": [] } } - ] + ], + "hunterStats": { + "bossKills": {}, + "bossPets": {}, + "pvpMatchesPlayed": 0, + "pvpMatchesWon": 0 + } } diff --git a/src/profile.ts b/src/profile.ts index 4764267..c2424e1 100644 --- a/src/profile.ts +++ b/src/profile.ts @@ -145,6 +145,14 @@ export type LootRollItem = Omit & { quantityAfter: number } +export type PetAward = { + encounterId: number + petName: string + quantity: number + duplicate: boolean + quantityAfter: number +} + export type LootRoll = { encounterId: number encounterName: string @@ -157,6 +165,7 @@ export type LootRoll = { awarded: boolean duplicate: boolean quantityAfter: number + petAwarded?: PetAward | null } export type Dungeon = { @@ -232,6 +241,12 @@ export type CharacterProfile = { craftingRecipes: CraftingRecipe[] gearUpgradePaths: GearUpgradePath[] dungeons: Dungeon[] + hunterStats: { + bossKills: Record + bossPets: Record + pvpMatchesPlayed: number + pvpMatchesWon: number + } } export type Account = { @@ -280,6 +295,7 @@ export type DungeonReward = { glyph: string }> bonusItem: BonusItem | null + petAwarded?: PetAward | null profile: CharacterProfile } @@ -365,6 +381,20 @@ export async function completeRoguelike( ) } +export async function recordBossKill( + encounterId: number, + options?: { petVariant?: 'normal' | 'purple' }, +): Promise<{ + profile: CharacterProfile + petAwarded: PetAward | null +}> { + return activeGameRepository().recordBossKill(encounterId, options) +} + +export async function recordPvpMatch(won: boolean): Promise { + return activeGameRepository().recordPvpMatch(won) +} + export async function allocateTalent(talentId: number): Promise { return activeGameRepository().allocateTalent(talentId) }