diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 05071ac..b1ca533 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -135,7 +135,9 @@ "mcp__claude_ai_Context7__resolve-library-id", "mcp__claude_ai_Context7__query-docs", "Bash(npm show:*)", - "Bash(git:*)" + "Bash(git:*)", + "Bash(cp:*)", + "Bash(/home/mozyonee/Projects/widgetable/apps/server/src/storage/storage.config.ts:*)" ], "deny": [], "ask": [] diff --git a/apps/client/public/sw.js b/apps/client/public/sw.js index 8e8ba21..7f51156 100644 --- a/apps/client/public/sw.js +++ b/apps/client/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const a=(a,i)=>(a=new URL(a+".js",i).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(i,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>a(e,n),d={module:{uri:n},exports:c,require:r};s[n]=Promise.all(i.map(e=>d[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-a41e51f5"],function(e){"use strict";importScripts("/push-sw.js"),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/369-6b0b57d2160535cd.js",revision:"6b0b57d2160535cd"},{url:"/_next/static/chunks/523-367f7faddea50012.js",revision:"367f7faddea50012"},{url:"/_next/static/chunks/636-e4a70979177dee32.js",revision:"e4a70979177dee32"},{url:"/_next/static/chunks/642-b86e7ae6e7d6a349.js",revision:"b86e7ae6e7d6a349"},{url:"/_next/static/chunks/652-95cbeb5beae6e21b.js",revision:"95cbeb5beae6e21b"},{url:"/_next/static/chunks/746-779741b4e2698125.js",revision:"779741b4e2698125"},{url:"/_next/static/chunks/782-66c8b25ee45ada2c.js",revision:"66c8b25ee45ada2c"},{url:"/_next/static/chunks/87c73c54-4584b83e8281e545.js",revision:"4584b83e8281e545"},{url:"/_next/static/chunks/968-8ed2aea14a0ac4b2.js",revision:"8ed2aea14a0ac4b2"},{url:"/_next/static/chunks/app/(pages)/account/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/account/page-2fbae0083b7dc14d.js",revision:"2fbae0083b7dc14d"},{url:"/_next/static/chunks/app/(pages)/auth/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/auth/page-a3893bff78da88a5.js",revision:"a3893bff78da88a5"},{url:"/_next/static/chunks/app/(pages)/friends/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/friends/page-3d2caa8400b2ede2.js",revision:"3d2caa8400b2ede2"},{url:"/_next/static/chunks/app/(pages)/layout-f852056f116289ee.js",revision:"f852056f116289ee"},{url:"/_next/static/chunks/app/(pages)/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/page-f3356bafa90086e3.js",revision:"f3356bafa90086e3"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/page-4f0d11d7c9308510.js",revision:"4f0d11d7c9308510"},{url:"/_next/static/chunks/app/_global-error/page-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/_not-found/page-600014be023479d4.js",revision:"600014be023479d4"},{url:"/_next/static/chunks/app/layout-301d0681551a315d.js",revision:"301d0681551a315d"},{url:"/_next/static/chunks/app/pwa/page-c4e32ec364eb42be.js",revision:"c4e32ec364eb42be"},{url:"/_next/static/chunks/framework-95999e7820816f6d.js",revision:"95999e7820816f6d"},{url:"/_next/static/chunks/main-059076a541984aa4.js",revision:"059076a541984aa4"},{url:"/_next/static/chunks/main-app-62d61c46dc5e7d2a.js",revision:"62d61c46dc5e7d2a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-828819635e256b84.js",revision:"828819635e256b84"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-720e2e794d6522b8.js",revision:"720e2e794d6522b8"},{url:"/_next/static/css/a2a2710216ac4a71.css",revision:"a2a2710216ac4a71"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/auth",revision:"WO2sxNmWG3ki95APUVvrI"},{url:"/icon-192x192.png",revision:"651c40c4307dd375ef0b1d433bba2608"},{url:"/manifest.json",revision:"1934748cd753b835e3f8931cfd62bcab"},{url:"/push-sw.js",revision:"77a72c825e12dcf8f82553e26aa648e5"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:i})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/\.(png|jpg|jpeg|webp|gif|svg)$/i,new e.CacheFirst({cacheName:"images",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/users\/.*\/picture/,new e.StaleWhileRevalidate({cacheName:"profile-pictures",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:604800})]}),"GET")}); +if(!self.define){let e,s={};const a=(a,i)=>(a=new URL(a+".js",i).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(i,t)=>{const n=e||("document"in self?document.currentScript.src:"")||location.href;if(s[n])return;let c={};const r=e=>a(e,n),b={module:{uri:n},exports:c,require:r};s[n]=Promise.all(i.map(e=>b[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-a41e51f5"],function(e){"use strict";importScripts("/push-sw.js"),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/static/chunks/369-6b0b57d2160535cd.js",revision:"6b0b57d2160535cd"},{url:"/_next/static/chunks/523-367f7faddea50012.js",revision:"367f7faddea50012"},{url:"/_next/static/chunks/636-e4a70979177dee32.js",revision:"e4a70979177dee32"},{url:"/_next/static/chunks/642-1099a1704070643b.js",revision:"1099a1704070643b"},{url:"/_next/static/chunks/652-95cbeb5beae6e21b.js",revision:"95cbeb5beae6e21b"},{url:"/_next/static/chunks/67-906b5ac132a903b9.js",revision:"906b5ac132a903b9"},{url:"/_next/static/chunks/746-779741b4e2698125.js",revision:"779741b4e2698125"},{url:"/_next/static/chunks/87c73c54-4584b83e8281e545.js",revision:"4584b83e8281e545"},{url:"/_next/static/chunks/968-8ed2aea14a0ac4b2.js",revision:"8ed2aea14a0ac4b2"},{url:"/_next/static/chunks/app/(pages)/account/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/account/page-59ddcbeeac4f8441.js",revision:"59ddcbeeac4f8441"},{url:"/_next/static/chunks/app/(pages)/auth/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/auth/page-a3893bff78da88a5.js",revision:"a3893bff78da88a5"},{url:"/_next/static/chunks/app/(pages)/friends/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/friends/page-3d2caa8400b2ede2.js",revision:"3d2caa8400b2ede2"},{url:"/_next/static/chunks/app/(pages)/layout-f852056f116289ee.js",revision:"f852056f116289ee"},{url:"/_next/static/chunks/app/(pages)/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/page-f3356bafa90086e3.js",revision:"f3356bafa90086e3"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/loading-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/(pages)/pet/%5Bid%5D/page-547ab5460e71e58b.js",revision:"547ab5460e71e58b"},{url:"/_next/static/chunks/app/_global-error/page-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/app/_not-found/page-600014be023479d4.js",revision:"600014be023479d4"},{url:"/_next/static/chunks/app/layout-1761d832b4871ab0.js",revision:"1761d832b4871ab0"},{url:"/_next/static/chunks/app/pwa/page-c4e32ec364eb42be.js",revision:"c4e32ec364eb42be"},{url:"/_next/static/chunks/framework-95999e7820816f6d.js",revision:"95999e7820816f6d"},{url:"/_next/static/chunks/main-059076a541984aa4.js",revision:"059076a541984aa4"},{url:"/_next/static/chunks/main-app-62d61c46dc5e7d2a.js",revision:"62d61c46dc5e7d2a"},{url:"/_next/static/chunks/next/dist/client/components/builtin/app-error-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/forbidden-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/global-error-828819635e256b84.js",revision:"828819635e256b84"},{url:"/_next/static/chunks/next/dist/client/components/builtin/not-found-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/next/dist/client/components/builtin/unauthorized-0cd28987998b27ab.js",revision:"0cd28987998b27ab"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-720e2e794d6522b8.js",revision:"720e2e794d6522b8"},{url:"/_next/static/css/a2a2710216ac4a71.css",revision:"a2a2710216ac4a71"},{url:"/_next/static/media/4cf2300e9c8272f7-s.p.woff2",revision:"18bae71b1e1b2bb25321090a3b563103"},{url:"/_next/static/media/747892c23ea88013-s.woff2",revision:"a0761690ccf4441ace5cec893b82d4ab"},{url:"/_next/static/media/8d697b304b401681-s.woff2",revision:"cc728f6c0adb04da0dfcb0fc436a8ae5"},{url:"/_next/static/media/93f479601ee12b01-s.p.woff2",revision:"da83d5f06d825c5ae65b7cca706cb312"},{url:"/_next/static/media/9610d9e46709d722-s.woff2",revision:"7b7c0ef93df188a852344fc272fc096b"},{url:"/_next/static/media/ba015fad6dcf6784-s.woff2",revision:"8ea4f719af3312a055caf09f34c89a77"},{url:"/auth",revision:"SmC_IgqAkgyZVdYLF82iW"},{url:"/icon-192x192.png",revision:"651c40c4307dd375ef0b1d433bba2608"},{url:"/manifest.json",revision:"1934748cd753b835e3f8931cfd62bcab"},{url:"/push-sw.js",revision:"77a72c825e12dcf8f82553e26aa648e5"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:i})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/\.(png|jpg|jpeg|webp|gif|svg)$/i,new e.CacheFirst({cacheName:"images",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\/users\/.*\/picture/,new e.StaleWhileRevalidate({cacheName:"profile-pictures",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:604800})]}),"GET")}); diff --git a/apps/client/src/app/(pages)/account/page.tsx b/apps/client/src/app/(pages)/account/page.tsx index 615bcaa..5640dc0 100644 --- a/apps/client/src/app/(pages)/account/page.tsx +++ b/apps/client/src/app/(pages)/account/page.tsx @@ -4,9 +4,9 @@ import { Button, InputTextHidden } from '@/components/ui/Button'; import { Skeleton } from '@/components/ui/Skeleton'; import { useAuth } from '@/features/auth/hooks/useAuth'; import { setUserData } from '@/features/auth/slices/userSlice'; -import { ClaimButton } from '@/features/claims/components/ClaimButton'; -import { RewardsModal } from '@/features/claims/components/RewardsModal'; -import { useClaims } from '@/features/claims/hooks/useClaims'; +import { ItemButton } from '@/features/items/components/ItemButton'; +import { ItemsModal } from '@/features/items/components/ItemsModal'; +import { useItems } from '@/features/items/hooks/useItems'; import { usePushNotifications } from '@/features/notifications/hooks/usePushNotifications'; import { useTranslation } from '@/i18n/hooks/useTranslation'; import api from '@/lib/api'; @@ -14,7 +14,7 @@ import { useImagesLoaded } from '@/lib/hooks/useImagesLoaded'; import { callError, callSuccess } from '@/lib/toast'; import { useAppDispatch, useAppSelector } from '@/store'; import { Camera, Power, User as UserIcon } from '@nsmr/pixelart-react'; -import { LANGUAGES, User } from '@widgetable/types'; +import { ClaimType, LANGUAGES, User } from '@widgetable/types'; import Image from 'next/image'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -49,7 +49,7 @@ const Account = () => { const dispatch = useAppDispatch(); const user = useAppSelector((state) => state.user.userData); const { t, language, setLanguage } = useTranslation(); - const { claimStatus, claimingType, lastRewards, claimDaily, claimQuick, closeRewardsModal } = useClaims(); + const { itemStatus, claimingType, lastItems, claimDaily, claimQuick, closeItemsModal } = useItems(); const { isSupported, isSubscribed, @@ -203,20 +203,20 @@ const Account = () => {

{t('account.carePackages')}

{t('account.carePackagesDesc')}

- {claimStatus && ( + {itemStatus && (
- void claimDaily()} /> - void claimQuick()} />
@@ -283,7 +283,7 @@ const Account = () => { )} - {lastRewards && } + {lastItems && } ); }; diff --git a/apps/client/src/app/(pages)/loading.tsx b/apps/client/src/app/(pages)/loading.tsx index 06110b5..3457e37 100644 --- a/apps/client/src/app/(pages)/loading.tsx +++ b/apps/client/src/app/(pages)/loading.tsx @@ -4,7 +4,7 @@ export default function Loading() { return (
-
+
diff --git a/apps/client/src/app/globals.css b/apps/client/src/app/globals.css index 203d3ea..89531e6 100644 --- a/apps/client/src/app/globals.css +++ b/apps/client/src/app/globals.css @@ -64,6 +64,9 @@ --color-accent: var(--accent); --color-danger: var(--danger); --color-success: var(--success); + --color-tier-common: #22c55e; + --color-tier-premium: #3b82f6; + --color-tier-legendary: #a855f7; --font-sans: 'Basis33', ui-sans-serif, system-ui, sans-serif; --font-mono: 'Basis33', ui-monospace, monospace; @@ -124,6 +127,13 @@ a { cursor: pointer; } +img { + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + pointer-events: none; +} + .bounce-squash { animation: bounceSquash 1s ease-in-out infinite; } \ No newline at end of file diff --git a/apps/client/src/data/actionSprites.ts b/apps/client/src/data/actionSprites.ts deleted file mode 100644 index 9876daf..0000000 --- a/apps/client/src/data/actionSprites.ts +++ /dev/null @@ -1,80 +0,0 @@ -export const ACTION_SPRITES: Record = { - // Feed Actions - Sushi: 'food/food/sushi_rolls.png', - Hamburger: 'food/food/hamburger.png', - Lasagna: 'food/food/lasagna.png', - 'Bento Box': 'food/food/bento_box.png', - Pizza: 'food/food/pizza_slice.png', - Ramen: 'food/food/ramen_bowl.png', - Taco: 'food/food/taco.png', - Burrito: 'food/food/burrito.png', - Sandwich: 'food/food/sandwich.png', - 'Hot Dog': 'food/food/hot_dog.png', - Soup: 'food/food/tomato_soup_bowl.png', - 'Rice Bowl': 'food/food/rice_bowl.png', - 'Mac & Cheese': 'food/food/mac_and_cheese_bowl.png', - Watermelon: 'food/fruits/watermelon_slice.png', - Mango: 'food/fruits/mango.png', - Strawberry: 'food/plants/strawberry.png', - Cookie: 'food/desserts/chocolate_chip_cookies.png', - Brownie: 'food/desserts/brownie.png', - Donut: 'food/desserts/glazed_donut.png', - 'French Fries': 'food/food/french_fries.png', - - // Drink Actions - 'Orange Juice': 'food/desserts/orange_juice.png', - 'Apple Cider': 'food/desserts/apple_cider_mug.png', - 'Fruit Parfait': 'food/food/fruit_parfait_cup.png', - Latte: 'food/desserts/latte.png', - Milk: 'food/food/milk_carton.png', - 'Hot Cocoa': 'food/desserts/cocoa_mug.png', - 'Fruit Tea': 'food/desserts/tea_cup.png', - Cappuccino: 'food/desserts/cappuccino_mug.png', - - // Wash Actions - 'Bath Salts': 'bathroom/bath_salts_jar.png', - Bathtub: 'bathroom/bathtub.png', - Shampoo: 'bathroom/shampoo_bottle.png', - Shower: 'bathroom/shower_stall.png', - 'Mouth Wash': 'bathroom/mouth_wash_bottle.png', - 'Hand Soap': 'bathroom/hand_soap_pump.png', - Brush: 'bathroom/toothbrush.png', - 'Wipe Down': 'bathroom/bath_sponge.png', - Sponge: 'bathroom/natural_sponge.png', - - // Valentine Items - Chocolates - 'Chocolate Bar Red': '/valentine/chocolate_bar_red.png', - 'Chocolate Bar Brown': '/valentine/chocolate_bar_brown.png', - 'Chocolate Bar Blue': '/valentine/chocolate_bar_blue.png', - 'Chocolate Bar White': '/valentine/chocolate_bar_white.png', - 'Chocolate Bar Duo': '/valentine/chocolate_bar_red_blue.png', - Chocolate: '/valentine/chocolate.png', - - // Valentine Items - Letters & Envelopes - 'Letter Sealed Red Heart': '/valentine/letter_sealed_red_heart.png', - 'Letter Sealed Red Border': '/valentine/letter_sealed_red_border.png', - 'Letter Sealed Pink': '/valentine/letter_sealed_pink.png', - 'Letter Sealed Small Heart': '/valentine/letter_sealed_small_heart.png', - 'Letter Sealed Orange': '/valentine/letter_sealed_orange.png', - 'Letter Sealed Pink Heart': '/valentine/letter_sealed_pink_heart.png', - 'Letter Sealed Brown': '/valentine/letter_sealed_brown.png', - 'Letter Sealed Amber': '/valentine/letter_sealed_amber.png', - 'Envelope White': '/valentine/envelope_white.png', - 'Envelope Pink': '/valentine/envelope_pink.png', - - // Care Actions - Toilet: 'bathroom/toilet.png', - 'Quick Potty': 'bathroom/toilet_paper_roll.png', - 'Long Sleep': 'bedroom/blue_bed_horizontal.png', - Nap: 'bedroom/white_pillow.png', - Rest: 'bedroom/folded_blanket.png', - 'Grooming Session': 'bathroom/hair_brush.png', - 'Nail Trim': 'bathroom/nail_file.png', - 'Ear Cleaning': 'bathroom/cotton_swab_jar.png', -}; - -export const getActionSprite = (actionName: string): string | undefined => { - const sprite = ACTION_SPRITES[actionName]; - if (!sprite) return undefined; - return sprite.startsWith('/') ? sprite : `/assets_new/${sprite}`; -}; diff --git a/apps/client/src/data/petScales.ts b/apps/client/src/data/petScales.ts deleted file mode 100644 index 65126d0..0000000 --- a/apps/client/src/data/petScales.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PET_SCALES, PetType } from '@widgetable/types'; - -export const getPetScale = (petType: PetType | 'egg'): number => { - return PET_SCALES[petType] ?? 1.0; -}; diff --git a/apps/client/src/features/auth/hooks/useAuth.ts b/apps/client/src/features/auth/hooks/useAuth.ts index a48927e..dc5f826 100644 --- a/apps/client/src/features/auth/hooks/useAuth.ts +++ b/apps/client/src/features/auth/hooks/useAuth.ts @@ -1,7 +1,7 @@ 'use client'; import { logout, setAuthenticated, setUserData } from '@/features/auth/slices/userSlice'; -import { clearClaims } from '@/features/claims/slices/claimsSlice'; +import { clearItems } from '@/features/items/slices/itemsSlice'; import { clearPets } from '@/features/pets/slices/petsSlice'; import api from '@/lib/api'; import { persistor, useAppDispatch, useAppSelector } from '@/store'; @@ -37,7 +37,7 @@ export const useAuth = () => { const handleLogout = async () => { dispatch(clearPets()); - dispatch(clearClaims()); + dispatch(clearItems()); dispatch(logout()); await persistor.purge(); router.push('/auth'); diff --git a/apps/client/src/features/claims/hooks/useClaims.ts b/apps/client/src/features/claims/hooks/useClaims.ts deleted file mode 100644 index a4218b7..0000000 --- a/apps/client/src/features/claims/hooks/useClaims.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser'; -import { setClaimStatus as setClaimStatusAction } from '@/features/claims/slices/claimsSlice'; -import { useTranslation } from '@/i18n/hooks/useTranslation'; -import api, { isAbortError } from '@/lib/api'; -import { usePolling } from '@/lib/hooks/usePolling'; -import { callError } from '@/lib/toast'; -import { useAppDispatch, useAppSelector } from '@/store'; -import { ClaimResult, ClaimStatus } from '@widgetable/types'; -import { useCallback, useEffect, useState } from 'react'; - -export const useClaims = () => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const claimStatus = useAppSelector((state) => state.claims.claimStatus); - const loaded = useAppSelector((state) => state.claims.loaded); - const refreshUser = useRefreshUser(); - - const [claimingType, setClaimingType] = useState<'daily' | 'quick' | null>(null); - const [lastRewards, setLastRewards] = useState(null); - - const loadClaimStatus = useCallback(async () => { - try { - const response = await api.get('/claims/status'); - dispatch(setClaimStatusAction(response.data)); - } catch (error: unknown) { - if (!isAbortError(error)) callError(t('claims.failedLoad')); - } - }, [dispatch]); - - const executeClaim = async (endpoint: string, type: 'daily' | 'quick') => { - if (claimingType) return; - - setClaimingType(type); - - try { - const response = await api.post(endpoint); - const result = response.data; - - setLastRewards(result); - await loadClaimStatus(); - - await refreshUser(); - } catch { - callError(t('claims.failedClaim')); - } finally { - setClaimingType(null); - } - }; - - const claimDaily = useCallback(async () => { - await executeClaim('/claims/daily', 'daily'); - }, [claimingType, loadClaimStatus, dispatch, refreshUser]); - - const claimQuick = useCallback(async () => { - await executeClaim('/claims/quick', 'quick'); - }, [claimingType, loadClaimStatus, dispatch, refreshUser]); - - useEffect(() => { - void loadClaimStatus(); - }, [loadClaimStatus]); - - usePolling(() => void loadClaimStatus(), 60000); - - return { - claimStatus, - claimingType, - lastRewards, - loading: !loaded, - claimDaily, - claimQuick, - refreshStatus: loadClaimStatus, - closeRewardsModal: () => setLastRewards(null), - }; -}; diff --git a/apps/client/src/features/claims/slices/claimsSlice.ts b/apps/client/src/features/claims/slices/claimsSlice.ts deleted file mode 100644 index 6e2fe33..0000000 --- a/apps/client/src/features/claims/slices/claimsSlice.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ClaimStatus } from '@widgetable/types'; - -interface ClaimsState { - claimStatus: ClaimStatus | null; - loaded: boolean; -} - -const initialState: ClaimsState = { - claimStatus: null, - loaded: false, -}; - -export const claimsSlice = createSlice({ - name: 'claims', - initialState, - reducers: { - setClaimStatus: (state, action: PayloadAction) => { - state.claimStatus = action.payload; - state.loaded = true; - }, - clearClaims: () => initialState, - }, -}); - -export const { setClaimStatus, clearClaims } = claimsSlice.actions; -export default claimsSlice.reducer; diff --git a/apps/client/src/features/friends/components/GiftModal.tsx b/apps/client/src/features/friends/components/GiftModal.tsx index a184f63..9f840cc 100644 --- a/apps/client/src/features/friends/components/GiftModal.tsx +++ b/apps/client/src/features/friends/components/GiftModal.tsx @@ -2,7 +2,7 @@ import { Modal } from '@/components/ui/Modal'; import { Skeleton } from '@/components/ui/Skeleton'; -import { getActionSprite } from '@/data/actionSprites'; +import { getItemSprite } from '@widgetable/types'; import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser'; import { useTranslation } from '@/i18n/hooks/useTranslation'; import api from '@/lib/api'; @@ -29,7 +29,7 @@ export const GiftModal = ({ isOpen, onClose, friend }: GiftModalProps) => { const giftableItems = VALENTINE_GIFT_ITEM_NAMES.filter((name) => (inventory[name] ?? 0) > 0).map((name) => ({ name, count: inventory[name], - sprite: getActionSprite(name), + sprite: getItemSprite(name), })); const spriteUrls = giftableItems.map((item) => item.sprite).filter(Boolean) as string[]; diff --git a/apps/client/src/features/claims/components/ClaimButton.tsx b/apps/client/src/features/items/components/ItemButton.tsx similarity index 61% rename from apps/client/src/features/claims/components/ClaimButton.tsx rename to apps/client/src/features/items/components/ItemButton.tsx index 038d6fa..f685086 100644 --- a/apps/client/src/features/claims/components/ClaimButton.tsx +++ b/apps/client/src/features/items/components/ItemButton.tsx @@ -4,32 +4,31 @@ import { ICON_SIZES } from '@/config/constants'; import { Button } from '@/components/ui/Button'; import { useTranslation } from '@/i18n/hooks/useTranslation'; import { Check, Clock } from '@nsmr/pixelart-react'; -import { ClaimTimer } from './ClaimTimer'; +import { ClaimType } from '@widgetable/types'; +import { ItemTimer } from './ItemTimer'; -interface ClaimButtonProps { - type: 'daily' | 'quick' | 'debug'; +interface ItemButtonProps { + type: ClaimType; available: boolean; - claimingType: 'daily' | 'quick' | 'debug' | null; - nextClaimTime?: Date; + claimingType: ClaimType | null; + nextItemTime?: Date; onClaim: () => void; } -export const ClaimButton = ({ type, available, claimingType, nextClaimTime, onClaim }: ClaimButtonProps) => { +export const ItemButton = ({ type, available, claimingType, nextItemTime, onClaim }: ItemButtonProps) => { const { t } = useTranslation(); const isClaiming = claimingType === type; const getButtonText = () => { - if (isClaiming) return t('claims.collecting'); + if (isClaiming) return t('items.collecting'); switch (type) { - case 'daily': - return available ? t('claims.dailyCarePackage') : t('claims.carePackage'); - case 'quick': - return available ? t('claims.quickCarePackage') : t('claims.carePackage'); - case 'debug': - return t('claims.debugClaim'); + case ClaimType.DAILY: + return available ? t('items.dailyCarePackage') : t('items.carePackage'); + case ClaimType.QUICK: + return available ? t('items.quickCarePackage') : t('items.carePackage'); default: - return t('claims.claimRewards'); + return t('items.claimItems'); } }; @@ -54,8 +53,8 @@ export const ClaimButton = ({ type, available, claimingType, nextClaimTime, onCl {!available && !isClaiming && (
- {t('claims.nextIn')} - + {t('items.nextIn')} +
)}
diff --git a/apps/client/src/features/claims/components/ClaimTimer.tsx b/apps/client/src/features/items/components/ItemTimer.tsx similarity index 53% rename from apps/client/src/features/claims/components/ClaimTimer.tsx rename to apps/client/src/features/items/components/ItemTimer.tsx index 82ba102..b64b847 100644 --- a/apps/client/src/features/claims/components/ClaimTimer.tsx +++ b/apps/client/src/features/items/components/ItemTimer.tsx @@ -2,14 +2,14 @@ import { useCountdown } from '@/lib/hooks/useCountdown'; -interface ClaimTimerProps { - nextClaimTime?: Date; +interface ItemTimerProps { + nextItemTime?: Date; } -export const ClaimTimer = ({ nextClaimTime }: ClaimTimerProps) => { - const { timeLeft, isReady } = useCountdown(nextClaimTime); +export const ItemTimer = ({ nextItemTime }: ItemTimerProps) => { + const { timeLeft, isReady } = useCountdown(nextItemTime); - if (!nextClaimTime) return null; + if (!nextItemTime) return null; const displayText = isReady ? 'Ready!' : timeLeft; diff --git a/apps/client/src/features/claims/components/RewardsModal.tsx b/apps/client/src/features/items/components/ItemsModal.tsx similarity index 56% rename from apps/client/src/features/claims/components/RewardsModal.tsx rename to apps/client/src/features/items/components/ItemsModal.tsx index 480af00..4fe5613 100644 --- a/apps/client/src/features/claims/components/RewardsModal.tsx +++ b/apps/client/src/features/items/components/ItemsModal.tsx @@ -2,39 +2,47 @@ import { Modal } from '@/components/ui/Modal'; import { ICON_SIZES } from '@/config/constants'; -import { getActionSprite } from '@/data/actionSprites'; +import { getItemSprite } from '@widgetable/types'; import { useTranslation } from '@/i18n/hooks/useTranslation'; import { Bed, Check, Coffee, Edit, Zap } from '@nsmr/pixelart-react'; -import { ClaimResult, EGG_ITEM_NAME, ItemReward, ItemTier } from '@widgetable/types'; +import { + ACTION_AMOUNT_BY_NAME, + EGG_ITEM_NAME, + getItemTier, + ItemResult, + ItemReward, + ItemTier, + VALENTINE_GIFT_ITEMS, +} from '@widgetable/types'; import Image from 'next/image'; -interface RewardsModalProps { - rewards: ClaimResult; +interface ItemsModalProps { + result: ItemResult; onClose: () => void; } -const getTierColor = (tier: ItemTier): string => { - switch (tier) { - case ItemTier.BASIC: - return 'border-gray-400'; - case ItemTier.COMMON: - return 'border-green-500'; - case ItemTier.PREMIUM: - return 'border-blue-500'; - case ItemTier.LEGENDARY: - return 'border-purple-500'; - default: - return 'border-gray-400'; - } +const VALENTINE_TIER_BY_NAME = new Map(VALENTINE_GIFT_ITEMS.map((item) => [item.name, item.tier])); + +const getItemTierByName = (name: string): ItemTier => { + const valentineTier = VALENTINE_TIER_BY_NAME.get(name); + if (valentineTier !== undefined) return valentineTier; + return getItemTier(ACTION_AMOUNT_BY_NAME[name] ?? 0); +}; + +const TIER_COLORS: Record = { + [ItemTier.BASIC]: 'border-inactive', + [ItemTier.COMMON]: 'border-tier-common', + [ItemTier.PREMIUM]: 'border-tier-premium', + [ItemTier.LEGENDARY]: 'border-tier-legendary', }; const ItemDisplay = ({ item, index }: { item: ItemReward; index: number }) => { const { t } = useTranslation(); - const spritePath = getActionSprite(item.name); + const spritePath = getItemSprite(item.name); return (
{spritePath && ( @@ -48,21 +56,25 @@ const ItemDisplay = ({ item, index }: { item: ItemReward; index: number }) => { ); }; -export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => { +export const ItemsModal = ({ result, onClose }: ItemsModalProps) => { const { t } = useTranslation(); - const sortedFood = [...rewards.rewards.food].sort((a, b) => b.tier - a.tier); - const sortedDrinks = [...rewards.rewards.drinks].sort((a, b) => b.tier - a.tier); - const sortedHygiene = [...rewards.rewards.hygiene].sort((a, b) => b.tier - a.tier); - const sortedCare = [...(rewards.rewards.care || [])].sort((a, b) => b.tier - a.tier); + const sortedFood = [...result.items.food].sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name)); + const sortedDrinks = [...result.items.drinks].sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name)); + const sortedHygiene = [...result.items.hygiene].sort( + (a, b) => getItemTierByName(b.name) - getItemTierByName(a.name), + ); + const sortedCare = [...(result.items.care || [])].sort( + (a, b) => getItemTierByName(b.name) - getItemTierByName(a.name), + ); return ( } - maxWidth="2xl" + maxWidth="sm" lockScroll preventTouchMove contentClassName="p-6 space-y-6" @@ -71,21 +83,21 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => { onClick={onClose} className="w-full bg-primary hover:bg-primary/90 text-white font-bold py-3 px-6 rounded-lg transition-colors" > - {t('rewards.awesome')} + {t('items.awesome')} } > - {rewards.rewards.food.length > 0 && ( + {result.items.food.length > 0 && (

- {t('rewards.food')} ( - {t('rewards.items', { - count: rewards.rewards.food.reduce((sum, item) => sum + item.quantity, 0), + {t('items.food')} ( + {t('items.count', { + count: result.items.food.reduce((sum, item) => sum + item.quantity, 0), })} )

-
+
{sortedFood.map((item, idx) => ( ))} @@ -93,17 +105,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)} - {rewards.rewards.drinks.length > 0 && ( + {result.items.drinks.length > 0 && (

- {t('rewards.drinks')} ( - {t('rewards.items', { - count: rewards.rewards.drinks.reduce((sum, item) => sum + item.quantity, 0), + {t('items.drinks')} ( + {t('items.count', { + count: result.items.drinks.reduce((sum, item) => sum + item.quantity, 0), })} )

-
+
{sortedDrinks.map((item, idx) => ( ))} @@ -111,17 +123,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)} - {rewards.rewards.hygiene.length > 0 && ( + {result.items.hygiene.length > 0 && (

- {t('rewards.hygiene')} ( - {t('rewards.items', { - count: rewards.rewards.hygiene.reduce((sum, item) => sum + item.quantity, 0), + {t('items.hygiene')} ( + {t('items.count', { + count: result.items.hygiene.reduce((sum, item) => sum + item.quantity, 0), })} )

-
+
{sortedHygiene.map((item, idx) => ( {

- {t('rewards.care')} ( - {t('rewards.items', { count: sortedCare.reduce((sum, item) => sum + item.quantity, 0) })}) + {t('items.care')} ( + {t('items.count', { count: sortedCare.reduce((sum, item) => sum + item.quantity, 0) })})

-
+
{sortedCare.map((item, idx) => ( {
)} - {(rewards.rewards.valentines?.length ?? 0) > 0 && ( + {(result.items.valentines?.length ?? 0) > 0 && (

{ className="w-5 h-5" style={{ imageRendering: 'pixelated' }} /> - {t('rewards.valentines')} ( - {t('rewards.items', { - count: rewards.rewards.valentines!.reduce((sum, item) => sum + item.quantity, 0), + {t('items.valentines')} ( + {t('items.count', { + count: result.items.valentines!.reduce((sum, item) => sum + item.quantity, 0), })} )

-
- {[...rewards.rewards.valentines!] - .sort((a, b) => b.tier - a.tier) +
+ {[...result.items.valentines!] + .sort((a, b) => getItemTierByName(b.name) - getItemTierByName(a.name)) .map((item, idx) => ( ))} @@ -177,17 +189,17 @@ export const RewardsModal = ({ rewards, onClose }: RewardsModalProps) => {
)} - {rewards.rewards.eggs > 0 && ( + {result.items.eggs > 0 && (
-

{t('rewards.bonus')}

+

{t('items.bonus')}

{EGG_ITEM_NAME}
{EGG_ITEM_NAME}
-
x{rewards.rewards.eggs}
-
{t('rewards.luckyFind')}
+
x{result.items.eggs}
+
{t('items.luckyFind')}
diff --git a/apps/client/src/features/items/hooks/useItems.ts b/apps/client/src/features/items/hooks/useItems.ts new file mode 100644 index 0000000..dde9d43 --- /dev/null +++ b/apps/client/src/features/items/hooks/useItems.ts @@ -0,0 +1,75 @@ +'use client'; +import { useRefreshUser } from '@/features/auth/hooks/useRefreshUser'; +import { setItemStatus as setItemStatusAction } from '@/features/items/slices/itemsSlice'; +import { useTranslation } from '@/i18n/hooks/useTranslation'; +import api, { isAbortError } from '@/lib/api'; +import { usePolling } from '@/lib/hooks/usePolling'; +import { callError } from '@/lib/toast'; +import { useAppDispatch, useAppSelector } from '@/store'; +import { ClaimType, ItemResult, ItemStatus } from '@widgetable/types'; +import { useCallback, useEffect, useState } from 'react'; + +export const useItems = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const itemStatus = useAppSelector((state) => state.items.itemStatus); + const loaded = useAppSelector((state) => state.items.loaded); + const refreshUser = useRefreshUser(); + + const [claimingType, setClaimingType] = useState(null); + const [lastItems, setLastItems] = useState(null); + + const loadItemStatus = useCallback(async () => { + try { + const response = await api.get('/items/status'); + dispatch(setItemStatusAction(response.data)); + } catch (error: unknown) { + if (!isAbortError(error)) callError(t('items.failedLoad')); + } + }, [dispatch]); + + const executeItemClaim = async (endpoint: string, type: ClaimType) => { + if (claimingType) return; + + setClaimingType(type); + + try { + const response = await api.post(endpoint); + const result = response.data; + + setLastItems(result); + await loadItemStatus(); + + await refreshUser(); + } catch { + callError(t('items.failedClaim')); + } finally { + setClaimingType(null); + } + }; + + const claimDaily = useCallback(async () => { + await executeItemClaim('/items/daily', ClaimType.DAILY); + }, [claimingType, loadItemStatus, dispatch, refreshUser]); + + const claimQuick = useCallback(async () => { + await executeItemClaim('/items/quick', ClaimType.QUICK); + }, [claimingType, loadItemStatus, dispatch, refreshUser]); + + useEffect(() => { + void loadItemStatus(); + }, [loadItemStatus]); + + usePolling(() => void loadItemStatus(), 60000); + + return { + itemStatus, + claimingType, + lastItems, + loading: !loaded, + claimDaily, + claimQuick, + refreshStatus: loadItemStatus, + closeItemsModal: () => setLastItems(null), + }; +}; diff --git a/apps/client/src/features/items/slices/itemsSlice.ts b/apps/client/src/features/items/slices/itemsSlice.ts new file mode 100644 index 0000000..2a849e2 --- /dev/null +++ b/apps/client/src/features/items/slices/itemsSlice.ts @@ -0,0 +1,27 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { ItemStatus } from '@widgetable/types'; + +interface ItemsState { + itemStatus: ItemStatus | null; + loaded: boolean; +} + +const initialState: ItemsState = { + itemStatus: null, + loaded: false, +}; + +export const itemsSlice = createSlice({ + name: 'items', + initialState, + reducers: { + setItemStatus: (state, action: PayloadAction) => { + state.itemStatus = action.payload; + state.loaded = true; + }, + clearItems: () => initialState, + }, +}); + +export const { setItemStatus, clearItems } = itemsSlice.actions; +export default itemsSlice.reducer; diff --git a/apps/client/src/features/pets/components/Pet.tsx b/apps/client/src/features/pets/components/Pet.tsx index 1f0b089..d09d534 100644 --- a/apps/client/src/features/pets/components/Pet.tsx +++ b/apps/client/src/features/pets/components/Pet.tsx @@ -3,9 +3,9 @@ import { Button, InputTextHidden } from '@/components/ui/Button'; import { Skeleton } from '@/components/ui/Skeleton'; import { HTTP_STATUS } from '@/config/constants'; -import { getActionSprite } from '@/data/actionSprites'; +import { getItemSprite } from '@widgetable/types'; import { setUserData } from '@/features/auth/slices/userSlice'; -import { RewardsModal } from '@/features/claims/components/RewardsModal'; +import { ItemsModal } from '@/features/items/components/ItemsModal'; import { BackgroundSelector } from '@/features/pets/components/BackgroundSelector'; import { InviteModal } from '@/features/pets/components/InviteModal'; import PetSprite from '@/features/pets/components/PetSprite'; @@ -30,7 +30,7 @@ import { Users, Zap, } from '@nsmr/pixelart-react'; -import { ClaimResult, formatTime, Pet, PET_THRESHOLDS, User } from '@widgetable/types'; +import { getItemTier, ItemResult, ItemTier, formatTime, Pet, PET_THRESHOLDS, User } from '@widgetable/types'; import { getExpForCurrentLevel, @@ -44,19 +44,11 @@ import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import { usePet } from '../hooks/usePet'; -const getTierColor = (tier?: number): string => { - switch (tier) { - case 1: - return 'border-gray-400/50'; - case 2: - return 'border-green-500/50'; - case 3: - return 'border-blue-500/50'; - case 4: - return 'border-purple-500/50'; - default: - return 'border-gray-400/50'; - } +const TIER_COLORS: Record = { + [ItemTier.BASIC]: 'border-inactive/50', + [ItemTier.COMMON]: 'border-tier-common/50', + [ItemTier.PREMIUM]: 'border-tier-premium/50', + [ItemTier.LEGENDARY]: 'border-tier-legendary/50', }; const actionBarBtnClass = @@ -71,7 +63,7 @@ const PetPage = () => { const [eggTimeLeft, setEggTimeLeft] = useState(''); const [expeditionTimeLeft, setExpeditionTimeLeft] = useState(''); const [canClaimExpedition, setCanClaimExpedition] = useState(false); - const [lastRewards, setLastRewards] = useState(null); + const [lastItems, setLastItems] = useState(null); const [bgSelectorOpen, setBgSelectorOpen] = useState(false); const [actionsMenuOpen, setActionsMenuOpen] = useState(false); @@ -187,8 +179,8 @@ const PetPage = () => { const handleClaimExpedition = async () => { if (!pet?._id) return; try { - const rewards = await api.post(`/pets/${pet._id}/expedition/claim`); - setLastRewards(rewards.data); + const rewards = await api.post(`/pets/${pet._id}/expedition/claim`); + setLastItems(rewards.data); await Promise.all([loadPet(), api.get('/auth/me').then((r) => dispatch(setUserData(r.data)))]); } catch { @@ -224,7 +216,8 @@ const PetPage = () => { const isDisabled = inventoryCount === 0 || isNeedTooHigh; return { name: action.name, - sprite: getActionSprite(action.name), + sprite: getItemSprite(action.name), + amount: action.amount, inventoryCost: action.inventoryCost, inventoryCount, isDisabled, @@ -472,7 +465,7 @@ const PetPage = () => { disabled={ isEgg || pet.isOnExpedition || !!currentAnimation || action.isDisabled } - className={`flex flex-col items-center justify-center gap-1 p-2 rounded-lg border-2 bg-surface transition-colors ${getTierColor(action.inventoryCost)} ${isEgg || pet.isOnExpedition || currentAnimation || action.isDisabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-secondary/10 cursor-pointer'}`} + className={`flex flex-col items-center justify-center gap-1 p-2 rounded-lg border-2 bg-surface transition-colors ${TIER_COLORS[getItemTier(action.amount)]} ${isEgg || pet.isOnExpedition || currentAnimation || action.isDisabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-secondary/10 cursor-pointer'}`} > {action.sprite && (
@@ -513,7 +506,7 @@ const PetPage = () => { onInvite={(friendId) => void sendCoparentingRequest(friendId)} /> - {lastRewards && setLastRewards(null)} />} + {lastItems && setLastItems(null)} />} ); }; diff --git a/apps/client/src/features/pets/components/PetSprite.tsx b/apps/client/src/features/pets/components/PetSprite.tsx index 11587fb..9ebe8cf 100644 --- a/apps/client/src/features/pets/components/PetSprite.tsx +++ b/apps/client/src/features/pets/components/PetSprite.tsx @@ -1,5 +1,5 @@ import { PET_SPRITE_SIZES } from '@/config/constants'; -import { getPetScale } from '@/data/petScales'; +import { getPetScale } from '@widgetable/types'; import { ANIMATION_DURATIONS, Pet, diff --git a/apps/client/src/store/index.ts b/apps/client/src/store/index.ts index a6357ea..7010228 100644 --- a/apps/client/src/store/index.ts +++ b/apps/client/src/store/index.ts @@ -5,19 +5,19 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { persistReducer, persistStore } from 'redux-persist'; import userReducer from '@/features/auth/slices/userSlice'; -import claimsReducer from '@/features/claims/slices/claimsSlice'; import petsReducer from '@/features/pets/slices/petsSlice'; +import itemsReducer from '@/features/items/slices/itemsSlice'; const persistConfig = { key: 'root', storage, - whitelist: ['user', 'pets', 'claims', 'adminUsers', 'adminStats'], + whitelist: ['user', 'pets', 'items', 'adminUsers', 'adminStats'], }; const rootReducer = combineReducers({ user: userReducer, pets: petsReducer, - claims: claimsReducer, + items: itemsReducer, }); const persistedReducer = persistReducer(persistConfig, rootReducer); diff --git a/apps/server/README.md b/apps/server/README.md index ad63ae6..b45e981 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -43,7 +43,7 @@ Each domain is a NestJS module following the controller → service → entity p | `auth` | Register/login/logout, JWT signing, Argon2 password hashing | | `users` | User CRUD, avatar upload/download via MinIO | | `pets` | Pet CRUD + retroactive stats degradation | -| `claims` | Pet action claiming system | +| `rewards` | Pet action claiming system | | `friends` | Friend management | | `gifts` | Gift exchange system | | `notifications` | Push notifications (web-push) | diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 7b527b1..89e0aa2 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -6,13 +6,12 @@ import { diskStorage } from 'multer'; import { tmpdir } from 'os'; import { extname } from 'path'; import { AuthModule } from 'src/auth/auth.module'; -import { ClaimsModule } from 'src/claims/claims.module'; import { CoparentingModule } from 'src/coparenting/coparenting.module'; import { FriendsModule } from 'src/friends/friends.module'; import { GiftsModule } from 'src/gifts/gifts.module'; import { NotificationsModule } from 'src/notifications/notifications.module'; import { PetsModule } from 'src/pets/pets.module'; -import { SharedModule } from 'src/shared/shared.module'; +import { ItemsModule } from 'src/items/items.module'; import { StorageModule } from 'src/storage/storage.module'; import { UsersModule } from 'src/users/users.module'; import { v4 as uuidv4 } from 'uuid'; @@ -46,7 +45,6 @@ import { v4 as uuidv4 } from 'uuid'; }, inject: [ConfigService], }), - SharedModule, StorageModule, UsersModule, FriendsModule, @@ -54,7 +52,7 @@ import { v4 as uuidv4 } from 'uuid'; CoparentingModule, PetsModule, AuthModule, - ClaimsModule, + ItemsModule, NotificationsModule, ], controllers: [], diff --git a/apps/server/src/claims/claims.service.ts b/apps/server/src/claims/claims.service.ts deleted file mode 100644 index d835f87..0000000 --- a/apps/server/src/claims/claims.service.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { InjectModel } from '@nestjs/mongoose'; -import { CLAIMS_CONFIG, ClaimResult, ClaimStatus, PetActionCategory, TIME_CONVERSION } from '@widgetable/types'; -import { Model, Types } from 'mongoose'; -import { Pet, PetDocument } from 'src/pets/entities/pet.entity'; -import { RewardsService } from 'src/shared/rewards.service'; -import { User, UserDocument } from 'src/users/entities/user.entity'; -import { UsersService } from 'src/users/users.service'; - -@Injectable() -export class ClaimsService { - constructor( - @InjectModel(User.name) private userModel: Model, - @InjectModel(Pet.name) private petModel: Model, - private readonly usersService: UsersService, - private readonly rewardsService: RewardsService, - ) {} - - async getClaimStatus(userId: Types.ObjectId): Promise { - const user = await this.userModel.findById(userId); - if (!user) throw new NotFoundException(); - - const pets = await this.petModel.find({ parents: { $in: [userId] } }); - const petCount = pets.filter((pet) => !pet.isEgg).length; - - const now = new Date(); - const dailyAvailable = this.checkCooldown(user.lastDailyClaimTime, CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS, now); - const quickAvailable = this.checkCooldown(user.lastQuickClaimTime, CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS, now); - - return { - dailyAvailable, - quickAvailable, - nextDailyTime: dailyAvailable - ? undefined - : this.getNextClaimTime(user.lastDailyClaimTime!, CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS), - nextQuickTime: quickAvailable - ? undefined - : this.getNextClaimTime(user.lastQuickClaimTime!, CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS), - petCount, - }; - } - - async claimDaily(userId: Types.ObjectId): Promise { - return this.executeClaim(userId, 'daily'); - } - - async claimQuick(userId: Types.ObjectId): Promise { - return this.executeClaim(userId, 'quick'); - } - - async claimDebug(userId: Types.ObjectId): Promise { - if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_CLAIMS !== 'true') { - throw new BadRequestException(); - } - return this.executeClaim(userId, 'daily', true); - } - - private async executeClaim( - userId: Types.ObjectId, - claimType: 'daily' | 'quick', - skipCooldown = false, - ): Promise { - const user = await this.userModel.findById(userId); - if (!user) throw new NotFoundException(); - - const pets = await this.petModel.find({ parents: { $in: [userId] } }); - const activePets = pets.filter((pet) => !pet.isEgg); - const petCount = activePets.length; - - const now = new Date(); - const cooldownHours = - claimType === 'daily' ? CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS : CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS; - const lastClaimTime = claimType === 'daily' ? user.lastDailyClaimTime : user.lastQuickClaimTime; - - if (!skipCooldown && !this.checkCooldown(lastClaimTime, cooldownHours, now)) { - throw new BadRequestException(); - } - - // Calculate rewards - const rewards = this.calculateRewards(petCount, claimType === 'quick'); - - await this.usersService.applyRewards(userId, rewards.rewards); - - // Update claim timestamp - const updateField = claimType === 'daily' ? 'lastDailyClaimTime' : 'lastQuickClaimTime'; - await this.userModel.findByIdAndUpdate(userId, { [updateField]: now }); - - return rewards; - } - - private calculateRewards(petCount: number, isQuick: boolean): ClaimResult { - const multiplier = isQuick ? CLAIMS_CONFIG.QUICK_REWARD_MULTIPLIER : 1.0; - // Ensure minimum rewards even with 0 pets - const petMultiplier = Math.max(1, Math.sqrt(petCount)); - - // Calculate base amounts - const foodCount = Math.floor(CLAIMS_CONFIG.BASE_FOOD_ITEMS * petMultiplier * multiplier); - const drinkCount = Math.floor(CLAIMS_CONFIG.BASE_DRINK_ITEMS * petMultiplier * multiplier); - const hygieneCount = Math.floor(CLAIMS_CONFIG.BASE_HYGIENE_ITEMS * petMultiplier * multiplier); - const careCount = Math.floor(CLAIMS_CONFIG.BASE_CARE_ITEMS * petMultiplier * multiplier); - - // Select random items from each category - const foodItems = this.rewardsService.selectRandomItems(PetActionCategory.FEED, foodCount); - const drinkItems = this.rewardsService.selectRandomItems(PetActionCategory.DRINK, drinkCount); - const hygieneItems = this.rewardsService.selectRandomItems(PetActionCategory.WASH, hygieneCount); - const careItems = this.rewardsService.selectRandomItems(PetActionCategory.CARE, careCount); - - // Calculate egg chance (decreases with more pets: 35% → 5%) - const eggChance = Math.max(CLAIMS_CONFIG.BASE_EGG_CHANCE / (1 + petCount * 0.6), 0.05); - const earnedEggs = Math.random() < eggChance ? 1 : 0; - - // Valentine bonus (February only) - const valentineItems = this.rewardsService.isValentineSeason() - ? this.rewardsService.selectRandomValentineItems(Math.floor(2 * petMultiplier * multiplier)) - : []; - - const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0); - const totalItems = foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount; - const cooldownHours = isQuick ? CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS : CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS; - const nextClaimTime = new Date(Date.now() + cooldownHours * 60 * 60 * 1000); - - return { - success: true, - rewards: { - food: foodItems, - drinks: drinkItems, - hygiene: hygieneItems, - care: careItems, - eggs: earnedEggs, - valentines: valentineItems.length > 0 ? valentineItems : undefined, - }, - totalItems, - nextClaimTime, - }; - } - - private checkCooldown(lastClaimTime: Date | undefined, cooldownHours: number, now: Date): boolean { - if (!lastClaimTime) return true; - const timeSinceLastClaim = now.getTime() - lastClaimTime.getTime(); - const cooldownMs = TIME_CONVERSION.HOURS_TO_MS(cooldownHours); - return timeSinceLastClaim >= cooldownMs; - } - - private getNextClaimTime(lastClaimTime: Date, cooldownHours: number): Date { - return new Date(lastClaimTime.getTime() + TIME_CONVERSION.HOURS_TO_MS(cooldownHours)); - } -} diff --git a/apps/server/src/friends/friends.service.ts b/apps/server/src/friends/friends.service.ts index 00fe195..441daf8 100644 --- a/apps/server/src/friends/friends.service.ts +++ b/apps/server/src/friends/friends.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { USER_POPULATE_FIELDS } from 'src/shared/constants'; +import { USER_POPULATE_FIELDS } from 'src/users/users.constants'; import { InjectModel, InjectConnection } from '@nestjs/mongoose'; import { RequestType, RequestStatus, DEFAULT_LANGUAGE } from '@widgetable/types'; import { Model, Connection, Types } from 'mongoose'; diff --git a/apps/server/src/gifts/gifts.service.ts b/apps/server/src/gifts/gifts.service.ts index 465ed26..456b344 100644 --- a/apps/server/src/gifts/gifts.service.ts +++ b/apps/server/src/gifts/gifts.service.ts @@ -1,22 +1,18 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { InjectModel, InjectConnection } from '@nestjs/mongoose'; +import { InjectModel } from '@nestjs/mongoose'; import { DEFAULT_LANGUAGE, isValentineGiftItem } from '@widgetable/types'; -import { Model, Connection, Types } from 'mongoose'; +import { Model, Types } from 'mongoose'; import { NotificationsService, nt } from 'src/notifications/notifications.service'; import { User, UserDocument } from 'src/users/entities/user.entity'; import { UsersService } from 'src/users/users.service'; -import { BaseService } from 'src/common/base.service'; @Injectable() -export class GiftsService extends BaseService { +export class GiftsService { constructor( @InjectModel(User.name) private userModel: Model, - @InjectConnection() connection: Connection, private readonly usersService: UsersService, private readonly notificationsService: NotificationsService, - ) { - super(connection); - } + ) {} async sendGift(senderId: Types.ObjectId, recipientId: string, itemName: string, quantity: number) { if (senderId.toString() === recipientId) throw new BadRequestException(); @@ -31,9 +27,6 @@ export class GiftsService extends BaseService { const recipientObjectId = new Types.ObjectId(recipientId); - const hasItem = await this.usersService.hasInventory(senderId, itemName, quantity); - if (!hasItem) throw new BadRequestException(); - await this.usersService.consumeInventory(senderId, itemName, quantity); const recipient = await this.userModel.findById(recipientId); diff --git a/apps/server/src/claims/claims.controller.ts b/apps/server/src/items/items.controller.ts similarity index 53% rename from apps/server/src/claims/claims.controller.ts rename to apps/server/src/items/items.controller.ts index d86e32b..db29def 100644 --- a/apps/server/src/claims/claims.controller.ts +++ b/apps/server/src/items/items.controller.ts @@ -1,31 +1,27 @@ import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ClaimType } from '@widgetable/types'; import { GetUser } from 'src/common/decorators/get-user.decorator'; import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; import { UserDocument } from 'src/users/entities/user.entity'; -import { ClaimsService } from './claims.service'; +import { ItemsService } from './items.service'; -@Controller('claims') +@Controller('items') @UseGuards(JwtAuthGuard) -export class ClaimsController { - constructor(private readonly claimsService: ClaimsService) {} +export class ItemsController { + constructor(private readonly itemsService: ItemsService) {} @Get('status') async getStatus(@GetUser() user: UserDocument) { - return this.claimsService.getClaimStatus(user._id); + return this.itemsService.getItemStatus(user._id); } @Post('daily') async claimDaily(@GetUser() user: UserDocument) { - return this.claimsService.claimDaily(user._id); + return this.itemsService.executeClaim(user._id, ClaimType.DAILY); } @Post('quick') async claimQuick(@GetUser() user: UserDocument) { - return this.claimsService.claimQuick(user._id); - } - - @Post('debug') - async claimDebug(@GetUser() user: UserDocument) { - return this.claimsService.claimDebug(user._id); + return this.itemsService.executeClaim(user._id, ClaimType.QUICK); } } diff --git a/apps/server/src/claims/claims.module.ts b/apps/server/src/items/items.module.ts similarity index 66% rename from apps/server/src/claims/claims.module.ts rename to apps/server/src/items/items.module.ts index e15dd9f..10145dd 100644 --- a/apps/server/src/claims/claims.module.ts +++ b/apps/server/src/items/items.module.ts @@ -3,8 +3,8 @@ import { MongooseModule } from '@nestjs/mongoose'; import { Pet, PetSchema } from 'src/pets/entities/pet.entity'; import { User, UserSchema } from 'src/users/entities/user.entity'; import { UsersModule } from 'src/users/users.module'; -import { ClaimsController } from './claims.controller'; -import { ClaimsService } from './claims.service'; +import { ItemsController } from './items.controller'; +import { ItemsService } from './items.service'; @Module({ imports: [ @@ -14,8 +14,8 @@ import { ClaimsService } from './claims.service'; ]), UsersModule, ], - controllers: [ClaimsController], - providers: [ClaimsService], - exports: [ClaimsService], + controllers: [ItemsController], + providers: [ItemsService], + exports: [ItemsService], }) -export class ClaimsModule {} +export class ItemsModule {} diff --git a/apps/server/src/items/items.service.ts b/apps/server/src/items/items.service.ts new file mode 100644 index 0000000..39b8dad --- /dev/null +++ b/apps/server/src/items/items.service.ts @@ -0,0 +1,206 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { sample } from 'lodash'; +import { ConfigService } from '@nestjs/config'; +import { InjectModel } from '@nestjs/mongoose'; +import { + CLAIM_TYPE_CONFIG, + ClaimType, + EXPEDITION_CONFIG, + getNextClaimTime, + isClaimAvailable, + ItemResult, + ItemReward, + ITEMS_CLAIM_CONFIG, + ItemStatus, + PET_ACTIONS_BY_CATEGORY, + PetActionCategory, + VALENTINE_GIFT_ITEMS, +} from '@widgetable/types'; +import { Model, Types } from 'mongoose'; +import { Pet, PetDocument } from 'src/pets/entities/pet.entity'; +import { User, UserDocument } from 'src/users/entities/user.entity'; +import { UsersService } from 'src/users/users.service'; + +@Injectable() +export class ItemsService { + constructor( + @InjectModel(User.name) private userModel: Model, + @InjectModel(Pet.name) private petModel: Model, + private readonly usersService: UsersService, + private readonly configService: ConfigService, + ) {} + + async getItemStatus(userId: Types.ObjectId): Promise { + const user = await this.userModel.findById(userId); + if (!user) throw new NotFoundException(); + + const pets = await this.petModel.find({ parents: { $in: [userId] } }); + const petCount = pets.filter((pet) => !pet.isEgg).length; + + const now = new Date(); + const available = {} as Record; + const nextClaimTime: Partial> = {}; + + for (const type of Object.values(ClaimType)) { + const { cooldownHours } = CLAIM_TYPE_CONFIG[type]; + const lastTime = user.lastClaimTimes?.get(type); + const isAvailable = isClaimAvailable(lastTime, cooldownHours, now); + available[type] = isAvailable; + if (!isAvailable) nextClaimTime[type] = getNextClaimTime(lastTime!, cooldownHours); + } + + return { available, nextClaimTime, petCount }; + } + + async executeClaim(userId: Types.ObjectId, claimType: ClaimType): Promise { + const user = await this.userModel.findById(userId); + if (!user) throw new NotFoundException(); + + const pets = await this.petModel.find({ parents: { $in: [userId] } }); + const petCount = pets.filter((pet) => !pet.isEgg).length; + + const now = new Date(); + const { cooldownHours, multiplier } = CLAIM_TYPE_CONFIG[claimType]; + const lastClaimTime = user.lastClaimTimes?.get(claimType); + + if (!isClaimAvailable(lastClaimTime, cooldownHours, now)) { + throw new BadRequestException(); + } + + const result = this.calculateClaimItems(petCount, multiplier); + + await this.usersService.addInventoryBundle(userId, result.items); + await this.userModel.findByIdAndUpdate(userId, { [`lastClaimTimes.${claimType}`]: now }); + + return result; + } + + calculateClaimItems(petCount: number, multiplier: number): ItemResult { + const petMultiplier = Math.max(1, Math.sqrt(petCount)); + + const foodCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_FOOD_ITEMS * petMultiplier * multiplier); + const drinkCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_DRINK_ITEMS * petMultiplier * multiplier); + const hygieneCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_HYGIENE_ITEMS * petMultiplier * multiplier); + const careCount = Math.floor(ITEMS_CLAIM_CONFIG.BASE_CARE_ITEMS * petMultiplier * multiplier); + + const foodItems = this.selectRandomItems(PetActionCategory.FEED, foodCount); + const drinkItems = this.selectRandomItems(PetActionCategory.DRINK, drinkCount); + const hygieneItems = this.selectRandomItems(PetActionCategory.WASH, hygieneCount); + const careItems = this.selectRandomItems(PetActionCategory.CARE, careCount); + + // Egg chance decreases as pet count grows + const eggChance = Math.max( + ITEMS_CLAIM_CONFIG.BASE_EGG_CHANCE / (1 + petCount * ITEMS_CLAIM_CONFIG.EGG_CHANCE_PET_DECAY), + ITEMS_CLAIM_CONFIG.MIN_EGG_CHANCE, + ); + const earnedEggs = Math.random() < eggChance ? 1 : 0; + + const isValentineSeason = this.configService.get('VALENTINE_SEASON', '').toLowerCase() === 'true'; + const valentineItems = isValentineSeason + ? this.selectRandomValentineItems(Math.floor(2 * petMultiplier * multiplier)) + : []; + + const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0); + const totalItems = foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount; + + return { + success: true, + items: { + food: foodItems, + drinks: drinkItems, + hygiene: hygieneItems, + care: careItems, + eggs: earnedEggs, + valentines: valentineItems.length > 0 ? valentineItems : undefined, + }, + totalItems, + }; + } + + calculateExpeditionItems(pet: PetDocument): ItemResult { + const { + BASE_FOOD_ITEMS, + BASE_DRINK_ITEMS, + BASE_HYGIENE_ITEMS, + BASE_CARE_ITEMS, + MIN_EGG_CHANCE, + MAX_EGG_CHANCE, + LEVEL_ITEM_RATE, + LEVEL_ITEM_CAP, + EGG_CHANCE_LEVEL_GROWTH, + } = EXPEDITION_CONFIG; + // Caps bonus at +100% to prevent extreme scaling at high levels + const levelMultiplier = 1 + Math.min(pet.level * LEVEL_ITEM_RATE, LEVEL_ITEM_CAP); + + const foodCount = Math.floor(BASE_FOOD_ITEMS * levelMultiplier); + const drinkCount = Math.floor(BASE_DRINK_ITEMS * levelMultiplier); + const hygieneCount = Math.floor(BASE_HYGIENE_ITEMS * levelMultiplier); + const careCount = Math.floor(BASE_CARE_ITEMS * levelMultiplier); + + const foodItems = this.selectRandomItems(PetActionCategory.FEED, foodCount); + const drinkItems = this.selectRandomItems(PetActionCategory.DRINK, drinkCount); + const hygieneItems = this.selectRandomItems(PetActionCategory.WASH, hygieneCount); + const careItems = this.selectRandomItems(PetActionCategory.CARE, careCount); + + // Logarithmic growth from MIN to MAX based on level + const eggChance = Math.min( + MIN_EGG_CHANCE + (MAX_EGG_CHANCE - MIN_EGG_CHANCE) * (1 - 1 / (1 + pet.level * EGG_CHANCE_LEVEL_GROWTH)), + MAX_EGG_CHANCE, + ); + const earnedEggs = Math.random() < eggChance ? 1 : 0; + + // February-only valentine items + const isValentineSeason = this.configService.get('VALENTINE_SEASON', '').toLowerCase() === 'true'; + const valentineItems = isValentineSeason + ? this.selectRandomValentineItems(Math.floor(1 * levelMultiplier)) + : []; + + const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0); + + return { + success: true, + items: { + food: foodItems, + drinks: drinkItems, + hygiene: hygieneItems, + care: careItems, + eggs: earnedEggs, + valentines: valentineItems.length > 0 ? valentineItems : undefined, + }, + totalItems: foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount, + }; + } + + selectRandomItems(category: PetActionCategory, count: number): ItemReward[] { + const actions = PET_ACTIONS_BY_CATEGORY[category].filter((a) => a.inventoryCost !== undefined); + const itemCounts = new Map(); + + for (let i = 0; i < count; i++) { + const selected = sample(actions)!; + itemCounts.set(selected.name, (itemCounts.get(selected.name) || 0) + 1); + } + + const result: ItemReward[] = []; + itemCounts.forEach((quantity, name) => { + result.push({ name, quantity }); + }); + + return result; + } + + selectRandomValentineItems(count: number): ItemReward[] { + const itemCounts = new Map(); + + for (let i = 0; i < count; i++) { + const selected = sample(VALENTINE_GIFT_ITEMS)!; + itemCounts.set(selected.name, (itemCounts.get(selected.name) || 0) + 1); + } + + const result: ItemReward[] = []; + itemCounts.forEach((quantity, name) => { + result.push({ name, quantity }); + }); + + return result; + } +} diff --git a/apps/server/src/notifications/notifications.constants.ts b/apps/server/src/notifications/notifications.constants.ts new file mode 100644 index 0000000..11d980b --- /dev/null +++ b/apps/server/src/notifications/notifications.constants.ts @@ -0,0 +1,3 @@ +export const WEBPUSH_CONFIG = { + INVALID_SUBSCRIPTION_CODES: [410, 404], +} as const; diff --git a/apps/server/src/notifications/notifications.controller.ts b/apps/server/src/notifications/notifications.controller.ts index c32806c..68a1a22 100644 --- a/apps/server/src/notifications/notifications.controller.ts +++ b/apps/server/src/notifications/notifications.controller.ts @@ -30,9 +30,4 @@ export class NotificationsController { unsubscribe(@Body() body: { endpoint: string }) { return this.notificationsService.unsubscribe(body.endpoint); } - - @Post('test') - sendTestNotification(@GetUser() user: UserDocument) { - return this.notificationsService.sendTestNotification(user._id); - } } diff --git a/apps/server/src/notifications/notifications.service.ts b/apps/server/src/notifications/notifications.service.ts index 56bbb2f..c955f73 100644 --- a/apps/server/src/notifications/notifications.service.ts +++ b/apps/server/src/notifications/notifications.service.ts @@ -4,17 +4,17 @@ import { InjectModel } from '@nestjs/mongoose'; import { Cron, CronExpression } from '@nestjs/schedule'; import { translate } from '@widgetable/i18n'; import { - CLAIMS_CONFIG, + calculateDecayedNeeds, + CLAIM_TYPE_CONFIG, + ClaimType, DEFAULT_LANGUAGE, + isClaimAvailable, NOTIFICATION_CONFIG, - PET_NEED_KEYS, - PET_NEEDS_CONFIG, - PET_UPDATE_INTERVAL, + PET_THRESHOLDS, } from '@widgetable/types'; -import { clamp } from 'lodash'; import { Model, Types } from 'mongoose'; import { Pet, PetDocument } from 'src/pets/entities/pet.entity'; -import { WEBPUSH_CONFIG } from 'src/shared/constants'; +import { WEBPUSH_CONFIG } from './notifications.constants'; import { User, UserDocument } from 'src/users/entities/user.entity'; import * as webpush from 'web-push'; import { PushSubscription, PushSubscriptionDocument } from './entities/push-subscription.entity'; @@ -26,6 +26,8 @@ export const nt = (lang: string, key: string, params?: Record(); constructor( @InjectModel(PushSubscription.name) private subscriptionModel: Model, @@ -57,11 +59,6 @@ export class NotificationsService { return this.subscriptionModel.deleteOne({ endpoint }); } - private async getUserLang(userId: string | Types.ObjectId): Promise { - const user = await this.userModel.findById(userId).select('language').lean(); - return user?.language || DEFAULT_LANGUAGE; - } - private async batchGetUserLangs(userIds: string[]): Promise> { const users = await this.userModel .find({ _id: { $in: userIds } }) @@ -79,46 +76,6 @@ export class NotificationsService { return langMap; } - async sendTestNotification(userId: Types.ObjectId) { - const subscriptions = await this.subscriptionModel.find({ userId }); - - if (subscriptions.length === 0) { - return { sent: 0, message: 'No subscriptions found for this user' }; - } - - const lang = await this.getUserLang(userId); - const payload = JSON.stringify({ - title: nt(lang, 'test.title'), - body: nt(lang, 'test.body'), - icon: '/icon-192x192.png', - url: '/', - }); - - let sent = 0; - for (const sub of subscriptions) { - try { - await webpush.sendNotification( - { endpoint: sub.endpoint, keys: sub.keys as webpush.PushSubscription['keys'] }, - payload, - ); - sent++; - } catch (error) { - const err = error as { statusCode?: number; message?: string }; - if ( - err.statusCode && - (WEBPUSH_CONFIG.INVALID_SUBSCRIPTION_CODES as readonly number[]).includes(err.statusCode) - ) { - await this.subscriptionModel.deleteOne({ _id: sub._id }); - this.logger.debug(`Removed expired subscription: ${sub.endpoint}`); - } else { - this.logger.error(`Failed to send test notification: ${err.message || 'Unknown error'}`); - } - } - } - - return { sent, total: subscriptions.length }; - } - async sendNotificationToUser( userId: Types.ObjectId, payload: { title: string; body: string; icon?: string; url?: string }, @@ -164,31 +121,24 @@ export class NotificationsService { const now = Date.now(); for (const pet of pets) { - const timeDiff = now - (pet.updatedAt?.getTime() || 0); - const intervals = Math.floor(timeDiff / PET_UPDATE_INTERVAL); + const lastUpdatedAt = pet.needsUpdatedAt ?? pet.updatedAt ?? new Date(); + const { needs: currentNeeds, changed } = calculateDecayedNeeds(pet.needs, lastUpdatedAt, new Date(now)); - if (intervals <= 0) continue; + if (!changed) continue; let hasNewUrgent = false; let allZero = true; - for (const key of PET_NEED_KEYS) { - const config = PET_NEEDS_CONFIG[key]; - const decrease = - intervals * config.decayRate * (PET_UPDATE_INTERVAL / NOTIFICATION_CONFIG.DECAY_TIME_UNIT_MS); - const stored = pet.needs[key]; - const current = clamp(stored - decrease, 0, 100); - + for (const key of Object.keys(currentNeeds) as (keyof typeof currentNeeds)[]) { + const current = currentNeeds[key]; if (current !== 0) allZero = false; // Only notify when crossing threshold to avoid spam - if (stored >= 30 && current < 30) hasNewUrgent = true; + if (pet.needs[key] >= PET_THRESHOLDS.URGENT && current < PET_THRESHOLDS.URGENT) hasNewUrgent = true; } - if ( - hasNewUrgent && - pet.urgentNotifiedAt && - now - pet.urgentNotifiedAt.getTime() < NOTIFICATION_CONFIG.PET_NEEDS_COOLDOWN_MS - ) { + const petId = pet._id.toString(); + const cooldownExpiry = this.urgentCooldowns.get(petId) ?? 0; + if (hasNewUrgent && now < cooldownExpiry) { hasNewUrgent = false; } @@ -224,15 +174,12 @@ export class NotificationsService { }), }); notifiedUsers.add(userId); - // A shared pet appears in each parent's list; deduplicate before the bulk write for (const petId of petIds) notifiedPetIds.add(petId.toString()); } - if (notifiedPetIds.size > 0) { - await this.petModel.updateMany( - { _id: { $in: [...notifiedPetIds] } }, - { $set: { urgentNotifiedAt: new Date(now) } }, - ); + const cooldownExpiry = now + NOTIFICATION_CONFIG.PET_NEEDS_COOLDOWN_MS; + for (const petId of notifiedPetIds) { + this.urgentCooldowns.set(petId, cooldownExpiry); } // Notify all-zero needs (only for users not already notified above) @@ -339,38 +286,30 @@ export class NotificationsService { this.logger.debug('Checking claim availability for notifications...'); const now = new Date(); - const dailyCooldownMs = CLAIMS_CONFIG.DAILY_COOLDOWN_HOURS * 60 * 60 * 1000; - const quickCooldownMs = CLAIMS_CONFIG.QUICK_COOLDOWN_HOURS * 60 * 60 * 1000; - const dailyWindowEnd = new Date(now.getTime() - dailyCooldownMs); - const dailyWindowStart = new Date(dailyWindowEnd.getTime() - NOTIFICATION_CONFIG.COOLDOWN_MS); + // Build per-type window starts and query conditions + const orConditions = Object.values(ClaimType).map((type) => { + const windowStart = new Date( + now.getTime() - CLAIM_TYPE_CONFIG[type].cooldownHours * 3600000 - NOTIFICATION_CONFIG.COOLDOWN_MS, + ); + return { [`lastClaimTimes.${type}`]: { $gte: windowStart } }; + }); - const quickWindowEnd = new Date(now.getTime() - quickCooldownMs); - const quickWindowStart = new Date(quickWindowEnd.getTime() - NOTIFICATION_CONFIG.COOLDOWN_MS); + const users = await this.userModel.find({ $or: orConditions }).lean(); - const users = await this.userModel - .find({ - $or: [ - { lastDailyClaimTime: { $gte: dailyWindowStart, $lte: dailyWindowEnd } }, - { lastQuickClaimTime: { $gte: quickWindowStart, $lte: quickWindowEnd } }, - ], - }) - .lean(); for (const user of users) { - const dailyReady = - user.lastDailyClaimTime && - user.lastDailyClaimTime >= dailyWindowStart && - user.lastDailyClaimTime <= dailyWindowEnd; - const quickReady = - user.lastQuickClaimTime && - user.lastQuickClaimTime >= quickWindowStart && - user.lastQuickClaimTime <= quickWindowEnd; + const readyTypes = Object.values(ClaimType).filter((type) => { + const lastTime = user.lastClaimTimes?.[type as unknown as keyof typeof user.lastClaimTimes]; + return isClaimAvailable(lastTime, CLAIM_TYPE_CONFIG[type].cooldownHours, now); + }); + + if (readyTypes.length === 0) continue; const lang = user.language || DEFAULT_LANGUAGE; - const bodyKey = dailyReady && quickReady ? 'claims.both' : dailyReady ? 'claims.daily' : 'claims.quick'; + const bodyKey = readyTypes.length > 1 ? 'items.all' : `items.${readyTypes[0]}`; await this.sendNotificationToUser(user._id, { - title: nt(lang, 'claims.title'), + title: nt(lang, 'items.title'), body: nt(lang, bodyKey), }); } diff --git a/apps/server/src/pets/entities/pet.entity.ts b/apps/server/src/pets/entities/pet.entity.ts index a97b7b6..da09060 100644 --- a/apps/server/src/pets/entities/pet.entity.ts +++ b/apps/server/src/pets/entities/pet.entity.ts @@ -42,21 +42,12 @@ export class Pet { level: number; @Prop({ required: false }) background?: number; + @Prop({ type: Date, required: false }) + needsUpdatedAt?: Date; @Prop({ required: true, default: false }) isOnExpedition: boolean; @Prop({ required: false }) expeditionReturnTime?: Date; - @Prop({ type: Date, required: false }) - urgentNotifiedAt?: Date; - @Prop({ type: Object, required: false }) - expeditionRewards?: { - food: Array<{ name: string; quantity: number; tier: number }>; - drinks: Array<{ name: string; quantity: number; tier: number }>; - hygiene: Array<{ name: string; quantity: number; tier: number }>; - care: Array<{ name: string; quantity: number; tier: number }>; - eggs: number; - valentines?: Array<{ name: string; quantity: number; tier: number }>; - }; } export const PetSchema = SchemaFactory.createForClass(Pet); diff --git a/apps/server/src/pets/pets.controller.ts b/apps/server/src/pets/pets.controller.ts index 03c632b..67fa093 100644 --- a/apps/server/src/pets/pets.controller.ts +++ b/apps/server/src/pets/pets.controller.ts @@ -61,8 +61,7 @@ export class PetsController { const currentPet = await this.petsService.getPet(petId); const currentNeedValue = currentPet.needs[action.needKey]; - const newNeedValue = - action.value === 'increment' ? Math.min(currentNeedValue + action.amount, 100) : action.value; + const newNeedValue = Math.min(currentNeedValue + action.amount, 100); return this.petsService.update(petId, { needs: { [action.needKey]: newNeedValue } }, action.experience); } diff --git a/apps/server/src/pets/pets.module.ts b/apps/server/src/pets/pets.module.ts index d183b35..fdbfa64 100644 --- a/apps/server/src/pets/pets.module.ts +++ b/apps/server/src/pets/pets.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; +import { ItemsModule } from 'src/items/items.module'; import { UsersModule } from 'src/users/users.module'; import { Pet, PetSchema } from './entities/pet.entity'; import { PetsController } from './pets.controller'; import { PetsService } from './pets.service'; @Module({ - imports: [MongooseModule.forFeature([{ name: Pet.name, schema: PetSchema }]), UsersModule], + imports: [MongooseModule.forFeature([{ name: Pet.name, schema: PetSchema }]), UsersModule, ItemsModule], controllers: [PetsController], providers: [PetsService], exports: [PetsService], diff --git a/apps/server/src/pets/pets.service.ts b/apps/server/src/pets/pets.service.ts index 1460247..9d51941 100644 --- a/apps/server/src/pets/pets.service.ts +++ b/apps/server/src/pets/pets.service.ts @@ -8,18 +8,16 @@ import { import { InjectConnection, InjectModel } from '@nestjs/mongoose'; import { + calculateDecayedNeeds, calculateLevel, - ClaimResult, DEFAULT_LANGUAGE, EXPEDITION_BASE_DURATION, - EXPEDITION_CONFIG, EXPEDITION_LEVEL_MULTIPLIER, HATCH_DURATIONS, + ItemResult, + MAX_EXPEDITION_SLOTS_RATIO, PET_NEED_KEYS, - PET_NEEDS_CONFIG, PET_THRESHOLDS, - PET_UPDATE_INTERVAL, - PetActionCategory, PetType, PetUpdate, } from '@widgetable/types'; @@ -27,8 +25,7 @@ import { translate } from '@widgetable/i18n'; import { clamp, random } from 'lodash'; import { Connection, Model, Types } from 'mongoose'; import { BaseService } from 'src/common/base.service'; -import { PET_CONFIG } from 'src/shared/constants'; -import { RewardsService } from 'src/shared/rewards.service'; +import { ItemsService } from 'src/items/items.service'; import { UserDocument } from 'src/users/entities/user.entity'; import { UsersService } from 'src/users/users.service'; import { Pet, PetDocument } from './entities/pet.entity'; @@ -41,7 +38,7 @@ export class PetsService extends BaseService { @InjectModel(Pet.name) private petModel: Model, @InjectConnection() connection: Connection, private readonly usersService: UsersService, - private readonly rewardsService: RewardsService, + private readonly itemsService: ItemsService, ) { super(connection); } @@ -148,31 +145,13 @@ export class PetsService extends BaseService { if (pet.isEgg) return pet; - const timeDiff = Date.now() - (pet.updatedAt?.getTime() || 0); - const intervals = Math.floor(timeDiff / PET_UPDATE_INTERVAL); + const lastUpdatedAt = pet.needsUpdatedAt ?? pet.updatedAt ?? new Date(); + const { needs: updatedNeeds, changed } = calculateDecayedNeeds(pet.needs, lastUpdatedAt, new Date()); - if (intervals <= 0) return pet; - // Convert per-minute decay to per-interval rate - const DECAY_TIME_UNIT = 60 * 1000; - const updatedNeeds: Record = {}; - - PET_NEED_KEYS.forEach((key) => { - const config = PET_NEEDS_CONFIG[key]; - const decrease = intervals * config.decayRate * (PET_UPDATE_INTERVAL / DECAY_TIME_UNIT); - updatedNeeds[key] = clamp(pet.needs[key] - decrease, 0, 100); - }); - - // Allows the next neglect cycle to trigger a fresh notification - const anyNeedRestored = PET_NEED_KEYS.some( - (key) => pet.needs[key] < PET_THRESHOLDS.URGENT && updatedNeeds[key] >= PET_THRESHOLDS.URGENT, - ); + if (!changed) return pet; const updatedPet = await this.petModel - .findByIdAndUpdate( - pet._id, - { needs: updatedNeeds, ...(anyNeedRestored && { urgentNotifiedAt: null }) }, - { new: true }, - ) + .findByIdAndUpdate(pet._id, { needs: updatedNeeds, needsUpdatedAt: new Date() }, { new: true }) .populate('parents', this.PARENT_FIELDS); return updatedPet || pet; @@ -198,7 +177,7 @@ export class PetsService extends BaseService { const now = new Date(); const allPets = await this.getPetsForUser(userId); const activePets = allPets.filter((p) => !p.isEgg); - const maxSlots = Math.ceil(activePets.length * PET_CONFIG.MAX_EXPEDITION_SLOTS_RATIO); + const maxSlots = Math.ceil(activePets.length * MAX_EXPEDITION_SLOTS_RATIO); const usedSlots = allPets.filter( (p) => p.isOnExpedition && p.expeditionReturnTime && new Date(p.expeditionReturnTime) > now, ).length; @@ -210,15 +189,12 @@ export class PetsService extends BaseService { const duration = baseDuration * levelMultiplier; const returnTime = new Date(Date.now() + duration); - const rewards = this.calculateExpeditionRewards(pet); - const updatedPet = await this.petModel .findByIdAndUpdate( petId, { isOnExpedition: true, expeditionReturnTime: returnTime, - expeditionRewards: rewards.rewards, }, { new: true }, ) @@ -228,92 +204,29 @@ export class PetsService extends BaseService { return updatedPet; } - async claimExpedition(petId: PetDocument['_id'], userId: UserDocument['_id']): Promise { + async claimExpedition(petId: PetDocument['_id'], userId: UserDocument['_id']): Promise { const pet = await this.getPet(petId); if (!pet.isOnExpedition) throw new ConflictException(); if (!pet.expeditionReturnTime) throw new UnprocessableEntityException(); - if (new Date() < pet.expeditionReturnTime) { - throw new ConflictException(); - } - if (!pet.expeditionRewards) throw new UnprocessableEntityException(); - // Atomic transaction ensures rewards and state update together - const rewards = pet.expeditionRewards; + if (new Date() < pet.expeditionReturnTime) throw new ConflictException(); + + const result = this.itemsService.calculateExpeditionItems(pet); + + // Atomic transaction ensures items and state update together await this.withTransaction(async (session) => { - await this.usersService.applyRewards(userId, rewards, session); + await this.usersService.addInventoryBundle(userId, result.items, session); await this.petModel.findByIdAndUpdate( petId, { isOnExpedition: false, expeditionReturnTime: undefined, - expeditionRewards: undefined, }, { session }, ); }); - const valentineCount = (rewards.valentines || []).reduce((sum, item) => sum + item.quantity, 0); - const totalItems = - [...rewards.food, ...rewards.drinks, ...rewards.hygiene, ...(rewards.care || [])].reduce( - (sum, item) => sum + item.quantity, - rewards.eggs, - ) + valentineCount; - - return { - success: true, - rewards, - totalItems, - nextClaimTime: new Date(), - }; - } - - private calculateExpeditionRewards(pet: PetDocument): ClaimResult { - const { - BASE_FOOD_ITEMS, - BASE_DRINK_ITEMS, - BASE_HYGIENE_ITEMS, - BASE_CARE_ITEMS, - MIN_EGG_CHANCE, - MAX_EGG_CHANCE, - } = EXPEDITION_CONFIG; - // Exponential scaling caps at +100% - const levelMultiplier = 1 + Math.min(pet.level * 0.1, 1.0); - - const foodCount = Math.floor(BASE_FOOD_ITEMS * levelMultiplier); - const drinkCount = Math.floor(BASE_DRINK_ITEMS * levelMultiplier); - const hygieneCount = Math.floor(BASE_HYGIENE_ITEMS * levelMultiplier); - const careCount = Math.floor(BASE_CARE_ITEMS * levelMultiplier); - - const foodItems = this.rewardsService.selectRandomItems(PetActionCategory.FEED, foodCount); - const drinkItems = this.rewardsService.selectRandomItems(PetActionCategory.DRINK, drinkCount); - const hygieneItems = this.rewardsService.selectRandomItems(PetActionCategory.WASH, hygieneCount); - const careItems = this.rewardsService.selectRandomItems(PetActionCategory.CARE, careCount); - // Logarithmic growth from 5% to 18% based on level - const eggChance = Math.min( - MIN_EGG_CHANCE + (MAX_EGG_CHANCE - MIN_EGG_CHANCE) * (1 - 1 / (1 + pet.level * 0.3)), - MAX_EGG_CHANCE, - ); - const earnedEggs = Math.random() < eggChance ? 1 : 0; - // February-only valentine items - const valentineItems = this.rewardsService.isValentineSeason() - ? this.rewardsService.selectRandomValentineItems(Math.floor(1 * levelMultiplier)) - : []; - - const valentineCount = valentineItems.reduce((sum, item) => sum + item.quantity, 0); - - return { - success: true, - rewards: { - food: foodItems, - drinks: drinkItems, - hygiene: hygieneItems, - care: careItems, - eggs: earnedEggs, - valentines: valentineItems.length > 0 ? valentineItems : undefined, - }, - totalItems: foodCount + drinkCount + hygieneCount + careCount + earnedEggs + valentineCount, - nextClaimTime: new Date(), - }; + return result; } } diff --git a/apps/server/src/requests/requests.service.ts b/apps/server/src/requests/requests.service.ts index b5dfe01..f3b4025 100644 --- a/apps/server/src/requests/requests.service.ts +++ b/apps/server/src/requests/requests.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { USER_POPULATE_FIELDS } from 'src/shared/constants'; +import { USER_POPULATE_FIELDS } from 'src/users/users.constants'; import { InjectModel } from '@nestjs/mongoose'; import { Model, ClientSession, Types } from 'mongoose'; import { Request, RequestDocument, RequestMetadata } from './entities/request.entity'; diff --git a/apps/server/src/shared/constants.ts b/apps/server/src/shared/constants.ts deleted file mode 100644 index 1be463b..0000000 --- a/apps/server/src/shared/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { LANGUAGES } from '@widgetable/types'; - -export const USER_POPULATE_FIELDS = '_id name email picture' as const; - -export const SUPPORTED_LANGUAGES = LANGUAGES.map((l) => l.code); - -export const PET_CONFIG = { - MAX_EXPEDITION_SLOTS_RATIO: 0.3, -} as const; - -export const WEBPUSH_CONFIG = { - INVALID_SUBSCRIPTION_CODES: [410, 404], - PRESIGNED_URL_EXPIRY_SECONDS: 3600, -} as const; diff --git a/apps/server/src/shared/rewards.service.ts b/apps/server/src/shared/rewards.service.ts deleted file mode 100644 index 50c9dba..0000000 --- a/apps/server/src/shared/rewards.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - ItemReward, - ItemTier, - PET_ACTIONS_BY_CATEGORY, - PetAction, - PetActionCategory, - REWARDS_CONFIG, - TIER_WEIGHTS, - VALENTINE_GIFT_ITEMS, -} from '@widgetable/types'; - -@Injectable() -export class RewardsService { - selectRandomItems(category: PetActionCategory, count: number): ItemReward[] { - const actions = PET_ACTIONS_BY_CATEGORY[category].filter((a) => a.inventoryCost !== undefined); - const itemCounts = new Map(); - - const itemsByTier = new Map(); - actions.forEach((action) => { - const tier = (action.inventoryCost || 1) as ItemTier; - if (!itemsByTier.has(tier)) itemsByTier.set(tier, []); - itemsByTier.get(tier)!.push(action); - }); - - for (let i = 0; i < count; i++) { - const tier = this.selectWeightedTier(); - const tierItems = itemsByTier.get(tier) || []; - if (tierItems.length === 0) continue; - - const availableItems = tierItems.filter( - (item) => (itemCounts.get(item.name) || 0) < REWARDS_CONFIG.MAX_DUPLICATE_ITEMS, - ); - const selectedItem = - availableItems.length > 0 - ? availableItems[Math.floor(Math.random() * availableItems.length)] - : tierItems[Math.floor(Math.random() * tierItems.length)]; - - itemCounts.set(selectedItem.name, (itemCounts.get(selectedItem.name) || 0) + 1); - } - - const result: ItemReward[] = []; - itemCounts.forEach((quantity, name) => { - const action = actions.find((a) => a.name === name)!; - result.push({ name, quantity, tier: (action.inventoryCost || 1) as ItemTier }); - }); - - return result; - } - - selectWeightedTier(): ItemTier { - const rand = Math.random() * 100; - if (rand < TIER_WEIGHTS[ItemTier.BASIC]) return ItemTier.BASIC; - if (rand < TIER_WEIGHTS[ItemTier.BASIC] + TIER_WEIGHTS[ItemTier.COMMON]) return ItemTier.COMMON; - if (rand < TIER_WEIGHTS[ItemTier.BASIC] + TIER_WEIGHTS[ItemTier.COMMON] + TIER_WEIGHTS[ItemTier.PREMIUM]) - return ItemTier.PREMIUM; - return ItemTier.LEGENDARY; - } - - isValentineSeason(): boolean { - return new Date().getMonth() === 1; // February - } - - selectRandomValentineItems(count: number): ItemReward[] { - const itemCounts = new Map(); - - const itemsByTier = new Map(); - VALENTINE_GIFT_ITEMS.forEach((item) => { - if (!itemsByTier.has(item.tier)) itemsByTier.set(item.tier, []); - itemsByTier.get(item.tier)!.push(item); - }); - - for (let i = 0; i < count; i++) { - const tier = this.selectWeightedTier(); - const tierItems = itemsByTier.get(tier) || []; - if (tierItems.length === 0) continue; - - const selected = tierItems[Math.floor(Math.random() * tierItems.length)]; - const existing = itemCounts.get(selected.name); - if (existing) { - existing.count++; - } else { - itemCounts.set(selected.name, { count: 1, tier: selected.tier }); - } - } - - const result: ItemReward[] = []; - itemCounts.forEach((data, name) => { - result.push({ name, quantity: data.count, tier: data.tier }); - }); - - return result; - } -} diff --git a/apps/server/src/shared/shared.module.ts b/apps/server/src/shared/shared.module.ts deleted file mode 100644 index e59142a..0000000 --- a/apps/server/src/shared/shared.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { RewardsService } from './rewards.service'; - -@Global() -@Module({ - providers: [RewardsService], - exports: [RewardsService], -}) -export class SharedModule {} diff --git a/apps/server/src/storage/storage.config.ts b/apps/server/src/storage/storage.config.ts index 940a79a..6178c43 100644 --- a/apps/server/src/storage/storage.config.ts +++ b/apps/server/src/storage/storage.config.ts @@ -26,3 +26,5 @@ export class StorageConfig { }; } } + +export const PRESIGNED_URL_EXPIRY_SECONDS = 3600; diff --git a/apps/server/src/users/entities/user.entity.ts b/apps/server/src/users/entities/user.entity.ts index 518f707..9977c38 100644 --- a/apps/server/src/users/entities/user.entity.ts +++ b/apps/server/src/users/entities/user.entity.ts @@ -1,5 +1,5 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { Database, DEFAULT_LANGUAGE, UserInventory } from '@widgetable/types'; +import { ClaimType, Database, DEFAULT_LANGUAGE, UserInventory } from '@widgetable/types'; import { Request } from 'express'; import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; @@ -23,11 +23,8 @@ export class User { @Prop({ type: Object, default: {} }) inventory?: UserInventory; - @Prop({ type: Date, required: false }) - lastDailyClaimTime?: Date; - - @Prop({ type: Date, required: false }) - lastQuickClaimTime?: Date; + @Prop({ type: Map, of: Date, default: {} }) + lastClaimTimes?: Map; @Prop({ default: DEFAULT_LANGUAGE }) language?: string; diff --git a/apps/server/src/users/users.constants.ts b/apps/server/src/users/users.constants.ts new file mode 100644 index 0000000..e607b35 --- /dev/null +++ b/apps/server/src/users/users.constants.ts @@ -0,0 +1 @@ +export const USER_POPULATE_FIELDS = '_id name email picture' as const; diff --git a/apps/server/src/users/users.service.ts b/apps/server/src/users/users.service.ts index 072664c..2c05495 100644 --- a/apps/server/src/users/users.service.ts +++ b/apps/server/src/users/users.service.ts @@ -1,13 +1,13 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { DEFAULT_LANGUAGE, EGG_ITEM_NAME } from '@widgetable/types'; +import { DEFAULT_LANGUAGE, EGG_ITEM_NAME, ItemBundle, SUPPORTED_LANGUAGES } from '@widgetable/types'; import * as Minio from 'minio'; import { ClientSession, Model, Types } from 'mongoose'; import * as path from 'path'; -import { SUPPORTED_LANGUAGES, USER_POPULATE_FIELDS, WEBPUSH_CONFIG } from 'src/shared/constants'; -import { STORAGE_CLIENT, StorageBucket } from 'src/storage/storage.config'; +import { PRESIGNED_URL_EXPIRY_SECONDS, STORAGE_CLIENT, StorageBucket } from 'src/storage/storage.config'; import { v4 as uuidv4 } from 'uuid'; import { User, UserDocument } from './entities/user.entity'; +import { USER_POPULATE_FIELDS } from './users.constants'; @Injectable() export class UsersService { @@ -42,11 +42,7 @@ export class UsersService { const user = await this.userModel.findById(userId); if (!user || !user.picture) throw new NotFoundException(); - return this.s3Client.presignedGetObject( - StorageBucket.AVATARS, - user.picture, - WEBPUSH_CONFIG.PRESIGNED_URL_EXPIRY_SECONDS, - ); + return this.s3Client.presignedGetObject(StorageBucket.AVATARS, user.picture, PRESIGNED_URL_EXPIRY_SECONDS); } async setImage(userId: Types.ObjectId, file: Express.Multer.File): Promise { @@ -145,37 +141,18 @@ export class UsersService { return updatedUser; } - async applyRewards( - userId: Types.ObjectId, - rewards: { - food: Array<{ name: string; quantity: number }>; - drinks: Array<{ name: string; quantity: number }>; - hygiene: Array<{ name: string; quantity: number }>; - care: Array<{ name: string; quantity: number }>; - eggs: number; - valentines?: Array<{ name: string; quantity: number }>; - }, - session?: ClientSession, - ) { - for (const item of rewards.food) { - await this.addInventory(userId, item.name, item.quantity, session); - } - for (const item of rewards.drinks) { - await this.addInventory(userId, item.name, item.quantity, session); - } - for (const item of rewards.hygiene) { - await this.addInventory(userId, item.name, item.quantity, session); - } - for (const item of rewards.care) { - await this.addInventory(userId, item.name, item.quantity, session); - } - if (rewards.eggs > 0) { - await this.addInventory(userId, EGG_ITEM_NAME, rewards.eggs, session); + async addInventoryBundle(userId: Types.ObjectId, items: ItemBundle, session?: ClientSession) { + const inc: Record = {}; + + const allItems = [...items.food, ...items.drinks, ...items.hygiene, ...items.care, ...(items.valentines ?? [])]; + for (const item of allItems) { + inc[`inventory.${item.name}`] = (inc[`inventory.${item.name}`] ?? 0) + item.quantity; } - if (rewards.valentines) { - for (const item of rewards.valentines) { - await this.addInventory(userId, item.name, item.quantity, session); - } + if (items.eggs > 0) { + inc[`inventory.${EGG_ITEM_NAME}`] = (inc[`inventory.${EGG_ITEM_NAME}`] ?? 0) + items.eggs; } + + const updatedUser = await this.userModel.findByIdAndUpdate(userId, { $inc: inc }, { new: true, session }); + if (!updatedUser) throw new NotFoundException(); } } diff --git a/packages/i18n/locales/en.json b/packages/i18n/locales/en.json index c60220c..70bf340 100644 --- a/packages/i18n/locales/en.json +++ b/packages/i18n/locales/en.json @@ -1,326 +1,295 @@ { - "common": { - "loading": "Loading...", - "calculating": "Calculating..." - }, - "auth": { - "welcomeBack": "Welcome Back", - "createAccount": "Create Account", - "login": "Login", - "register": "Register", - "email": "Email", - "password": "Password", - "noAccount": "Don't have an account?", - "hasAccount": "Already have an account?", - "failedAuth": "Failed to authenticate" - }, - "account": { - "profile": "Profile", - "carePackages": "Treat Box", - "carePackagesDesc": "Collect treats for your pets!", - "logOut": "Log Out", - "usernamePlaceholder": "User Name", - "usernameUpdated": "Username updated", - "failedUpdateName": "Failed to update name", - "failedUploadImage": "Failed to upload image", - "selectImage": "Please select an image file", - "imageTooLarge": "Image size should be less than 5MB", - "language": "Language", - "notifications": "Notifications", - "enableNotifications": "Enable Notifications", - "disableNotifications": "Disable Notifications", - "notificationsNotSupported": "Notifications are not supported in this browser", - "notificationsDenied": "Notifications are blocked. Please enable them in browser settings.", - "serviceWorkerNotAvailable": "Service worker not available", - "failedEnableNotifications": "Failed to enable notifications" - }, - "pets": { - "title": "Pets", - "addPet": "Add Pet", - "egg": "Egg", - "level": "Level {level}", - "xp": "{current}/{next} XP", - "hatching": "Hatching...", - "hatchingIn": "Hatching in {time}", - "ready": "Ready!", - "needEggs": "You need eggs to add a pet!", - "clickToAdd": "Click the button below to add a pet!", - "expedition": "Expedition", - "background": "Background", - "invite": "Invite", - "selectBackground": "Select Background", - "random": "Random", - "bgLabel": "Background {id}", - "onExpedition": "{name} is on an expedition", - "backFromExpedition": "{name} is back from an expedition!", - "willReturn": "Will return in {time}", - "claimRewards": "Claim Rewards", - "departedExpedition": "{name} departed on an expedition!", - "returnedSuccess": "{name} returned successfully!", - "failedStartExpedition": "Failed to start expedition", - "expeditionEggError": "Eggs cannot go on expeditions", - "expeditionSlotsFull": "All expedition slots are full", - "failedClaimRewards": "Failed to claim rewards", - "urgentNeeds": "{name} has urgent needs! Take care of {needs} first.", - "isBusy": "{name} is busy", - "noLongerParent": "You no longer parent {name}", - "type": { - "fox": "Fox", - "cat": "Cat", - "dog": "Dog", - "bunny": "Bunny", - "chicken": "Chicken", - "panda": "Panda", - "turtle": "Turtle", - "parrot": "Parrot" - }, - "category": { - "Feed": "Feed", - "Drink": "Drink", - "Wash": "Wash", - "Care": "Care" - }, - "needs": { - "hygiene": "Hygiene", - "hunger": "Hunger", - "thirst": "Thirst", - "energy": "Energy", - "toilet": "Toilet", - "urgency": { - "hygiene": "I need a bath!", - "hunger": "I'm hungry!", - "thirst": "I'm thirsty!", - "energy": "I'm tired!", - "toilet": "I need to go to the toilet!" - } - }, - "happy": { - "0": "I'm happy!", - "1": "Life is good!", - "2": "I'm feeling great!", - "3": "This is awesome!", - "4": "I love you!", - "5": "You're the best!", - "6": "I'm so content!", - "7": "Everything is perfect!" - }, - "usernameVariation": { - "0": "Hey {username}, {message}!", - "1": "{username}, {message}!", - "2": "{message}, {username}!", - "3": "{username}! {message}!" - } - }, - "friends": { - "title": "Friends", - "searchPlaceholder": "Search users...", - "noUsersFound": "No users found", - "searchToAdd": "Search for users above to add friends!", - "pending": "Pending", - "wantsToShare": "{name} wants to share", - "sharedWith": "Shared with {name}", - "failedLoad": "Failed to load friends", - "searchFailed": "Search failed", - "requestSent": "Friend request sent", - "failedSendRequest": "Failed to send request", - "requestCancelled": "Request cancelled", - "failedCancelRequest": "Failed to cancel request", - "friendAdded": "Friend added", - "failedAcceptRequest": "Failed to accept request", - "requestDeclined": "Request declined", - "failedDeclineRequest": "Failed to decline request", - "friendRemoved": "Friend removed", - "failedRemoveFriend": "Failed to remove friend" - }, - "invite": { - "title": "Invite a Friend", - "noFriends": "No friends can be invited", - "findMore": "Find more friends", - "sent": "Coparenting invitation sent to {name}", - "userNotFound": "User or pet not found", - "cannotSend": "Cannot send coparenting request", - "failedSend": "Failed to send coparenting invitation" - }, - "coparenting": { - "failedLoadRequests": "Failed to load requests", - "requestAccepted": "Request accepted", - "failedAccept": "Failed to accept", - "requestDeclined": "Request declined", - "failedDecline": "Failed to decline", - "requestCancelled": "Request cancelled", - "failedCancel": "Failed to cancel" - }, - "claims": { - "collecting": "Collecting...", - "dailyCarePackage": "Daily Treat Box", - "quickCarePackage": "Quick Treat Box", - "carePackage": "Treat Box", - "debugClaim": "Debug Claim", - "claimRewards": "Claim Rewards", - "nextIn": "Next in:", - "failedLoad": "Failed to load claim status", - "failedClaim": "Failed to claim rewards. Please try again." - }, - "rewards": { - "title": "Treat Box Rewards!", - "food": "Food", - "drinks": "Drinks", - "hygiene": "Hygiene", - "care": "Care", - "items": "{count} items", - "bonus": "Bonus", - "luckyFind": "Lucky find!", - "awesome": "AWESOME!", - "valentines": "Valentines" - }, - "action": { - "Sushi": "Sushi", - "Hamburger": "Hamburger", - "Lasagna": "Lasagna", - "Bento Box": "Bento Box", - "Pizza": "Pizza", - "Ramen": "Ramen", - "Taco": "Taco", - "Burrito": "Burrito", - "Sandwich": "Sandwich", - "Hot Dog": "Hot Dog", - "Soup": "Soup", - "Rice Bowl": "Rice Bowl", - "Mac & Cheese": "Mac & Cheese", - "Watermelon": "Watermelon", - "Mango": "Mango", - "Strawberry": "Strawberry", - "Cookie": "Cookie", - "Brownie": "Brownie", - "Donut": "Donut", - "French Fries": "French Fries", - "Orange Juice": "Orange Juice", - "Apple Cider": "Apple Cider", - "Fruit Parfait": "Fruit Parfait", - "Latte": "Latte", - "Milk": "Milk", - "Hot Cocoa": "Hot Cocoa", - "Fruit Tea": "Fruit Tea", - "Cappuccino": "Cappuccino", - "Bath Salts": "Bath Salts", - "Bathtub": "Bathtub", - "Shampoo": "Shampoo", - "Shower": "Shower", - "Mouth Wash": "Mouth Wash", - "Hand Soap": "Hand Soap", - "Brush": "Brush", - "Wipe Down": "Wipe Down", - "Sponge": "Sponge", - "Toilet": "Toilet", - "Quick Potty": "Quick Potty", - "Long Sleep": "Long Sleep", - "Nap": "Nap", - "Rest": "Rest", - "Grooming Session": "Grooming Session", - "Nail Trim": "Nail Trim", - "Ear Cleaning": "Ear Cleaning", - "Chocolate Bar Red": "Red Chocolate", - "Chocolate Bar Brown": "Chocolate Bar", - "Chocolate Bar Blue": "Blue Chocolate", - "Chocolate Bar White": "White Chocolate", - "Chocolate Bar Duo": "Duo Chocolate", - "Chocolate": "Chocolate", - "Letter Sealed Red Heart": "Red Heart Letter", - "Letter Sealed Red Border": "Red Border Letter", - "Letter Sealed Pink": "Pink Letter", - "Letter Sealed Small Heart": "Small Heart Letter", - "Letter Sealed Orange": "Orange Letter", - "Letter Sealed Pink Heart": "Pink Heart Letter", - "Letter Sealed Brown": "Brown Letter", - "Letter Sealed Amber": "Amber Letter", - "Envelope White": "White Envelope", - "Envelope Pink": "Pink Envelope" - }, - "gifts": { - "title": "Send a Gift", - "send": "Send", - "noItems": "No valentine items in your inventory", - "noItemsHint": "Collect valentines from care packages and expeditions!", - "sent": "Gift sent to {name}!", - "failedSend": "Failed to send gift", - "sendGift": "Gift" - }, - "valentine": { - "question": "Will you be my Valentine?", - "yes": "Yes!", - "no": "No", - "yay": "Yuppie!", - "present": "I have prepared a little present for you!", - "takeLook": "Take a look" - }, - "pwa": { - "warning": "Best as a PWA", - "description": "This app is designed for PWA and may not work as expected in a browser.", - "iosTitle": "Install on iOS:", - "iosStep1": "Tap the Share button in Safari", - "iosStep2": "Scroll down and tap \"Add to Home Screen\"", - "iosStep3": "Tap \"Add\" to confirm", - "androidTitle": "Install on Android:", - "androidStep1": "Tap the menu (⋮) in Chrome", - "androidStep2": "Tap \"Add to Home Screen\" or \"Install App\"", - "desktopTitle": "Install on Desktop:", - "desktopStep1": "Click the install icon (⊕) in the address bar", - "desktopStep2": "Click \"Install\" to confirm", - "continueAnyway": "Continue Anyway" - }, - "notif": { - "urgent": { - "title": { - "one": "{name} is sad!", - "many": "Your pets are sad!" - }, - "body": { - "one": "{name} needs your attention!", - "many": "{count} pets need your attention!" - } - }, - "zero": { - "title": "Your pets need you!", - "body": { - "one": "{name} needs your attention — all needs are at zero!", - "many": "{count} pets need your attention!" - } - }, - "expedition": { - "title": "Expedition Complete!", - "body": { - "one": "{name} has returned from the expedition!", - "many": "{count} pets have returned from their expeditions!" - } - }, - "egg": { - "title": "Egg Hatched!", - "body": { - "one": "{name} has hatched!", - "many": "{count} eggs have hatched!" - } - }, - "claims": { - "title": "Goodies Available!", - "both": "Your daily and quick goodies are ready to collect!", - "daily": "Your daily goodies are ready to collect!", - "quick": "Your quick goodies are ready to collect!" - }, - "test": { - "title": "Test Notification", - "body": "If you see this, push notifications are working!" - }, - "friend": { - "title": "New Friend Request", - "body": "{sender} sent you a friend request!" - }, - "coparenting": { - "title": "Co-Parenting Invite", - "body": "{sender} wants to co-parent {pet} with you!" - }, - "gift": { - "title": "Valentine Gift!", - "body": "{sender} sent you a valentine!" - } - } -} + "common": { + "loading": "Loading...", + "calculating": "Calculating..." + }, + "auth": { + "welcomeBack": "Welcome Back", + "createAccount": "Create Account", + "login": "Login", + "register": "Register", + "email": "Email", + "password": "Password", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "failedAuth": "Failed to authenticate" + }, + "account": { + "profile": "Profile", + "carePackages": "Treat Box", + "carePackagesDesc": "Collect treats for your pets!", + "logOut": "Log Out", + "usernamePlaceholder": "User Name", + "usernameUpdated": "Username updated", + "failedUpdateName": "Failed to update name", + "failedUploadImage": "Failed to upload image", + "selectImage": "Please select an image file", + "imageTooLarge": "Image size should be less than 5MB", + "language": "Language", + "notifications": "Notifications", + "enableNotifications": "Enable Notifications", + "disableNotifications": "Disable Notifications", + "notificationsNotSupported": "Notifications are not supported in this browser", + "notificationsDenied": "Notifications are blocked. Please enable them in browser settings.", + "serviceWorkerNotAvailable": "Service worker not available", + "failedEnableNotifications": "Failed to enable notifications" + }, + "pets": { + "title": "Pets", + "addPet": "Add Pet", + "egg": "Egg", + "level": "Level {level}", + "xp": "{current}/{next} XP", + "hatching": "Hatching...", + "hatchingIn": "Hatching in {time}", + "ready": "Ready!", + "needEggs": "You need eggs to add a pet!", + "clickToAdd": "Click the button below to add a pet!", + "expedition": "Expedition", + "background": "Background", + "invite": "Invite", + "selectBackground": "Select Background", + "random": "Random", + "bgLabel": "Background {id}", + "onExpedition": "{name} is on an expedition", + "backFromExpedition": "{name} is back from an expedition!", + "willReturn": "Will return in {time}", + "claimRewards": "Claim Rewards", + "departedExpedition": "{name} departed on an expedition!", + "returnedSuccess": "{name} returned successfully!", + "failedStartExpedition": "Failed to start expedition", + "expeditionEggError": "Eggs cannot go on expeditions", + "expeditionSlotsFull": "All expedition slots are full", + "failedClaimRewards": "Failed to claim rewards", + "urgentNeeds": "{name} has urgent needs! Take care of {needs} first.", + "isBusy": "{name} is busy", + "noLongerParent": "You no longer parent {name}", + "type": { + "fox": "Fox", + "cat": "Cat", + "dog": "Dog", + "bunny": "Bunny", + "chicken": "Chicken", + "panda": "Panda", + "turtle": "Turtle", + "parrot": "Parrot" + }, + "category": { + "Feed": "Feed", + "Drink": "Drink", + "Wash": "Wash", + "Care": "Care" + }, + "needs": { + "hygiene": "Hygiene", + "hunger": "Hunger", + "thirst": "Thirst", + "energy": "Energy", + "toilet": "Toilet", + "urgency": { + "hygiene": "I need a bath!", + "hunger": "I'm hungry!", + "thirst": "I'm thirsty!", + "energy": "I'm tired!", + "toilet": "I need to go to the toilet!" + } + }, + "happy": { + "0": "I'm happy!", + "1": "Life is good!", + "2": "I'm feeling great!", + "3": "This is awesome!", + "4": "I love you!", + "5": "You're the best!", + "6": "I'm so content!", + "7": "Everything is perfect!" + }, + "usernameVariation": { + "0": "Hey {username}, {message}!", + "1": "{username}, {message}!", + "2": "{message}, {username}!", + "3": "{username}! {message}!" + } + }, + "friends": { + "title": "Friends", + "searchPlaceholder": "Search users...", + "noUsersFound": "No users found", + "searchToAdd": "Search for users above to add friends!", + "pending": "Pending", + "wantsToShare": "{name} wants to share", + "sharedWith": "Shared with {name}", + "failedLoad": "Failed to load friends", + "searchFailed": "Search failed", + "requestSent": "Friend request sent", + "failedSendRequest": "Failed to send request", + "requestCancelled": "Request cancelled", + "failedCancelRequest": "Failed to cancel request", + "friendAdded": "Friend added", + "failedAcceptRequest": "Failed to accept request", + "requestDeclined": "Request declined", + "failedDeclineRequest": "Failed to decline request", + "friendRemoved": "Friend removed", + "failedRemoveFriend": "Failed to remove friend" + }, + "invite": { + "title": "Invite a Friend", + "noFriends": "No friends can be invited", + "findMore": "Find more friends", + "sent": "Coparenting invitation sent to {name}", + "userNotFound": "User or pet not found", + "cannotSend": "Cannot send coparenting request", + "failedSend": "Failed to send coparenting invitation" + }, + "coparenting": { + "failedLoadRequests": "Failed to load requests", + "requestAccepted": "Request accepted", + "failedAccept": "Failed to accept", + "requestDeclined": "Request declined", + "failedDecline": "Failed to decline", + "requestCancelled": "Request cancelled", + "failedCancel": "Failed to cancel" + }, + "action": { + "sushi": "Sushi", + "hamburger": "Hamburger", + "pizza": "Pizza", + "chocolateBar": "Chocolate Bar", + "cookie": "Cookie", + "donut": "Donut", + "watermelon": "Watermelon", + "mango": "Mango", + "strawberry": "Strawberry", + "orangeJuice": "Orange Juice", + "fruitTea": "Fruit Tea", + "appleCider": "Apple Cider", + "latte": "Latte", + "hotCocoa": "Hot Cocoa", + "milk": "Milk", + "bathtub": "Bathtub", + "shower": "Shower", + "handSoap": "Hand Soap", + "brush": "Brush", + "earCleaning": "Ear Cleaning", + "nailTrim": "Nail Trim", + "toilet": "Toilet", + "quickPotty": "Quick Potty", + "longSleep": "Long Sleep", + "nap": "Nap", + "rest": "Rest", + "letterSealedRedHeart": "Red Heart Letter", + "letterSealedRedBorder": "Red Border Letter", + "letterSealedPink": "Pink Letter", + "letterSealedSmallHeart": "Small Heart Letter", + "letterSealedOrange": "Orange Letter", + "letterSealedPinkHeart": "Pink Heart Letter", + "letterSealedBrown": "Brown Letter", + "letterSealedAmber": "Amber Letter", + "envelopeWhite": "White Envelope", + "envelopePink": "Pink Envelope" + }, + "gifts": { + "title": "Send a Gift", + "send": "Send", + "noItems": "No valentine items in your inventory", + "noItemsHint": "Collect valentines from care packages and expeditions!", + "sent": "Gift sent to {name}!", + "failedSend": "Failed to send gift", + "sendGift": "Gift" + }, + "valentine": { + "question": "Will you be my Valentine?", + "yes": "Yes!", + "no": "No", + "yay": "Yuppie!", + "present": "I have prepared a little present for you!", + "takeLook": "Take a look" + }, + "pwa": { + "warning": "Best as a PWA", + "description": "This app is designed for PWA and may not work as expected in a browser.", + "iosTitle": "Install on iOS:", + "iosStep1": "Tap the Share button in Safari", + "iosStep2": "Scroll down and tap \"Add to Home Screen\"", + "iosStep3": "Tap \"Add\" to confirm", + "androidTitle": "Install on Android:", + "androidStep1": "Tap the menu (⋮) in Chrome", + "androidStep2": "Tap \"Add to Home Screen\" or \"Install App\"", + "desktopTitle": "Install on Desktop:", + "desktopStep1": "Click the install icon (⊕) in the address bar", + "desktopStep2": "Click \"Install\" to confirm", + "continueAnyway": "Continue Anyway" + }, + "notif": { + "urgent": { + "title": { + "one": "{name} is sad!", + "many": "Your pets are sad!" + }, + "body": { + "one": "{name} needs your attention!", + "many": "{count} pets need your attention!" + } + }, + "zero": { + "title": "Your pets need you!", + "body": { + "one": "{name} needs your attention — all needs are at zero!", + "many": "{count} pets need your attention!" + } + }, + "expedition": { + "title": "Expedition Complete!", + "body": { + "one": "{name} has returned from the expedition!", + "many": "{count} pets have returned from their expeditions!" + } + }, + "egg": { + "title": "Egg Hatched!", + "body": { + "one": "{name} has hatched!", + "many": "{count} eggs have hatched!" + } + }, + "friend": { + "title": "New Friend Request", + "body": "{sender} sent you a friend request!" + }, + "coparenting": { + "title": "Co-Parenting Invite", + "body": "{sender} wants to co-parent {pet} with you!" + }, + "gift": { + "title": "Valentine Gift!", + "body": "{sender} sent you a valentine!" + }, + "items": { + "title": "Goodies Available!", + "daily": "Your daily goodies are ready to collect!", + "quick": "Your quick goodies are ready to collect!", + "all": "Your daily and quick goodies are ready to collect!" + } + }, + "items": { + "collecting": "Collecting...", + "dailyCarePackage": "Daily Treat Box", + "quickCarePackage": "Quick Treat Box", + "carePackage": "Treat Box", + "debugClaim": "Debug Claim", + "claimRewards": "Claim Rewards", + "nextIn": "Next in:", + "failedLoad": "Failed to load claim status", + "failedClaim": "Failed to claim rewards. Please try again.", + "title": "Treat Box Rewards!", + "food": "Food", + "drinks": "Drinks", + "hygiene": "Hygiene", + "care": "Care", + "count": "{count} items", + "bonus": "Bonus", + "luckyFind": "Lucky find!", + "awesome": "AWESOME!", + "valentines": "Valentines" + } +} \ No newline at end of file diff --git a/packages/i18n/locales/ru.json b/packages/i18n/locales/ru.json index 93f9604..e49084c 100644 --- a/packages/i18n/locales/ru.json +++ b/packages/i18n/locales/ru.json @@ -1,326 +1,299 @@ { - "common": { - "loading": "Загрузка...", - "calculating": "Подсчёт..." - }, - "auth": { - "welcomeBack": "С возвращением!", - "createAccount": "Создать аккаунт", - "login": "Войти", - "register": "Регистрация", - "email": "Эл. почта", - "password": "Пароль", - "noAccount": "Нет аккаунта?", - "hasAccount": "Уже есть аккаунт?", - "failedAuth": "Ошибка аутентификации" - }, - "account": { - "profile": "Профиль", - "carePackages": "Припасы", - "carePackagesDesc": "Собирайте припасы для питомцев!", - "logOut": "Выйти", - "usernamePlaceholder": "Имя пользователя", - "usernameUpdated": "Имя обновлено", - "failedUpdateName": "Не удалось обновить имя", - "failedUploadImage": "Не удалось загрузить изображение", - "selectImage": "Пожалуйста, выберите изображение", - "imageTooLarge": "Размер изображения не должен превышать 5 МБ", - "language": "Язык", - "notifications": "Уведомления", - "enableNotifications": "Включить уведомления", - "disableNotifications": "Выключить уведомления", - "notificationsNotSupported": "Уведомления не поддерживаются в этом браузере", - "notificationsDenied": "Уведомления заблокированы. Включите их в настройках браузера.", - "serviceWorkerNotAvailable": "Service worker недоступен", - "failedEnableNotifications": "Не удалось включить уведомления" - }, - "pets": { - "title": "Питомцы", - "addPet": "Добавить", - "egg": "Яйцо", - "level": "Уровень {level}", - "xp": "{current}/{next} XP", - "hatching": "Вылупляется...", - "hatchingIn": "Вылупится через {time}", - "ready": "Вернулся!", - "needEggs": "Нужны яйца, чтобы добавить питомца!", - "clickToAdd": "Нажмите кнопку ниже, чтобы добавить питомца!", - "expedition": "Охота", - "background": "Фон", - "invite": "Пригласить", - "selectBackground": "Выбрать фон", - "random": "Случайный", - "bgLabel": "Фон {id}", - "onExpedition": "{name} на охоте", - "backFromExpedition": "{name} вернулся с охоты!", - "willReturn": "Вернётся через {time}", - "claimRewards": "Забрать награды", - "departedExpedition": "{name} отправился на охоту!", - "returnedSuccess": "{name} успешно вернулся!", - "failedStartExpedition": "Не удалось начать охоту", - "expeditionEggError": "Яйца не могут отправляться на охоту", - "expeditionSlotsFull": "Все слоты для охоты заняты", - "failedClaimRewards": "Не удалось забрать награды", - "urgentNeeds": "У {name} срочные нужды! Позаботьтесь о: {needs}.", - "isBusy": "{name} занят", - "noLongerParent": "Вы больше не опекаете {name}", - "type": { - "fox": "Лиса", - "cat": "Кот", - "dog": "Собака", - "bunny": "Кролик", - "chicken": "Курица", - "panda": "Панда", - "turtle": "Черепаха", - "parrot": "Попугай" - }, - "category": { - "Feed": "Еда", - "Drink": "Напитки", - "Wash": "Гигиена", - "Care": "Уход" - }, - "needs": { - "hygiene": "Гигиена", - "hunger": "Голод", - "thirst": "Жажда", - "energy": "Энергия", - "toilet": "Туалет", - "urgency": { - "hygiene": "Мне нужно помыться!", - "hunger": "Я голоден!", - "thirst": "Я хочу пить!", - "energy": "Я устал!", - "toilet": "Мне нужно в туалет!" - } - }, - "happy": { - "0": "Я счастлив!", - "1": "Жизнь прекрасна!", - "2": "Я отлично себя чувствую!", - "3": "Всё хорошо!", - "4": "Я тебя люблю!", - "5": "Ты лучший!", - "6": "У меня всё хорошо!", - "7": "Всё идеально!" - }, - "usernameVariation": { - "0": "{username}, {message}!", - "1": "{username}, {message}!", - "2": "{message}, {username}!", - "3": "{username}! {message}!" - } - }, - "friends": { - "title": "Друзья", - "searchPlaceholder": "Поиск пользователей...", - "noUsersFound": "Пользователи не найдены", - "searchToAdd": "Найдите пользователей выше, чтобы добавить друзей!", - "pending": "Ожидание", - "wantsToShare": "{name} хочет поделиться", - "sharedWith": "Поделились с {name}", - "failedLoad": "Не удалось загрузить друзей", - "searchFailed": "Поиск не удался", - "requestSent": "Запрос в друзья отправлен", - "failedSendRequest": "Не удалось отправить запрос", - "requestCancelled": "Запрос отменён", - "failedCancelRequest": "Не удалось отменить запрос", - "friendAdded": "Друг добавлен", - "failedAcceptRequest": "Не удалось принять запрос", - "requestDeclined": "Запрос отклонён", - "failedDeclineRequest": "Не удалось отклонить запрос", - "friendRemoved": "Друг удалён", - "failedRemoveFriend": "Не удалось удалить друга" - }, - "invite": { - "title": "Пригласить друга", - "noFriends": "Нет доступных друзей", - "findMore": "Найти больше друзей", - "sent": "Приглашение отправлено {name}", - "userNotFound": "Пользователь или питомец не найден", - "cannotSend": "Невозможно отправить запрос", - "failedSend": "Не удалось отправить приглашение" - }, - "coparenting": { - "failedLoadRequests": "Не удалось загрузить запросы", - "requestAccepted": "Запрос принят", - "failedAccept": "Не удалось принять", - "requestDeclined": "Запрос отклонён", - "failedDecline": "Не удалось отклонить", - "requestCancelled": "Запрос отменён", - "failedCancel": "Не удалось отменить" - }, - "claims": { - "collecting": "Собираю...", - "dailyCarePackage": "Ежедневные припасы", - "quickCarePackage": "Быстрые припасы", - "carePackage": "Припасы", - "debugClaim": "Отладочный пакет", - "claimRewards": "Забрать награды", - "nextIn": "Следующие через:", - "failedLoad": "Не удалось загрузить статус наград", - "failedClaim": "Не удалось забрать награды. Попробуйте снова." - }, - "rewards": { - "title": "Награды из припасов!", - "food": "Еда", - "drinks": "Напитки", - "hygiene": "Гигиена", - "care": "Уход", - "items": "{count} предм.", - "bonus": "Бонус", - "luckyFind": "Повезло!", - "awesome": "ОТЛИЧНО!", - "valentines": "Валентинки" - }, - "action": { - "Sushi": "Суши", - "Hamburger": "Гамбургер", - "Lasagna": "Лазанья", - "Bento Box": "Бенто", - "Pizza": "Пицца", - "Ramen": "Рамен", - "Taco": "Тако", - "Burrito": "Буррито", - "Sandwich": "Сэндвич", - "Hot Dog": "Хот-дог", - "Soup": "Суп", - "Rice Bowl": "Рис", - "Mac & Cheese": "Макароны", - "Watermelon": "Арбуз", - "Mango": "Манго", - "Strawberry": "Клубника", - "Cookie": "Печенье", - "Brownie": "Брауни", - "Donut": "Пончик", - "French Fries": "Картошка фри", - "Orange Juice": "Апельсиновый сок", - "Apple Cider": "Яблочный сидр", - "Fruit Parfait": "Фруктовое парфе", - "Latte": "Латте", - "Milk": "Молоко", - "Hot Cocoa": "Горячий шоколад", - "Fruit Tea": "Фруктовый чай", - "Cappuccino": "Капучино", - "Bath Salts": "Соль для ванны", - "Bathtub": "Ванна", - "Shampoo": "Шампунь", - "Shower": "Душ", - "Mouth Wash": "Ополаскиватель", - "Hand Soap": "Мыло", - "Brush": "Щётка", - "Wipe Down": "Салфетки", - "Sponge": "Губка", - "Toilet": "Туалет", - "Quick Potty": "Горшок", - "Long Sleep": "Долгий сон", - "Nap": "Дремота", - "Rest": "Отдых", - "Grooming Session": "Груминг", - "Nail Trim": "Стрижка когтей", - "Ear Cleaning": "Чистка ушей", - "Chocolate Bar Red": "Красная шоколадка", - "Chocolate Bar Brown": "Шоколадка", - "Chocolate Bar Blue": "Голубая шоколадка", - "Chocolate Bar White": "Белый шоколад", - "Chocolate Bar Duo": "Двойная шоколадка", - "Chocolate": "Шоколадные конфеты", - "Letter Sealed Red Heart": "Письмо с сердцем", - "Letter Sealed Red Border": "Красное письмо", - "Letter Sealed Pink": "Розовое письмо", - "Letter Sealed Small Heart": "Письмо с сердечком", - "Letter Sealed Orange": "Оранжевое письмо", - "Letter Sealed Pink Heart": "Розовое письмо с сердцем", - "Letter Sealed Brown": "Коричневое письмо", - "Letter Sealed Amber": "Янтарное письмо", - "Envelope White": "Белый конверт", - "Envelope Pink": "Розовый конверт" - }, - "gifts": { - "title": "Отправить подарок", - "send": "Отправить", - "noItems": "Нет валентинок в инвентаре", - "noItemsHint": "Собирайте валентинки из припасов и с охоты!", - "sent": "Подарок отправлен {name}!", - "failedSend": "Не удалось отправить подарок", - "sendGift": "Подарок" - }, - "valentine": { - "question": "Будешь моей Валентинкой?", - "yes": "Да!", - "no": "Нет", - "yay": "Ура!", - "present": "У меня для тебя небольшой подарок :)", - "takeLook": "Посмотреть" - }, - "pwa": { - "warning": "Лучше как PWA", - "description": "Это приложение создано для PWA и может работать некорректно в браузере.", - "iosTitle": "Установка на iOS:", - "iosStep1": "Нажмите кнопку «Поделиться» в Safari", - "iosStep2": "Прокрутите вниз и нажмите «На экран Домой»", - "iosStep3": "Нажмите «Добавить» для подтверждения", - "androidTitle": "Установка на Android:", - "androidStep1": "Нажмите меню (⋮) в Chrome", - "androidStep2": "Нажмите «Добавить на главный экран» или «Установить»", - "desktopTitle": "Установка на ПК:", - "desktopStep1": "Нажмите значок установки (⊕) в адресной строке", - "desktopStep2": "Нажмите «Установить» для подтверждения", - "continueAnyway": "Продолжить" - }, - "notif": { - "urgent": { - "title": { - "one": "{name} грустит!", - "many": "Ваши питомцы грустят!" - }, - "body": { - "one": "{name} нуждается в вашем внимании!", - "many": "{count} питомцев нуждаются в вашем внимании!" - } - }, - "zero": { - "title": "Ваши питомцы ждут вас!", - "body": { - "one": "Все показатели {name} на нуле!", - "many": "{count} питомцев нуждаются в вашем внимании!" - } - }, - "expedition": { - "title": "Охота завершена!", - "body": { - "one": "{name} вернулся с охоты!", - "many": "{count} питомцев вернулись с охоты!" - } - }, - "egg": { - "title": "Яйцо вылупилось!", - "body": { - "one": "{name} вылупился!", - "many": "{count} яиц вылупились!" - } - }, - "claims": { - "title": "Припасы готовы!", - "both": "Ежедневные и быстрые припасы готовы к сбору!", - "daily": "Ежедневные припасы готовы к сбору!", - "quick": "Быстрые припасы готовы к сбору!" - }, - "test": { - "title": "Тестовое уведомление", - "body": "Если вы видите это, уведомления работают!" - }, - "friend": { - "title": "Новая заявка в друзья", - "body": "{sender} отправил вам заявку в друзья!" - }, - "coparenting": { - "title": "Приглашение к совместному уходу", - "body": "{sender} хочет совместно ухаживать за {pet}!" - }, - "gift": { - "title": "Валентинка!", - "body": "{sender} отправил вам валентинку!" - } - } -} + "common": { + "loading": "Загрузка...", + "calculating": "Подсчёт..." + }, + "auth": { + "welcomeBack": "С возвращением!", + "createAccount": "Создать аккаунт", + "login": "Войти", + "register": "Регистрация", + "email": "Эл. почта", + "password": "Пароль", + "noAccount": "Нет аккаунта?", + "hasAccount": "Уже есть аккаунт?", + "failedAuth": "Ошибка аутентификации" + }, + "account": { + "profile": "Профиль", + "carePackages": "Припасы", + "carePackagesDesc": "Собирайте припасы для питомцев!", + "logOut": "Выйти", + "usernamePlaceholder": "Имя пользователя", + "usernameUpdated": "Имя обновлено", + "failedUpdateName": "Не удалось обновить имя", + "failedUploadImage": "Не удалось загрузить изображение", + "selectImage": "Пожалуйста, выберите изображение", + "imageTooLarge": "Размер изображения не должен превышать 5 МБ", + "language": "Язык", + "notifications": "Уведомления", + "enableNotifications": "Включить уведомления", + "disableNotifications": "Выключить уведомления", + "notificationsNotSupported": "Уведомления не поддерживаются в этом браузере", + "notificationsDenied": "Уведомления заблокированы. Включите их в настройках браузера.", + "serviceWorkerNotAvailable": "Service worker недоступен", + "failedEnableNotifications": "Не удалось включить уведомления" + }, + "pets": { + "title": "Питомцы", + "addPet": "Добавить", + "egg": "Яйцо", + "level": "Уровень {level}", + "xp": "{current}/{next} XP", + "hatching": "Вылупляется...", + "hatchingIn": "Вылупится через {time}", + "ready": "Вернулся!", + "needEggs": "Нужны яйца, чтобы добавить питомца!", + "clickToAdd": "Нажмите кнопку ниже, чтобы добавить питомца!", + "expedition": "Охота", + "background": "Фон", + "invite": "Пригласить", + "selectBackground": "Выбрать фон", + "random": "Случайный", + "bgLabel": "Фон {id}", + "onExpedition": "{name} на охоте", + "backFromExpedition": "{name} вернулся с охоты!", + "willReturn": "Вернётся через {time}", + "claimRewards": "Забрать награды", + "departedExpedition": "{name} отправился на охоту!", + "returnedSuccess": "{name} успешно вернулся!", + "failedStartExpedition": "Не удалось начать охоту", + "expeditionEggError": "Яйца не могут отправляться на охоту", + "expeditionSlotsFull": "Все слоты для охоты заняты", + "failedClaimRewards": "Не удалось забрать награды", + "urgentNeeds": "У {name} срочные нужды! Позаботьтесь о: {needs}.", + "isBusy": "{name} занят", + "noLongerParent": "Вы больше не опекаете {name}", + "type": { + "fox": "Лиса", + "cat": "Кот", + "dog": "Собака", + "bunny": "Кролик", + "chicken": "Курица", + "panda": "Панда", + "turtle": "Черепаха", + "parrot": "Попугай" + }, + "category": { + "Feed": "Еда", + "Drink": "Напитки", + "Wash": "Гигиена", + "Care": "Уход" + }, + "needs": { + "hygiene": "Гигиена", + "hunger": "Голод", + "thirst": "Жажда", + "energy": "Энергия", + "toilet": "Туалет", + "urgency": { + "hygiene": "Мне нужно помыться!", + "hunger": "Я голоден!", + "thirst": "Я хочу пить!", + "energy": "Я устал!", + "toilet": "Мне нужно в туалет!" + } + }, + "happy": { + "0": "Я счастлив!", + "1": "Жизнь прекрасна!", + "2": "Я отлично себя чувствую!", + "3": "Всё хорошо!", + "4": "Я тебя люблю!", + "5": "Ты лучший!", + "6": "У меня всё хорошо!", + "7": "Всё идеально!" + }, + "usernameVariation": { + "0": "{username}, {message}!", + "1": "{username}, {message}!", + "2": "{message}, {username}!", + "3": "{username}! {message}!" + } + }, + "friends": { + "title": "Друзья", + "searchPlaceholder": "Поиск пользователей...", + "noUsersFound": "Пользователи не найдены", + "searchToAdd": "Найдите пользователей выше, чтобы добавить друзей!", + "pending": "Ожидание", + "wantsToShare": "{name} хочет поделиться", + "sharedWith": "Поделились с {name}", + "failedLoad": "Не удалось загрузить друзей", + "searchFailed": "Поиск не удался", + "requestSent": "Запрос в друзья отправлен", + "failedSendRequest": "Не удалось отправить запрос", + "requestCancelled": "Запрос отменён", + "failedCancelRequest": "Не удалось отменить запрос", + "friendAdded": "Друг добавлен", + "failedAcceptRequest": "Не удалось принять запрос", + "requestDeclined": "Запрос отклонён", + "failedDeclineRequest": "Не удалось отклонить запрос", + "friendRemoved": "Друг удалён", + "failedRemoveFriend": "Не удалось удалить друга" + }, + "invite": { + "title": "Пригласить друга", + "noFriends": "Нет доступных друзей", + "findMore": "Найти больше друзей", + "sent": "Приглашение отправлено {name}", + "userNotFound": "Пользователь или питомец не найден", + "cannotSend": "Невозможно отправить запрос", + "failedSend": "Не удалось отправить приглашение" + }, + "coparenting": { + "failedLoadRequests": "Не удалось загрузить запросы", + "requestAccepted": "Запрос принят", + "failedAccept": "Не удалось принять", + "requestDeclined": "Запрос отклонён", + "failedDecline": "Не удалось отклонить", + "requestCancelled": "Запрос отменён", + "failedCancel": "Не удалось отменить" + }, + "action": { + "sushi": "Суши", + "hamburger": "Гамбургер", + "pizza": "Пицца", + "chocolateBar": "Шоколадка", + "cookie": "Печенье", + "donut": "Пончик", + "watermelon": "Арбуз", + "mango": "Манго", + "strawberry": "Клубника", + "orangeJuice": "Апельсиновый сок", + "fruitTea": "Фруктовый чай", + "appleCider": "Яблочный сидр", + "latte": "Латте", + "hotCocoa": "Горячий шоколад", + "milk": "Молоко", + "bathtub": "Ванна", + "shower": "Душ", + "handSoap": "Мыло", + "brush": "Щётка", + "earCleaning": "Чистка ушей", + "nailTrim": "Стрижка когтей", + "toilet": "Туалет", + "quickPotty": "Горшок", + "longSleep": "Долгий сон", + "nap": "Дремота", + "rest": "Отдых", + "letterSealedRedHeart": "Письмо с сердцем", + "letterSealedRedBorder": "Красное письмо", + "letterSealedPink": "Розовое письмо", + "letterSealedSmallHeart": "Письмо с сердечком", + "letterSealedOrange": "Оранжевое письмо", + "letterSealedPinkHeart": "Розовое письмо с сердцем", + "letterSealedBrown": "Коричневое письмо", + "letterSealedAmber": "Янтарное письмо", + "envelopeWhite": "Белый конверт", + "envelopePink": "Розовый конверт" + }, + "gifts": { + "title": "Отправить подарок", + "send": "Отправить", + "noItems": "Нет валентинок в инвентаре", + "noItemsHint": "Собирайте валентинки из припасов и с охоты!", + "sent": "Подарок отправлен {name}!", + "failedSend": "Не удалось отправить подарок", + "sendGift": "Подарок" + }, + "valentine": { + "question": "Будешь моей Валентинкой?", + "yes": "Да!", + "no": "Нет", + "yay": "Ура!", + "present": "У меня для тебя небольшой подарок :)", + "takeLook": "Посмотреть" + }, + "pwa": { + "warning": "Лучше как PWA", + "description": "Это приложение создано для PWA и может работать некорректно в браузере.", + "iosTitle": "Установка на iOS:", + "iosStep1": "Нажмите кнопку «Поделиться» в Safari", + "iosStep2": "Прокрутите вниз и нажмите «На экран Домой»", + "iosStep3": "Нажмите «Добавить» для подтверждения", + "androidTitle": "Установка на Android:", + "androidStep1": "Нажмите меню (⋮) в Chrome", + "androidStep2": "Нажмите «Добавить на главный экран» или «Установить»", + "desktopTitle": "Установка на ПК:", + "desktopStep1": "Нажмите значок установки (⊕) в адресной строке", + "desktopStep2": "Нажмите «Установить» для подтверждения", + "continueAnyway": "Продолжить" + }, + "notif": { + "urgent": { + "title": { + "one": "{name} грустит!", + "many": "Ваши питомцы грустят!" + }, + "body": { + "one": "{name} нуждается в вашем внимании!", + "many": "{count} питомцев нуждаются в вашем внимании!" + } + }, + "zero": { + "title": "Ваши питомцы ждут вас!", + "body": { + "one": "Все показатели {name} на нуле!", + "many": "{count} питомцев нуждаются в вашем внимании!" + } + }, + "expedition": { + "title": "Охота завершена!", + "body": { + "one": "{name} вернулся с охоты!", + "many": "{count} питомцев вернулись с охоты!" + } + }, + "egg": { + "title": "Яйцо вылупилось!", + "body": { + "one": "{name} вылупился!", + "many": "{count} яиц вылупились!" + } + }, + "test": { + "title": "Тестовое уведомление", + "body": "Если вы видите это, уведомления работают!" + }, + "friend": { + "title": "Новая заявка в друзья", + "body": "{sender} отправил вам заявку в друзья!" + }, + "coparenting": { + "title": "Приглашение к совместному уходу", + "body": "{sender} хочет совместно ухаживать за {pet}!" + }, + "gift": { + "title": "Валентинка!", + "body": "{sender} отправил вам валентинку!" + }, + "items": { + "title": "Припасы готовы!", + "daily": "Ежедневные припасы готовы к сбору!", + "quick": "Быстрые припасы готовы к сбору!", + "all": "Ежедневные и быстрые припасы готовы к сбору!" + } + }, + "items": { + "collecting": "Собираю...", + "dailyCarePackage": "Ежедневные припасы", + "quickCarePackage": "Быстрые припасы", + "carePackage": "Припасы", + "debugClaim": "Отладочный пакет", + "claimRewards": "Забрать награды", + "nextIn": "Следующие через:", + "failedLoad": "Не удалось загрузить статус наград", + "failedClaim": "Не удалось забрать награды. Попробуйте снова.", + "title": "Награды из припасов!", + "food": "Еда", + "drinks": "Напитки", + "hygiene": "Гигиена", + "care": "Уход", + "count": "{count} предм.", + "bonus": "Бонус", + "luckyFind": "Повезло!", + "awesome": "ОТЛИЧНО!", + "valentines": "Валентинки" + } +} \ No newline at end of file diff --git a/packages/types/README.md b/packages/types/README.md index 49219fd..b7539ca 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -26,7 +26,7 @@ import { Pet, PetType, PetNeed, PET_THRESHOLDS } from '@widgetable/types'; ``` src/ -├── claims/ # ClaimResult, ClaimStatus +├── rewards/ # RewardResult, RewardStatus ├── config/ # Threshold and timing constants ├── database/ # Database-related types ├── pet/ diff --git a/packages/types/src/claims/config.ts b/packages/types/src/claims/config.ts deleted file mode 100644 index c5d56ca..0000000 --- a/packages/types/src/claims/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const CLAIMS_CONFIG = { - DAILY_COOLDOWN_HOURS: 24, - QUICK_COOLDOWN_HOURS: 4, - QUICK_REWARD_MULTIPLIER: 0.4, - BASE_FOOD_ITEMS: 3, - BASE_DRINK_ITEMS: 3, - BASE_HYGIENE_ITEMS: 2, - BASE_CARE_ITEMS: 2, - BASE_EGG_CHANCE: 0.35, -} as const; - -export const EXPEDITION_CONFIG = { - BASE_FOOD_ITEMS: 2, - BASE_DRINK_ITEMS: 2, - BASE_HYGIENE_ITEMS: 1, - BASE_CARE_ITEMS: 1, - MIN_EGG_CHANCE: 0.05, - MAX_EGG_CHANCE: 0.18, -} as const; diff --git a/packages/types/src/claims/index.ts b/packages/types/src/claims/index.ts deleted file mode 100644 index 2acd167..0000000 --- a/packages/types/src/claims/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './config'; -export * from './types'; diff --git a/packages/types/src/claims/types.ts b/packages/types/src/claims/types.ts deleted file mode 100644 index fcdc5fb..0000000 --- a/packages/types/src/claims/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ItemReward } from '../rewards'; - -export interface ClaimStatus { - dailyAvailable: boolean; - quickAvailable: boolean; - nextDailyTime?: Date; - nextQuickTime?: Date; - petCount: number; -} - -export interface ClaimResult { - success: boolean; - rewards: { - food: ItemReward[]; - drinks: ItemReward[]; - hygiene: ItemReward[]; - care: ItemReward[]; - eggs: number; - valentines?: ItemReward[]; - }; - totalItems: number; - nextClaimTime: Date; -} diff --git a/packages/types/src/config/languages.ts b/packages/types/src/config/languages.ts index 7c6cc0e..51b5f11 100644 --- a/packages/types/src/config/languages.ts +++ b/packages/types/src/config/languages.ts @@ -7,3 +7,4 @@ export const LANGUAGES = [ export type Language = (typeof LANGUAGES)[number]['code']; export const DEFAULT_LANGUAGE: Language = LANGUAGES[0].code; +export const SUPPORTED_LANGUAGES = LANGUAGES.map((l) => l.code); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5b7b6cf..63c6512 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,8 +1,7 @@ -export * from './claims'; export * from './config'; export * from './database'; export * from './pet'; export * from './request'; -export * from './rewards'; +export * from './items'; export * from './user'; export * from './utils'; diff --git a/packages/types/src/items/actions.ts b/packages/types/src/items/actions.ts new file mode 100644 index 0000000..d8a71e0 --- /dev/null +++ b/packages/types/src/items/actions.ts @@ -0,0 +1,58 @@ +import { PetActionCategory, PetNeed } from '../pet/enums'; +import { PetAction } from '../pet/types'; + +export const PET_ACTIONS_BY_CATEGORY = { + [PetActionCategory.FEED]: [ + // Full restore - premium foods + { name: 'sushi', needKey: PetNeed.HUNGER, amount: 100, inventoryCost: 1, experience: 18 }, + { name: 'hamburger', needKey: PetNeed.HUNGER, amount: 100, inventoryCost: 1, experience: 18 }, + + // High restore - main meals + { name: 'pizza', needKey: PetNeed.HUNGER, amount: 75, inventoryCost: 1, experience: 13 }, + { name: 'chocolateBar', needKey: PetNeed.HUNGER, amount: 18, inventoryCost: 1, experience: 8 }, + + // Small restore - snacks + { name: 'cookie', needKey: PetNeed.HUNGER, amount: 50, inventoryCost: 1, experience: 4 }, + { name: 'donut', needKey: PetNeed.HUNGER, amount: 55, inventoryCost: 1, experience: 3 }, + { name: 'watermelon', needKey: PetNeed.HUNGER, amount: 22, inventoryCost: 1, experience: 5 }, + { name: 'mango', needKey: PetNeed.HUNGER, amount: 50, inventoryCost: 1, experience: 4 }, + { name: 'strawberry', needKey: PetNeed.HUNGER, amount: 20, inventoryCost: 1, experience: 4 }, + ] as PetAction[], + [PetActionCategory.DRINK]: [ + // Full restore + { name: 'orangeJuice', needKey: PetNeed.THIRST, amount: 55, inventoryCost: 1, experience: 12 }, + { name: 'fruitTea', needKey: PetNeed.THIRST, amount: 100, inventoryCost: 1, experience: 7 }, + + // High restore + { name: 'appleCider', needKey: PetNeed.THIRST, amount: 45, inventoryCost: 1, experience: 14 }, + { name: 'latte', needKey: PetNeed.THIRST, amount: 65, inventoryCost: 1, experience: 9 }, + + // Moderate restore + { name: 'hotCocoa', needKey: PetNeed.THIRST, amount: 75, inventoryCost: 1, experience: 6 }, + { name: 'milk', needKey: PetNeed.THIRST, amount: 40, inventoryCost: 1, experience: 8 }, + ] as PetAction[], + [PetActionCategory.WASH]: [ + // Full restore - premium cleaning + { name: 'bathtub', needKey: PetNeed.HYGIENE, amount: 100, inventoryCost: 1, experience: 16 }, + { name: 'shower', needKey: PetNeed.HYGIENE, amount: 75, inventoryCost: 1, experience: 14 }, + + // Moderate restore + { name: 'handSoap', needKey: PetNeed.HYGIENE, amount: 50, inventoryCost: 1, experience: 8 }, + { name: 'brush', needKey: PetNeed.HYGIENE, amount: 50, inventoryCost: 1, experience: 9 }, + + // Small restore + { name: 'earCleaning', needKey: PetNeed.HYGIENE, amount: 35, inventoryCost: 1, experience: 5 }, + { name: 'nailTrim', needKey: PetNeed.HYGIENE, amount: 25, inventoryCost: 1, experience: 4 }, + ] as PetAction[], + [PetActionCategory.CARE]: [ + { name: 'toilet', needKey: PetNeed.TOILET, amount: 100, inventoryCost: 1, experience: 15 }, + { name: 'quickPotty', needKey: PetNeed.TOILET, amount: 60, inventoryCost: 1, experience: 10 }, + { name: 'longSleep', needKey: PetNeed.ENERGY, amount: 100, experience: 12 }, + { name: 'nap', needKey: PetNeed.ENERGY, amount: 50, experience: 6 }, + { name: 'rest', needKey: PetNeed.ENERGY, amount: 35, experience: 4 }, + ] as PetAction[], +}; + +export const ACTION_AMOUNT_BY_NAME: Record = Object.values(PET_ACTIONS_BY_CATEGORY) + .flat() + .reduce((acc, action) => ({ ...acc, [action.name]: action.amount }), {}); diff --git a/packages/types/src/items/config.ts b/packages/types/src/items/config.ts new file mode 100644 index 0000000..692d22c --- /dev/null +++ b/packages/types/src/items/config.ts @@ -0,0 +1,43 @@ +export enum ClaimType { + DAILY = 'daily', + QUICK = 'quick', +} + +export const CLAIM_TYPE_CONFIG: Record = { + [ClaimType.DAILY]: { cooldownHours: 24, multiplier: 1.0 }, + [ClaimType.QUICK]: { cooldownHours: 4, multiplier: 0.4 }, +}; + +export const isClaimAvailable = (lastClaimTime: Date | undefined, cooldownHours: number, now: Date): boolean => { + if (!lastClaimTime) return true; + return now.getTime() - lastClaimTime.getTime() >= cooldownHours * 60 * 60 * 1000; +}; + +export const getNextClaimTime = (lastClaimTime: Date, cooldownHours: number): Date => { + return new Date(lastClaimTime.getTime() + cooldownHours * 60 * 60 * 1000); +}; + +export const ITEMS_CLAIM_CONFIG = { + BASE_FOOD_ITEMS: 3, + BASE_DRINK_ITEMS: 4, + BASE_HYGIENE_ITEMS: 2, + BASE_CARE_ITEMS: 4, + BASE_EGG_CHANCE: 0.35, + // Controls how fast egg chance decays as pet count grows: 1 + petCount * decay + EGG_CHANCE_PET_DECAY: 0.6, + MIN_EGG_CHANCE: 0.05, +} as const; + +export const EXPEDITION_CONFIG = { + BASE_FOOD_ITEMS: 3, + BASE_DRINK_ITEMS: 4, + BASE_HYGIENE_ITEMS: 2, + BASE_CARE_ITEMS: 4, + MIN_EGG_CHANCE: 0.05, + MAX_EGG_CHANCE: 0.18, + // Per-level bonus rate applied to item counts (caps at LEVEL_ITEM_CAP) + LEVEL_ITEM_RATE: 0.1, + LEVEL_ITEM_CAP: 1.0, + // Logarithmic growth coefficient for expedition egg chance + EGG_CHANCE_LEVEL_GROWTH: 0.3, +} as const; diff --git a/packages/types/src/rewards/constants.ts b/packages/types/src/items/constants.ts similarity index 87% rename from packages/types/src/rewards/constants.ts rename to packages/types/src/items/constants.ts index c9fc005..9298999 100644 --- a/packages/types/src/rewards/constants.ts +++ b/packages/types/src/items/constants.ts @@ -7,6 +7,6 @@ export const TIER_WEIGHTS = { [ItemTier.LEGENDARY]: 5, } as const; -export const REWARDS_CONFIG = { +export const ITEMS_CONFIG = { MAX_DUPLICATE_ITEMS: 2, } as const; diff --git a/packages/types/src/items/index.ts b/packages/types/src/items/index.ts new file mode 100644 index 0000000..b9b0bae --- /dev/null +++ b/packages/types/src/items/index.ts @@ -0,0 +1,6 @@ +export * from './actions'; +export * from './config'; +export * from './constants'; +export * from './sprites'; +export * from './types'; +export * from './valentine'; diff --git a/packages/types/src/items/sprites.ts b/packages/types/src/items/sprites.ts new file mode 100644 index 0000000..a2b83ec --- /dev/null +++ b/packages/types/src/items/sprites.ts @@ -0,0 +1,60 @@ +export const ITEM_SPRITES: Record = { + // Feed + sushi: 'food/food/sushi_rolls.png', + hamburger: 'food/food/hamburger.png', + pizza: 'food/food/pizza_slice.png', + chocolateBar: 'food/food/chocolate_bar.png', + cookie: 'food/desserts/chocolate_chip_cookies.png', + donut: 'food/desserts/glazed_donut.png', + watermelon: 'food/fruits/watermelon_slice.png', + mango: 'food/fruits/mango.png', + strawberry: 'food/plants/strawberry.png', + + // Drink + orangeJuice: 'food/desserts/orange_juice.png', + fruitTea: 'food/desserts/tea_cup.png', + appleCider: 'food/desserts/apple_cider_mug.png', + latte: 'food/desserts/latte.png', + hotCocoa: 'food/desserts/cocoa_mug.png', + milk: 'food/food/milk_carton.png', + + // Wash + bathtub: 'bathroom/bathtub.png', + shower: 'bathroom/shower_stall.png', + handSoap: 'bathroom/hand_soap_pump.png', + brush: 'bathroom/toothbrush.png', + earCleaning: 'bathroom/cotton_swab_jar.png', + nailTrim: 'bathroom/nail_file.png', + + // Care + toilet: 'bathroom/toilet.png', + quickPotty: 'bathroom/toilet_paper_roll.png', + longSleep: 'bedroom/blue_bed_horizontal.png', + nap: 'bedroom/white_pillow.png', + rest: 'bedroom/folded_blanket.png', + + // Valentine - Chocolates + chocolateBarBrown: '/valentine/chocolate_bar_brown.png', + chocolateBarBlue: '/valentine/chocolate_bar_blue.png', + chocolateBarWhite: '/valentine/chocolate_bar_white.png', + chocolateBarDuo: '/valentine/chocolate_bar_red_blue.png', + chocolate: '/valentine/chocolate.png', + + // Valentine - Letters & Envelopes + letterSealedRedHeart: '/valentine/letter_sealed_red_heart.png', + letterSealedRedBorder: '/valentine/letter_sealed_red_border.png', + letterSealedPink: '/valentine/letter_sealed_pink.png', + letterSealedSmallHeart: '/valentine/letter_sealed_small_heart.png', + letterSealedOrange: '/valentine/letter_sealed_orange.png', + letterSealedPinkHeart: '/valentine/letter_sealed_pink_heart.png', + letterSealedBrown: '/valentine/letter_sealed_brown.png', + letterSealedAmber: '/valentine/letter_sealed_amber.png', + envelopeWhite: '/valentine/envelope_white.png', + envelopePink: '/valentine/envelope_pink.png', +}; + +export const getItemSprite = (itemName: string): string | undefined => { + const sprite = ITEM_SPRITES[itemName]; + if (!sprite) return undefined; + return sprite.startsWith('/') ? sprite : `/assets_new/${sprite}`; +}; diff --git a/packages/types/src/items/types.ts b/packages/types/src/items/types.ts new file mode 100644 index 0000000..df9aabc --- /dev/null +++ b/packages/types/src/items/types.ts @@ -0,0 +1,41 @@ +export enum ItemTier { + BASIC = 1, + COMMON = 2, + PREMIUM = 3, + LEGENDARY = 4, +} + +export const getItemTier = (amount: number): ItemTier => { + if (amount >= 100) return ItemTier.LEGENDARY; + if (amount >= 75) return ItemTier.PREMIUM; + if (amount >= 50) return ItemTier.COMMON; + return ItemTier.BASIC; +}; + +export interface ItemReward { + name: string; + quantity: number; +} + +import { ClaimType } from './config'; + +export interface ItemStatus { + available: Record; + nextClaimTime: Partial>; + petCount: number; +} + +export interface ItemBundle { + food: ItemReward[]; + drinks: ItemReward[]; + hygiene: ItemReward[]; + care: ItemReward[]; + eggs: number; + valentines?: ItemReward[]; +} + +export interface ItemResult { + success: boolean; + items: ItemBundle; + totalItems: number; +} diff --git a/packages/types/src/items/valentine.ts b/packages/types/src/items/valentine.ts new file mode 100644 index 0000000..cf6887b --- /dev/null +++ b/packages/types/src/items/valentine.ts @@ -0,0 +1,20 @@ +import { ItemTier } from './types'; + +export const VALENTINE_GIFT_ITEMS = [ + { name: 'letterSealedRedHeart', tier: ItemTier.PREMIUM }, + { name: 'letterSealedRedBorder', tier: ItemTier.COMMON }, + { name: 'letterSealedPink', tier: ItemTier.BASIC }, + { name: 'letterSealedSmallHeart', tier: ItemTier.COMMON }, + { name: 'letterSealedOrange', tier: ItemTier.BASIC }, + { name: 'letterSealedPinkHeart', tier: ItemTier.COMMON }, + { name: 'letterSealedBrown', tier: ItemTier.BASIC }, + { name: 'letterSealedAmber', tier: ItemTier.BASIC }, + { name: 'envelopeWhite', tier: ItemTier.BASIC }, + { name: 'envelopePink', tier: ItemTier.COMMON }, +] as const; + +export const VALENTINE_GIFT_ITEM_NAMES: string[] = VALENTINE_GIFT_ITEMS.map((item) => item.name); + +export const isValentineGiftItem = (itemName: string): boolean => { + return VALENTINE_GIFT_ITEM_NAMES.includes(itemName); +}; diff --git a/packages/types/src/pet/actions.ts b/packages/types/src/pet/actions.ts deleted file mode 100644 index f09ff31..0000000 --- a/packages/types/src/pet/actions.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { PetActionCategory, PetNeed } from './enums'; -import { PetAction } from './types'; - -export const PET_ACTIONS_BY_CATEGORY = { - [PetActionCategory.FEED]: [ - // Full restore - premium foods - { name: 'Sushi', needKey: PetNeed.HUNGER, value: 100, amount: 100, inventoryCost: 4, experience: 30 }, - { name: 'Hamburger', needKey: PetNeed.HUNGER, value: 100, amount: 100, inventoryCost: 4, experience: 30 }, - { name: 'Lasagna', needKey: PetNeed.HUNGER, value: 100, amount: 100, inventoryCost: 4, experience: 30 }, - - // High restore - main meals - { - name: 'Bento Box', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 70, - inventoryCost: 3, - experience: 20, - }, - { name: 'Pizza', needKey: PetNeed.HUNGER, value: 'increment', amount: 65, inventoryCost: 2, experience: 18 }, - { name: 'Ramen', needKey: PetNeed.HUNGER, value: 'increment', amount: 68, inventoryCost: 2, experience: 19 }, - { name: 'Taco', needKey: PetNeed.HUNGER, value: 'increment', amount: 62, inventoryCost: 2, experience: 17 }, - { name: 'Burrito', needKey: PetNeed.HUNGER, value: 'increment', amount: 65, inventoryCost: 2, experience: 18 }, - - // Moderate restore - light meals - { name: 'Sandwich', needKey: PetNeed.HUNGER, value: 'increment', amount: 50, inventoryCost: 1, experience: 13 }, - { name: 'Hot Dog', needKey: PetNeed.HUNGER, value: 'increment', amount: 48, inventoryCost: 1, experience: 12 }, - { name: 'Soup', needKey: PetNeed.HUNGER, value: 'increment', amount: 45, inventoryCost: 1, experience: 11 }, - { - name: 'Rice Bowl', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 47, - inventoryCost: 1, - experience: 12, - }, - { - name: 'Mac & Cheese', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 52, - inventoryCost: 1, - experience: 13, - }, - - // Small restore - snacks - { - name: 'Watermelon', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 25, - inventoryCost: 1, - experience: 6, - }, - { name: 'Mango', needKey: PetNeed.HUNGER, value: 'increment', amount: 22, inventoryCost: 1, experience: 5 }, - { - name: 'Strawberry', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 20, - inventoryCost: 1, - experience: 5, - }, - { name: 'Cookie', needKey: PetNeed.HUNGER, value: 'increment', amount: 20, inventoryCost: 1, experience: 5 }, - { name: 'Brownie', needKey: PetNeed.HUNGER, value: 'increment', amount: 22, inventoryCost: 1, experience: 5 }, - { name: 'Donut', needKey: PetNeed.HUNGER, value: 'increment', amount: 18, inventoryCost: 1, experience: 4 }, - { - name: 'French Fries', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 18, - inventoryCost: 1, - experience: 4, - }, - { - name: 'Chocolate Bar Brown', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 30, - inventoryCost: 1, - experience: 8, - }, - { - name: 'Chocolate Bar White', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 30, - inventoryCost: 1, - experience: 8, - }, - { - name: 'Chocolate Bar Red', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 35, - inventoryCost: 2, - experience: 10, - }, - { - name: 'Chocolate Bar Blue', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 35, - inventoryCost: 2, - experience: 10, - }, - { - name: 'Chocolate Bar Duo', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 40, - inventoryCost: 2, - experience: 12, - }, - { - name: 'Chocolate', - needKey: PetNeed.HUNGER, - value: 'increment', - amount: 45, - inventoryCost: 3, - experience: 15, - }, - ] as PetAction[], - [PetActionCategory.DRINK]: [ - // Full restore - { name: 'Orange Juice', needKey: PetNeed.THIRST, value: 100, amount: 100, inventoryCost: 2, experience: 20 }, - { name: 'Apple Cider', needKey: PetNeed.THIRST, value: 100, amount: 100, inventoryCost: 3, experience: 25 }, - { name: 'Fruit Parfait', needKey: PetNeed.THIRST, value: 100, amount: 100, inventoryCost: 3, experience: 25 }, - - // High restore - { name: 'Latte', needKey: PetNeed.THIRST, value: 'increment', amount: 65, inventoryCost: 2, experience: 16 }, - { name: 'Milk', needKey: PetNeed.THIRST, value: 'increment', amount: 55, inventoryCost: 1, experience: 14 }, - - // Moderate restore - { name: 'Hot Cocoa', needKey: PetNeed.THIRST, value: 'increment', amount: 40, inventoryCost: 1, experience: 8 }, - { - name: 'Fruit Tea', - needKey: PetNeed.THIRST, - value: 'increment', - amount: 50, - inventoryCost: 1, - experience: 10, - }, - - // Small restore - { - name: 'Cappuccino', - needKey: PetNeed.THIRST, - value: 'increment', - amount: 30, - inventoryCost: 1, - experience: 7, - }, - ] as PetAction[], - [PetActionCategory.WASH]: [ - // Full restore - premium cleaning - { name: 'Bath Salts', needKey: PetNeed.HYGIENE, value: 100, amount: 100, inventoryCost: 4, experience: 35 }, - { name: 'Bathtub', needKey: PetNeed.HYGIENE, value: 100, amount: 100, inventoryCost: 3, experience: 28 }, - - // High restore - { name: 'Shampoo', needKey: PetNeed.HYGIENE, value: 'increment', amount: 80, inventoryCost: 2, experience: 22 }, - { name: 'Shower', needKey: PetNeed.HYGIENE, value: 'increment', amount: 70, inventoryCost: 2, experience: 18 }, - - // Moderate restore - { - name: 'Grooming Session', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 55, - inventoryCost: 2, - experience: 16, - }, - { - name: 'Mouth Wash', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 50, - inventoryCost: 1, - experience: 12, - }, - { - name: 'Hand Soap', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 40, - inventoryCost: 1, - experience: 10, - }, - { name: 'Brush', needKey: PetNeed.HYGIENE, value: 'increment', amount: 45, inventoryCost: 1, experience: 11 }, - // Small restore - { - name: 'Wipe Down', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 25, - inventoryCost: 1, - experience: 6, - }, - { name: 'Sponge', needKey: PetNeed.HYGIENE, value: 'increment', amount: 30, inventoryCost: 1, experience: 7 }, - , - { - name: 'Ear Cleaning', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 25, - inventoryCost: 1, - experience: 9, - }, - { - name: 'Nail Trim', - needKey: PetNeed.HYGIENE, - value: 'increment', - amount: 20, - inventoryCost: 1, - experience: 8, - }, - ] as PetAction[], - [PetActionCategory.CARE]: [ - { name: 'Toilet', needKey: PetNeed.TOILET, value: 100, amount: 100, inventoryCost: 1, experience: 15 }, - { - name: 'Quick Potty', - needKey: PetNeed.TOILET, - value: 'increment', - amount: 60, - inventoryCost: 1, - experience: 10, - }, - { name: 'Long Sleep', needKey: PetNeed.ENERGY, value: 100, amount: 100, experience: 12 }, - { name: 'Nap', needKey: PetNeed.ENERGY, value: 'increment', amount: 50, experience: 6 }, - { name: 'Rest', needKey: PetNeed.ENERGY, value: 'increment', amount: 35, experience: 4 }, - ] as PetAction[], -}; diff --git a/packages/types/src/pet/config.ts b/packages/types/src/pet/config.ts index a527518..da98c0a 100644 --- a/packages/types/src/pet/config.ts +++ b/packages/types/src/pet/config.ts @@ -21,6 +21,10 @@ export const PET_SCALES: Record = { [PetType.PARROT]: 0.95, }; +export const getPetScale = (petType: PetType | 'egg'): number => { + return PET_SCALES[petType] ?? 1.0; +}; + export const PET_NEEDS_CONFIG = { [PetNeed.HYGIENE]: { label: 'Hygiene', diff --git a/packages/types/src/pet/constants.ts b/packages/types/src/pet/constants.ts index 1c007e5..9f0a60d 100644 --- a/packages/types/src/pet/constants.ts +++ b/packages/types/src/pet/constants.ts @@ -1,7 +1,8 @@ export const PET_UPDATE_INTERVAL = 5 * 1000; +export const MAX_EXPEDITION_SLOTS_RATIO = 0.3; export const USERNAME_INCLUSION_CHANCE = 0.3; -export const EGG_ITEM_NAME = 'Egg'; +export const EGG_ITEM_NAME = 'egg'; export const EXPEDITION_BASE_DURATION = 60 * 60 * 1000; export const EXPEDITION_LEVEL_MULTIPLIER = 0.1; diff --git a/packages/types/src/pet/index.ts b/packages/types/src/pet/index.ts index 55b747b..e631686 100644 --- a/packages/types/src/pet/index.ts +++ b/packages/types/src/pet/index.ts @@ -1,8 +1,6 @@ -export * from './actions'; export * from './config'; export * from './constants'; export * from './enums'; export * from './sprites'; export * from './types'; export * from './utils'; -export * from './valentine'; diff --git a/packages/types/src/pet/types.ts b/packages/types/src/pet/types.ts index 3c0d8e4..779b18d 100644 --- a/packages/types/src/pet/types.ts +++ b/packages/types/src/pet/types.ts @@ -1,5 +1,4 @@ import { Database } from '../database'; -import { ItemReward } from '../rewards'; import { PetActionCategory, PetAnimation, PetNeed, PetType } from './enums'; export interface PetNeedConfig { @@ -16,7 +15,6 @@ export type PetNeeds = Record; export interface PetAction { name: string; needKey: PetNeedKey; - value: number | 'increment'; amount: number; inventoryCost?: number; experience: number; @@ -33,14 +31,9 @@ export interface PetData { experience: number; level: number; background?: number | null; + needsUpdatedAt?: Date; isOnExpedition: boolean; expeditionReturnTime?: Date; - expeditionRewards?: { - food: ItemReward[]; - drinks: ItemReward[]; - hygiene: ItemReward[]; - eggs: number; - }; } export type PetUpdate = Partial> & { diff --git a/packages/types/src/pet/utils.ts b/packages/types/src/pet/utils.ts index 8329082..6e27818 100644 --- a/packages/types/src/pet/utils.ts +++ b/packages/types/src/pet/utils.ts @@ -1,3 +1,28 @@ +import { PET_NEED_KEYS, PET_NEEDS_CONFIG } from './config'; +import { PET_UPDATE_INTERVAL } from './constants'; +import { PetNeeds } from './types'; + +const DECAY_TIME_UNIT_MS = 60 * 1000; + +export const calculateDecayedNeeds = ( + needs: PetNeeds, + lastUpdatedAt: Date, + now: Date, +): { needs: PetNeeds; changed: boolean } => { + const timeDiff = now.getTime() - lastUpdatedAt.getTime(); + const intervals = Math.floor(timeDiff / PET_UPDATE_INTERVAL); + + if (intervals <= 0) return { needs, changed: false }; + + const updated = { ...needs }; + for (const key of PET_NEED_KEYS) { + const decrease = intervals * PET_NEEDS_CONFIG[key].decayRate * (PET_UPDATE_INTERVAL / DECAY_TIME_UNIT_MS); + updated[key] = Math.max(0, Math.min(100, needs[key] - decrease)); + } + + return { needs: updated, changed: true }; +}; + const BASE_EXP = 50; const EXP_MULTIPLIER = 1.5; diff --git a/packages/types/src/pet/valentine.ts b/packages/types/src/pet/valentine.ts deleted file mode 100644 index 76c6434..0000000 --- a/packages/types/src/pet/valentine.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { ItemTier } from '../rewards'; - -export const VALENTINE_GIFT_ITEMS = [ - { name: 'Letter Sealed Red Heart', tier: ItemTier.PREMIUM }, - { name: 'Letter Sealed Red Border', tier: ItemTier.COMMON }, - { name: 'Letter Sealed Pink', tier: ItemTier.BASIC }, - { name: 'Letter Sealed Small Heart', tier: ItemTier.COMMON }, - { name: 'Letter Sealed Orange', tier: ItemTier.BASIC }, - { name: 'Letter Sealed Pink Heart', tier: ItemTier.COMMON }, - { name: 'Letter Sealed Brown', tier: ItemTier.BASIC }, - { name: 'Letter Sealed Amber', tier: ItemTier.BASIC }, - { name: 'Envelope White', tier: ItemTier.BASIC }, - { name: 'Envelope Pink', tier: ItemTier.COMMON }, -] as const; - -export const VALENTINE_GIFT_ITEM_NAMES: string[] = VALENTINE_GIFT_ITEMS.map((item) => item.name); - -export const isValentineGiftItem = (itemName: string): boolean => { - return VALENTINE_GIFT_ITEM_NAMES.includes(itemName); -}; diff --git a/packages/types/src/rewards/index.ts b/packages/types/src/rewards/index.ts deleted file mode 100644 index 33c8572..0000000 --- a/packages/types/src/rewards/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './types'; -export * from './constants'; diff --git a/packages/types/src/rewards/types.ts b/packages/types/src/rewards/types.ts deleted file mode 100644 index 75988fd..0000000 --- a/packages/types/src/rewards/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export enum ItemTier { - BASIC = 1, - COMMON = 2, - PREMIUM = 3, - LEGENDARY = 4, -} - -export interface ItemReward { - name: string; - quantity: number; - tier: ItemTier; -}