diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 0000000..a5d3992 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,41 @@ +# Themes + +**Note: This is a work in progress.** + +## Resources + +[tweakcn](https://tweakcn.com) - Generate themes +[tints.dev](https://www.tints.dev) - Convert single color into 10-color palette +[tailcolor](https://tailcolor.com) - Convert single color into 10-color palette + +### Tweakcn prompt + +Tweakcn will let you generate a theme baseline to work with. This is the prompt I have used: + +```md +I would like a theme generated with a primary color of #7201d3 and a +secondary color of #4a86e8, with an accent color of #ff9e01 +(these are all dark mode, please generate appropriate light mode variants). +I would like the sidebar colors to be pronounced and distinguished from the +other content, with the primary color and various shades integrated +tastefully to separate things like sidebars, navbar, main content, etc. +Themes leaning towards more high contrast than less are preferred. +``` + +## Accessibility + +All themes should account for Deuteranopia and Protanopia color blindness. + +### Deuteranopia + +Deuteranopia is a type of red-green color blindness where the green cones in the eye are absent. +This results in difficulty distinguishing between reds and greens. + +**If your theme uses the colors red and green**, you should provide a color override for green in the deuteranopia specific theme. + +### Protanopia + +Protanopia is another type of red-green color blindness where the red cones in the eye are absent. +This results in difficulty distinguishing between reds and greens. + +**If your theme uses the colors red and green**, you should provide a color override for red in the protanopia specific theme. diff --git a/prisma/models/clairobscur.prisma b/prisma/models/clairobscur.prisma new file mode 100644 index 0000000..af2c0aa --- /dev/null +++ b/prisma/models/clairobscur.prisma @@ -0,0 +1,74 @@ +enum ClairObscurItemCategory { + CHARACTER + LUMINA + PICTO + SKILL + WEAPON +} + +enum ClairObscurCharacter { + GUSTAVE + LUNE + MAELLE + SCIEL + VERSO + MONOCO +} + +enum ClairObscurAttribute { + VITALITY + MIGHT + AGILITY + DEFENSE + LUCK +} + +enum ClairObscurElement { + DARK + EARTH + FIRE + ICE + LIGHT + LIGHTNING + PHYSICAL + VOID +} + +enum ClairObscurStat { + ATTACK_POWER + SPEED + CRITICAL_RATE + HEALTH + DEFENSE +} + +enum ClairObscurDLC { + BASE +} + +model ClairObscurItem { + id String @id @default(cuid()) + slug String @unique + name String + disabled Boolean @default(false) + + // ClairObscur specific fields + category ClairObscurItemCategory + collectedItems ClairObscurCollectedItem[] +} + +model ClairObscurCollectedItem { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + item ClairObscurItem @relation(fields: [itemId], references: [id], onDelete: Cascade) + itemId String + + @@unique([userId, itemId]) + @@index([userId]) + @@index([itemId]) +} \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 630605a..240aa6a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ datasource db { enum GameId { none + clairobscur remnant2 slaythespire2 } @@ -63,10 +64,10 @@ model User { usersBeingFollowed UserFollowedUsers[] @relation("userBeingFollowed") usersFollowed UserFollowedUsers[] @relation("userFollowed") - notificationsReceived Notification[] @relation("NotificationsReceived") - notificationsTriggered Notification[] @relation("NotificationsTriggered") userRoles UserRole[] @relation("UserRoles") + clairObscurCollectedItems ClairObscurCollectedItem[] + remnant2CollectedItems Remnant2CollectedItem[] remnant2Builds Remnant2Build[] remnant2BuildCollections Remnant2BuildCollection[] @@ -186,36 +187,11 @@ model UserFollowedUsers { @@map("user_followed_users") } -model Notification { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? - - type NotificationType - isRead Boolean @default(false) - groupKey String? - - recipientId String - recipient User @relation("NotificationsReceived", fields: [recipientId], references: [id], onDelete: Cascade) - - actorId String? - actor User? @relation("NotificationsTriggered", fields: [actorId], references: [id], onDelete: Cascade) - - metadata Json - - @@index([recipientId, isRead]) - @@index([recipientId, deletedAt, createdAt(sort: Desc)]) - @@index([type, recipientId]) - @@index([recipientId, actorId, type, isRead, createdAt]) - @@index([recipientId, groupKey, createdAt]) - @@map("notifications") -} - enum Role { USER MODERATOR ADMIN + SUPERADMIN } model UserRole { diff --git a/prisma/seed.ts b/prisma/seed.ts deleted file mode 100644 index 64284fa..0000000 --- a/prisma/seed.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { allGameDBSeeds } from '@/features/game/constants/all-game-db-seeds'; -import { logger } from '@/lib/logger'; - -import { auth } from '../src/integrations/better-auth/better-auth'; -import { prisma } from './client'; - -const seededUsers = [ - { - username: 'admin', - email: 'yo@toolkits.gg', - emailVerified: true, - }, -]; - -const seededUserProfiles = [ - { - displayName: 'Toolkit Admin', - bio: 'Toolkit Admin bio here', - avatarUrl: undefined, - }, - { - displayName: 'Toolkit User', - bio: 'Toolkit User bio here', - avatarUrl: undefined, - }, -]; - -const seed = async () => { - const t0 = performance.now(); - logger.info('DB Seed: Started ...'); - - // Clean up existing data (ignore errors if tables don't exist yet) - try { - await Promise.all([ - prisma.userProfile.deleteMany(), - prisma.userFavoriteGame.deleteMany(), - prisma.userFollowedUsers.deleteMany(), - prisma.user.deleteMany(), - ]); - } catch (_error: unknown) { - logger.warn('DB Seed: Skipping cleanup (fresh database)'); - } - - const seededLocalPassword = 'useruser!'; - - await Promise.all( - seededUsers.map(async (user, index) => { - const result = await auth.api.signUpEmail({ - body: { - email: user.email, - password: seededLocalPassword, - name: user.username, - username: user.username, - }, - }); - - const userId = result.user.id; - - if (user.emailVerified) { - await prisma.user.update({ - where: { id: userId }, - data: { emailVerified: true }, - }); - } - - await prisma.userProfile.create({ - data: { - ...seededUserProfiles[index], - userId: userId, - }, - }); - - return result.user; - }) - ); - - // Seed items for each game - await Promise.all( - Object.entries(allGameDBSeeds).map(async ([gameId, gameSeed]) => { - logger.info(`DB Seed: Seeding items for ${gameId}...`); - await gameSeed.seed(); - }) - ); - - // Wrapping up - const t1 = performance.now(); - logger.info(`DB Seed: Finished (${t1 - t0}ms)`); -}; - -seed(); diff --git a/src/features/dal/README.md b/src/features/dal/README.md new file mode 100644 index 0000000..1a62ee7 --- /dev/null +++ b/src/features/dal/README.md @@ -0,0 +1,128 @@ +# Data Access Layer (DAL) + +An offline-first sync system that abstracts dual backends behind a unified API. Components never know if they're talking to Postgres (remote) or IndexedDB (local) — the DAL picks based on auth + network state. + +**Backend selection rule** (`core/choose-backend.ts`): +- Authenticated + online → **remote** (server functions → Prisma → Postgres) +- Offline or unauthenticated → **local** (IndexedDB, queued for later sync) + +--- + +## Core Layers + +| Layer | Location | Purpose | +|---|---|---| +| Actions | `actions/` | Defines read/write ops with both remote + local implementations | +| Hooks | `hooks/` | React API: `useDalQuery`, `useDalMutation` | +| Server | `server/` | TanStack Start server functions that call Prisma | +| Local | `local/` | IndexedDB wrappers that mirror Prisma syntax | +| Queue | `queue/` | Pending ops store + sync runner | +| Identity | `identity/` | Auth ID vs. anonymous UUID resolution | + +--- + +## How to Use It + +### 1. Define an Action + +Use `defineDalRead()` or `defineDalWrite()` to create an action: + +```ts +// actions/my-entity.ts +export const myEntityActions = { + list: defineDalRead({ + queryKey: (ctx, input) => ["my-entity", "list", ctx.authUserId], + remote: async (ctx, input) => listMyEntityServerFn({ data: input }), + local: async (ctx, input) => myEntityIdb.findMany(), + }), + + create: defineDalWrite({ + entity: "my_entity", + operation: "create", + idempotencyKey: (input) => buildIdempotencyKey("my_entity", input.id), + invalidates: ["my-entity"], + remote: async (ctx, input) => createMyEntityServerFn({ data: input }), + local: async (ctx, input) => myEntityIdb.upsert(input), + sync: async (ctx, input) => applyPendingOpServerFn({ data: { entity: "my_entity", payload: input } }), + }), +}; +``` + +### 2. Register the write action + +Add it to `actions/registry.ts` so the sync runner can replay it when coming back online: + +```ts +export const actionRegistry = { + my_entity: myEntityActions.create, + // ... +}; +``` + +### 3. Use hooks in components + +```tsx +// Read +const { data, isLoading } = useDalQuery(myEntityActions.list, undefined); + +// Write +const create = useDalMutation(myEntityActions.create); +create.mutate({ id: "abc", name: "foo" }); +``` + +`useDalMutation` automatically: +- Calls the **remote** impl if online + authed +- Calls the **local** impl + **enqueues the op** if offline/guest +- Invalidates relevant query keys after success + +--- + +## Offline Sync Flow + +When a user comes back online, `syncOps()` (`queue/sync-runner.ts`) is called. It: + +1. Reads all `pending` ops from the IndexedDB queue +2. For each op, resolves the write action via the registry +3. Calls the action's `sync` handler → `applyPendingOpServerFn` +4. The server handler compares timestamps (**last-write-wins**) and upserts via Prisma +5. Op status is updated: `applied`, `noop`, `conflict`, or `error` + +--- + +## Adding a New Entity — Checklist + +1. Add Prisma schema + run `pnpm db:generate` +2. Create `local/-idb.ts` (IndexedDB CRUD) +3. Create `server/.ts` (server functions with Prisma) +4. Create `actions/.ts` (read + write actions) +5. Register write actions in `actions/registry.ts` +6. Add a sync handler in `server/sync.ts` +7. Use `useDalQuery` / `useDalMutation` in your components + +--- + +## Key Files + +| File | Purpose | +|---|---| +| `core/types.ts` | Core interfaces: `DalAction`, `DalContext`, `SyncResult` | +| `core/define-action.ts` | Helper functions to create actions | +| `core/choose-backend.ts` | Backend selection: authed + online = remote, else local | +| `core/to-query-options.ts` | Converts actions to TanStack Query options | +| `actions/favorite-games.ts` | Example action set (list/favorite/unfavorite) | +| `actions/collected-items.ts` | Example action set (list/collect/uncollect) | +| `actions/registry.ts` | Central registry of all write actions | +| `hooks/use-dal-query.ts` | Read hook (`useDalQuery`, `useDalSuspenseQuery`) | +| `hooks/use-dal-mutation.ts` | Write hook with auto-enqueue | +| `hooks/use-dal-context-source.ts` | Provides `DalContext` (auth state, network, backend) | +| `server/favorite-games.ts` | Server functions for favorites | +| `server/collected-items.ts` | Server functions for collected items | +| `server/sync.ts` | Central sync endpoint + entity handlers | +| `server/require-user.ts` | Auth middleware for server functions | +| `local/favorite-games-idb.ts` | IndexedDB read/write for favorites | +| `local/local-db.ts` | IndexedDB schema and initialization | +| `queue/pending-ops.ts` | Pending ops queue (IndexedDB store) | +| `queue/sync-runner.ts` | Orchestrator for replaying pending ops | +| `queue/last-write-wins.ts` | Timestamp-based conflict resolution | +| `identity/anon-id.ts` | Generate/persist anonymous user IDs | +| `identity/use-effective-user-id.ts` | Hook: returns auth or anon ID | diff --git a/src/features/dal/hooks/use-dal-query.ts b/src/features/dal/hooks/use-dal-query.ts index 00e491e..d06453b 100644 --- a/src/features/dal/hooks/use-dal-query.ts +++ b/src/features/dal/hooks/use-dal-query.ts @@ -3,14 +3,17 @@ import { toQueryOptions } from "#/features/dal/core/to-query-options"; import type { DalReadAction } from "#/features/dal/core/types"; import { useDalContextSource } from "#/features/dal/hooks/use-dal-context-source"; -export function useDalQuery(action: DalReadAction, input: I) { +export function useDalQuery( + action: DalReadAction, + input: Input, +) { const ctxGetter = useDalContextSource(); return useQuery(toQueryOptions(action, input, ctxGetter)); } -export function useDalSuspenseQuery( - action: DalReadAction, - input: I, +export function useDalSuspenseQuery( + action: DalReadAction, + input: Input, ) { const ctxGetter = useDalContextSource(); return useSuspenseQuery(toQueryOptions(action, input, ctxGetter)); diff --git a/src/features/game/registry/game-registry.ts b/src/features/game/registry/game-registry.ts index f939ed0..e0937c7 100644 --- a/src/features/game/registry/game-registry.ts +++ b/src/features/game/registry/game-registry.ts @@ -2,6 +2,7 @@ import type { AppItem } from "#/features/game/item/types/app-item"; import type { GameConfig } from "#/features/game/types/game-config"; import { defaultTheme } from "#/features/theme/themes/default-theme"; import type { ToolkitThemeDefinition } from "#/features/theme/types/toolkit-theme-definition"; +import { GAME_CONFIG as CLAIROBSCUR_CONFIG } from "#/games/clairobscur/game-config"; import { GAME_CONFIG as REMNANT2_CONFIG } from "#/games/remnant2/game-config"; import { GAME_CONFIG as SLAYTHESPIRE2_CONFIG } from "#/games/slaythespire2/game-config"; @@ -10,6 +11,7 @@ type AnyGameConfig = GameConfig; // The registry — keys are the exact gameId strings const GAME_REGISTRY = { + clairObscur: CLAIROBSCUR_CONFIG, remnant2: REMNANT2_CONFIG, slaythespire2: SLAYTHESPIRE2_CONFIG, } satisfies Record; diff --git a/src/features/theme/themes/default-theme.ts b/src/features/theme/themes/default-theme.ts index f42479f..b4cc1b9 100644 --- a/src/features/theme/themes/default-theme.ts +++ b/src/features/theme/themes/default-theme.ts @@ -1,425 +1,425 @@ -import { type MantineThemeOverride } from '@mantine/core'; +import type { MantineThemeOverride } from "@mantine/core"; -import { BLACK, RED, WHITE } from '@/features/theme/constants/colors'; -import { baseTheme } from '@/features/theme/themes/base-theme'; -import { createThemeColors } from '@/features/theme/utils/create-theme-color'; +import { BLACK, RED, WHITE } from "@/features/theme/constants/colors"; +import { baseTheme } from "@/features/theme/themes/base-theme"; +import { createThemeColors } from "@/features/theme/utils/create-theme-color"; const defaultThemeColors = createThemeColors({ - primary: { - dark: [ - '#eee8ff', - '#e0d4ff', - '#c3a8ff', - '#a979ff', - '#933dff', - '#7201d3', - '#5d01af', - '#49008a', - '#350067', - '#220046', - '#13002d', - ], - light: [ - '#eee8ff', - '#e0d4ff', - '#c3a8ff', - '#a979ff', - '#933dff', - '#7201d3', - '#5d01af', - '#49008a', - '#350067', - '#220046', - '#13002d', - ], - fgDark: WHITE, - fgLight: WHITE, - }, + primary: { + dark: [ + "#eee8ff", + "#e0d4ff", + "#c3a8ff", + "#a979ff", + "#933dff", + "#7201d3", + "#5d01af", + "#49008a", + "#350067", + "#220046", + "#13002d", + ], + light: [ + "#eee8ff", + "#e0d4ff", + "#c3a8ff", + "#a979ff", + "#933dff", + "#7201d3", + "#5d01af", + "#49008a", + "#350067", + "#220046", + "#13002d", + ], + fgDark: WHITE, + fgLight: WHITE, + }, - secondary: { - dark: [ - '#f0f3fd', - '#dee5fa', - '#c0cef6', - '#9db5f2', - '#779ded', - '#4a86e8', - '#3669ba', - '#274f8e', - '#193665', - '#0b1d3a', - '#061329', - ], - light: [ - '#f0f3fd', - '#dee5fa', - '#c0cef6', - '#9db5f2', - '#779ded', - '#4a86e8', - '#3669ba', - '#274f8e', - '#193665', - '#0b1d3a', - '#061329', - ], - fgDark: WHITE, - fgLight: WHITE, - }, + secondary: { + dark: [ + "#f0f3fd", + "#dee5fa", + "#c0cef6", + "#9db5f2", + "#779ded", + "#4a86e8", + "#3669ba", + "#274f8e", + "#193665", + "#0b1d3a", + "#061329", + ], + light: [ + "#f0f3fd", + "#dee5fa", + "#c0cef6", + "#9db5f2", + "#779ded", + "#4a86e8", + "#3669ba", + "#274f8e", + "#193665", + "#0b1d3a", + "#061329", + ], + fgDark: WHITE, + fgLight: WHITE, + }, - accent: { - dark: [ - '#fff1e9', - '#ffdfcc', - '#ffbf8e', - '#ff9e01', - '#d98500', - '#b16c00', - '#8f5600', - '#6a3f00', - '#4b2b00', - '#2b1700', - '#1d0d00', - ], - light: [ - '#fff4ef', - '#ffede3', - '#ffd8c0', - '#ffc69d', - '#ffb373', - '#ff9e01', - '#ca7c00', - '#965b00', - '#643b00', - '#3a2000', - '#231100', - ], - fgDark: BLACK, - fgLight: BLACK, - }, + accent: { + dark: [ + "#fff1e9", + "#ffdfcc", + "#ffbf8e", + "#ff9e01", + "#d98500", + "#b16c00", + "#8f5600", + "#6a3f00", + "#4b2b00", + "#2b1700", + "#1d0d00", + ], + light: [ + "#fff4ef", + "#ffede3", + "#ffd8c0", + "#ffc69d", + "#ffb373", + "#ff9e01", + "#ca7c00", + "#965b00", + "#643b00", + "#3a2000", + "#231100", + ], + fgDark: BLACK, + fgLight: BLACK, + }, - base: { - dark: [ - '#ecdcff', - '#dbb8ff', - '#be68ff', - '#9800dd', - '#53007b', - '#0a0014', - '#0a0013', - '#0a0013', - '#000000', - '#000000', - '#000000', - ], - light: WHITE, - fgDark: WHITE, - fgLight: BLACK, - }, + base: { + dark: [ + "#ecdcff", + "#dbb8ff", + "#be68ff", + "#9800dd", + "#53007b", + "#0a0014", + "#0a0013", + "#0a0013", + "#000000", + "#000000", + "#000000", + ], + light: WHITE, + fgDark: WHITE, + fgLight: BLACK, + }, - card: { - dark: [ - '#e8defc', - '#d5bffa', - '#ae79f5', - '#8b28e2', - '#4f1384', - '#150229', - '#140227', - '#0e011f', - '#080114', - '#080114', - '#000000', - ], - light: [ - '#ffffff', - '#fdfcfe', - '#fbf8fd', - '#fbf8fd', - '#f9f5fc', - '#f7f2fb', - '#d2b2e8', - '#b171d5', - '#7c3e9a', - '#421e54', - '#291135', - ], - fgDark: [ - '#ffffff', - '#fdfcfe', - '#fbf8fd', - '#fbf8fd', - '#f9f5fc', - '#f7f2fb', - '#d2b2e8', - '#b171d5', - '#7c3e9a', - '#421e54', - '#291135', - ], - fgLight: [ - '#ecdcff', - '#dbb8ff', - '#be68ff', - '#9800dd', - '#53007b', - '#0a0014', - '#0a0013', - '#0a0013', - '#000000', - '#000000', - '#000000', - ], - }, + card: { + dark: [ + "#e8defc", + "#d5bffa", + "#ae79f5", + "#8b28e2", + "#4f1384", + "#150229", + "#140227", + "#0e011f", + "#080114", + "#080114", + "#000000", + ], + light: [ + "#ffffff", + "#fdfcfe", + "#fbf8fd", + "#fbf8fd", + "#f9f5fc", + "#f7f2fb", + "#d2b2e8", + "#b171d5", + "#7c3e9a", + "#421e54", + "#291135", + ], + fgDark: [ + "#ffffff", + "#fdfcfe", + "#fbf8fd", + "#fbf8fd", + "#f9f5fc", + "#f7f2fb", + "#d2b2e8", + "#b171d5", + "#7c3e9a", + "#421e54", + "#291135", + ], + fgLight: [ + "#ecdcff", + "#dbb8ff", + "#be68ff", + "#9800dd", + "#53007b", + "#0a0014", + "#0a0013", + "#0a0013", + "#000000", + "#000000", + "#000000", + ], + }, - popover: { - dark: [ - '#ecdcff', - '#dbb8ff', - '#be68ff', - '#9800dd', - '#53007b', - '#0a0014', - '#0a0013', - '#0a0013', - '#000000', - '#000000', - '#000000', - ], - light: WHITE, - fgDark: [ - '#f7f2fb', - '#ede1f6', - '#dac0ec', - '#c89ee3', - '#b67bd9', - '#a557cd', - '#8242a2', - '#63317c', - '#452057', - '#291135', - '#1b0924', - ], - fgLight: [ - '#ecdcff', - '#dbb8ff', - '#be68ff', - '#9800dd', - '#53007b', - '#0a0014', - '#0a0013', - '#0a0013', - '#000000', - '#000000', - '#000000', - ], - }, + popover: { + dark: [ + "#ecdcff", + "#dbb8ff", + "#be68ff", + "#9800dd", + "#53007b", + "#0a0014", + "#0a0013", + "#0a0013", + "#000000", + "#000000", + "#000000", + ], + light: WHITE, + fgDark: [ + "#f7f2fb", + "#ede1f6", + "#dac0ec", + "#c89ee3", + "#b67bd9", + "#a557cd", + "#8242a2", + "#63317c", + "#452057", + "#291135", + "#1b0924", + ], + fgLight: [ + "#ecdcff", + "#dbb8ff", + "#be68ff", + "#9800dd", + "#53007b", + "#0a0014", + "#0a0013", + "#0a0013", + "#000000", + "#000000", + "#000000", + ], + }, - muted: { - dark: [ - '#f2effa', - '#e8e2f6', - '#d0c3ed', - '#baa7e5', - '#a487dc', - '#916ad2', - '#7c4ac5', - '#62399e', - '#472874', - '#301951', - '#23113d', - ], - light: [ - '#fdfcfe', - '#faf9fd', - '#f5f2fb', - '#f1ecf9', - '#eee8f7', - '#e9e1f5', - '#c2a8e3', - '#9b6bce', - '#6d3e99', - '#3a1f54', - '#231035', - ], - fgDark: [ - '#f3eff7', - '#e9e3f1', - '#d1c4e2', - '#bca7d4', - '#a587c4', - '#8f69b4', - '#755196', - '#593d73', - '#3c274e', - '#231630', - '#170c20', - ], - fgLight: [ - '#efedf2', - '#dcd8e2', - '#bbb3c7', - '#9a8eac', - '#7b6b91', - '#5a4d6b', - '#483d56', - '#372f43', - '#272130', - '#18131e', - '#100c15', - ], - }, + muted: { + dark: [ + "#f2effa", + "#e8e2f6", + "#d0c3ed", + "#baa7e5", + "#a487dc", + "#916ad2", + "#7c4ac5", + "#62399e", + "#472874", + "#301951", + "#23113d", + ], + light: [ + "#fdfcfe", + "#faf9fd", + "#f5f2fb", + "#f1ecf9", + "#eee8f7", + "#e9e1f5", + "#c2a8e3", + "#9b6bce", + "#6d3e99", + "#3a1f54", + "#231035", + ], + fgDark: [ + "#f3eff7", + "#e9e3f1", + "#d1c4e2", + "#bca7d4", + "#a587c4", + "#8f69b4", + "#755196", + "#593d73", + "#3c274e", + "#231630", + "#170c20", + ], + fgLight: [ + "#efedf2", + "#dcd8e2", + "#bbb3c7", + "#9a8eac", + "#7b6b91", + "#5a4d6b", + "#483d56", + "#372f43", + "#272130", + "#18131e", + "#100c15", + ], + }, - destructive: { - dark: RED, - light: RED, - fgDark: WHITE, - fgLight: WHITE, - }, + destructive: { + dark: RED, + light: RED, + fgDark: WHITE, + fgLight: WHITE, + }, - border: { - dark: [ - '#eae6f7', - '#d3caee', - '#ab95dd', - '#8763cb', - '#5d3998', - '#311c54', - '#291748', - '#22123d', - '#190c2e', - '#0f061f', - '#0b0418', - ], - light: [ - '#faf9fd', - '#f5f2fa', - '#ece5f5', - '#e3d9f1', - '#daccec', - '#d2c1e8', - '#af8dd5', - '#8f59bf', - '#603983', - '#341d4a', - '#20102f', - ], - fgDark: WHITE, - fgLight: BLACK, - }, + border: { + dark: [ + "#eae6f7", + "#d3caee", + "#ab95dd", + "#8763cb", + "#5d3998", + "#311c54", + "#291748", + "#22123d", + "#190c2e", + "#0f061f", + "#0b0418", + ], + light: [ + "#faf9fd", + "#f5f2fa", + "#ece5f5", + "#e3d9f1", + "#daccec", + "#d2c1e8", + "#af8dd5", + "#8f59bf", + "#603983", + "#341d4a", + "#20102f", + ], + fgDark: WHITE, + fgLight: BLACK, + }, - input: { - dark: [ - '#eae6f7', - '#d3caee', - '#ab95dd', - '#8763cb', - '#5d3998', - '#311c54', - '#291748', - '#22123d', - '#190c2e', - '#0f061f', - '#0b0418', - ], - light: [ - '#faf9fd', - '#f5f2fa', - '#ece5f5', - '#e3d9f1', - '#daccec', - '#d2c1e8', - '#af8dd5', - '#8f59bf', - '#603983', - '#341d4a', - '#20102f', - ], - fgDark: WHITE, - fgLight: BLACK, - }, + input: { + dark: [ + "#eae6f7", + "#d3caee", + "#ab95dd", + "#8763cb", + "#5d3998", + "#311c54", + "#291748", + "#22123d", + "#190c2e", + "#0f061f", + "#0b0418", + ], + light: [ + "#faf9fd", + "#f5f2fa", + "#ece5f5", + "#e3d9f1", + "#daccec", + "#d2c1e8", + "#af8dd5", + "#8f59bf", + "#603983", + "#341d4a", + "#20102f", + ], + fgDark: WHITE, + fgLight: BLACK, + }, - ring: { - dark: [ - '#eee8ff', - '#e0d4ff', - '#c3a8ff', - '#a979ff', - '#933dff', - '#7201d3', - '#5d01af', - '#49008a', - '#350067', - '#220046', - '#13002d', - ], - light: [ - '#eee8ff', - '#e0d4ff', - '#c3a8ff', - '#a979ff', - '#933dff', - '#7201d3', - '#5d01af', - '#49008a', - '#350067', - '#220046', - '#13002d', - ], - fgDark: WHITE, - fgLight: BLACK, - }, + ring: { + dark: [ + "#eee8ff", + "#e0d4ff", + "#c3a8ff", + "#a979ff", + "#933dff", + "#7201d3", + "#5d01af", + "#49008a", + "#350067", + "#220046", + "#13002d", + ], + light: [ + "#eee8ff", + "#e0d4ff", + "#c3a8ff", + "#a979ff", + "#933dff", + "#7201d3", + "#5d01af", + "#49008a", + "#350067", + "#220046", + "#13002d", + ], + fgDark: WHITE, + fgLight: BLACK, + }, - sidebar: { - dark: [ - '#efdbff', - '#e1b6ff', - '#c961ff', - '#9a00cd', - '#560074', - '#07000d', - '#0a0012', - '#000000', - '#000000', - '#000000', - '#000000', - ], - light: [ - '#e8e2f6', - '#d0c3ed', - '#a587dc', - '#804cc6', - '#4b2977', - '#1a0b2e', - '#17092a', - '#140725', - '#0b0418', - '#06020f', - '#06020f', - ], - fgDark: WHITE, - fgLight: [ - '#ffffff', - '#fdfcfe', - '#fbf8fd', - '#fbf8fd', - '#f9f5fc', - '#f7f2fb', - '#d2b2e8', - '#b171d5', - '#7c3e9a', - '#421e54', - '#291135', - ], - }, + sidebar: { + dark: [ + "#efdbff", + "#e1b6ff", + "#c961ff", + "#9a00cd", + "#560074", + "#07000d", + "#0a0012", + "#000000", + "#000000", + "#000000", + "#000000", + ], + light: [ + "#e8e2f6", + "#d0c3ed", + "#a587dc", + "#804cc6", + "#4b2977", + "#1a0b2e", + "#17092a", + "#140725", + "#0b0418", + "#06020f", + "#06020f", + ], + fgDark: WHITE, + fgLight: [ + "#ffffff", + "#fdfcfe", + "#fbf8fd", + "#fbf8fd", + "#f9f5fc", + "#f7f2fb", + "#d2b2e8", + "#b171d5", + "#7c3e9a", + "#421e54", + "#291135", + ], + }, }); const defaultTheme: MantineThemeOverride = { - ...baseTheme, - colors: { - ...baseTheme.colors, - ...defaultThemeColors, - }, + ...baseTheme, + colors: { + ...baseTheme.colors, + ...defaultThemeColors, + }, }; export { defaultTheme }; diff --git a/src/games/clairobscur/game-config/index.ts b/src/games/clairobscur/game-config/index.ts new file mode 100644 index 0000000..f0ee5ab --- /dev/null +++ b/src/games/clairobscur/game-config/index.ts @@ -0,0 +1,12 @@ +import type { GameConfig } from "#/features/game/types/game-config"; +import { ITEMS } from "#/games/clairobscur/game-config/items"; +import { THEME } from "#/games/clairobscur/game-config/theme"; +import type { ClairObscurLocalItem } from "#/games/clairobscur/types/local-item"; +import type { ClairObscurItemCategory } from "@/prisma"; + +const GAME_CONFIG = { + ITEMS, + THEME, +} satisfies GameConfig; + +export { GAME_CONFIG }; diff --git a/src/games/clairobscur/game-config/items.ts b/src/games/clairobscur/game-config/items.ts new file mode 100644 index 0000000..a3ded0a --- /dev/null +++ b/src/games/clairobscur/game-config/items.ts @@ -0,0 +1,40 @@ +import type { GameConfig } from "#/features/game/types/game-config"; +import { CHARACTERS } from "#/games/clairobscur/item-data.ts/characters"; +import type { ClairObscurLocalItem } from "#/games/clairobscur/types/local-item"; +import type { ClairObscurItemCategory } from "@/prisma"; + +const ITEMS_BY_CATEGORY = { + CHARACTER: CHARACTERS, + LUMINA: [], // TODO + PICTO: [], // TODO + SKILL: [], // TODO + WEAPON: [], // TODO +} satisfies Record; + +const allItems = Object.entries(ITEMS_BY_CATEGORY) + .flatMap(([, items]): ClairObscurLocalItem[] => items) + .sort((a, b) => a.name.localeCompare(b.name)); + +const ALL_CLAIROBSCUR_ITEMS = allItems; + +const allCategories = Object.keys( + ITEMS_BY_CATEGORY, +) as ClairObscurItemCategory[]; + +// TODO: If uncollectable items or linked items are added, +// TODO: filter them out of collectable items +// TODO: See remnant2/config/items.ts for example of how to do this +const collectableItems = allItems; + +const ITEMS: GameConfig< + ClairObscurLocalItem, + ClairObscurItemCategory +>["ITEMS"] = { + all: allItems, + categorized: { ...ITEMS_BY_CATEGORY }, + categories: allCategories, + uncollectableCategories: [], + collectable: collectableItems, +}; + +export { ALL_CLAIROBSCUR_ITEMS, ITEMS }; diff --git a/src/games/clairobscur/game-config/theme.ts b/src/games/clairobscur/game-config/theme.ts new file mode 100644 index 0000000..6e61e11 --- /dev/null +++ b/src/games/clairobscur/game-config/theme.ts @@ -0,0 +1,391 @@ +import type { MantineThemeOverride } from "@mantine/core"; +import { GAME_ID } from "#/games/slaythespire2/constants/game-id"; +import { BLACK, RED, WHITE } from "@/features/theme/constants/colors"; +import { baseTheme } from "@/features/theme/themes/base-theme"; +import type { ToolkitThemeDefinition } from "@/features/theme/types/toolkit-theme-definition"; +import { createThemeColors } from "@/features/theme/utils/create-theme-color"; + +const clairObscurThemeColors = createThemeColors({ + primary: { + dark: [ + "#e7f3ff", + "#d0e4ff", + "#a5cbff", + "#74b0ff", + "#4a95ff", + "#3380ff", + "#2a6edb", + "#1f5bb8", + "#154694", + "#0d2e5c", + ], + light: [ + "#f0f7ff", + "#e0efff", + "#bddeff", + "#8cc4ff", + "#5ba8ff", + "#2e8aff", + "#2471d9", + "#1a5ab3", + "#11438c", + "#0a2d5c", + ], + fgDark: WHITE, + fgLight: WHITE, + }, + + secondary: { + dark: [ + "#fef8f4", + "#fdf1e9", + "#fce0cd", + "#fad3b3", + "#f9c597", + "#f8b46d", + "#cc8b39", + "#966527", + "#644217", + "#382309", + ], + light: [ + "#fef8f4", + "#fdf1e9", + "#fce0cd", + "#fad3b3", + "#f9c597", + "#f8b46d", + "#cc8b39", + "#966527", + "#644217", + "#382309", + ], + fgDark: WHITE, + fgLight: WHITE, + }, + + accent: { + dark: [ + "#d5fedd", + "#a0fcb6", + "#4dee82", + "#45d875", + "#3cbf67", + "#34a85a", + "#278546", + "#1b6433", + "#0f4220", + "#05250f", + ], + light: [ + "#d5fedd", + "#a0fcb6", + "#4dee82", + "#45d875", + "#3cbf67", + "#34a85a", + "#278546", + "#1b6433", + "#0f4220", + "#05250f", + ], + fgDark: BLACK, + fgLight: BLACK, + }, + + base: { + dark: [ + "#e3e5ea", + "#c5c9d3", + "#8d96a9", + "#616877", + "#373c44", + "#111317", + "#0f1115", + "#0c0e12", + "#06070a", + "#030405", + ], + light: [ + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#b5d0f0", + "#8eb5e0", + "#6799d1", + "#3f7dc2", + ], + fgDark: WHITE, + fgLight: BLACK, + }, + + card: { + dark: [ + "#e2e5ed", + "#c6ccdc", + "#8a98ba", + "#5d6b88", + "#353e50", + "#131720", + "#10131b", + "#0e1118", + "#080b11", + "#06070c", + ], + light: [ + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#ffffff", + "#a8c4e8", + "#7aa0d1", + "#517ab5", + "#2e5694", + ], + fgDark: [ + "#f0f1f4", + "#e0e2e7", + "#c1c4cd", + "#a3a7b3", + "#848a99", + "#666d7f", + "#505565", + "#3d414d", + "#292c35", + "#17191f", + "#0e0f13", + ], + fgLight: [ + "#e4e5e8", + "#c7c9cf", + "#9296a2", + "#626571", + "#373940", + "#101114", + "#0d0e11", + "#0a0b0d", + "#070709", + "#030405", + "#030405", + ], + }, + + popover: { + dark: [ + "#e4e5ea", + "#c6c9d4", + "#9298ad", + "#61687b", + "#393d4a", + "#14161c", + "#111319", + "#0c0e13", + "#090b0f", + "#030405", + "#030405", + ], + light: WHITE, + fgDark: WHITE, + fgLight: BLACK, + }, + + muted: { + dark: [ + "#e3e5ea", + "#c8ccd7", + "#959eb2", + "#677083", + "#3f4451", + "#1c1f26", + "#171a20", + "#13161b", + "#0f1116", + "#06070a", + "#030405", + ], + light: [ + "#ffffff", + "#fcfcfc", + "#f9f9f9", + "#f6f6f6", + "#f3f3f3", + "#f1f1f1", + "#bbbbbb", + "#898989", + "#595959", + "#2e2e2e", + "#1b1b1b", + ], + fgDark: [ + "#e8e9ec", + "#d0d2d8", + "#a2a6b1", + "#787d8a", + "#4f5462", + "#282c38", + "#22252e", + "#1c1f26", + "#14161c", + "#0b0c11", + "#07080c", + ], + fgLight: [ + "#e5e6e7", + "#ccccce", + "#9b9c9f", + "#6e6f72", + "#414345", + "#1a1b1c", + "#151617", + "#111213", + "#0d0d0e", + "#070707", + "#070707", + ], + }, + + destructive: { + dark: RED, + light: RED, + fgDark: WHITE, + fgLight: WHITE, + }, + + border: { + dark: [ + "#e8ebf3", + "#cdd4e5", + "#9faece", + "#7287af", + "#51617f", + "#404c60", + "#353e4e", + "#2b323e", + "#22272f", + "#191d23", + ], + light: [ + "#ffffff", + "#fbfcfd", + "#fbfcfd", + "#f7fafc", + "#f4f7fa", + "#f1f5f9", + "#a4c2db", + "#6f8fa9", + "#485f70", + "#23303a", + ], + fgDark: WHITE, + fgLight: BLACK, + }, + + input: { + dark: [ + "#e5e8f1", + "#cbd2e3", + "#9aa9ca", + "#6c7fa5", + "#495773", + "#2b3446", + "#222a39", + "#1b222f", + "#141a24", + "#0a0e16", + "#05070d", + ], + light: [ + "#ffffff", + "#f8fbff", + "#f3f8ff", + "#eef4ff", + "#e8f1ff", + "#e1edff", + "#aac8eb", + "#7aa4d1", + "#5180b7", + "#2e5d9c", + ], + fgDark: WHITE, + fgLight: BLACK, + }, + + ring: { + dark: [ + "#d5fedd", + "#a0fcb6", + "#4dee82", + "#45d875", + "#3cbf67", + "#34a85a", + "#278546", + "#1b6433", + "#0f4220", + "#05250f", + ], + light: [ + "#d5fedd", + "#a0fcb6", + "#4dee82", + "#45d875", + "#3cbf67", + "#34a85a", + "#278546", + "#1b6433", + "#0f4220", + "#05250f", + ], + fgDark: WHITE, + fgLight: WHITE, + }, + + sidebar: { + dark: [ + "#e1e5ee", + "#c4ccdd", + "#889cbc", + "#5c6b84", + "#374050", + "#1f2530", + "#1a2129", + "#161d24", + "#12181f", + "#0e141a", + ], + light: [ + "#ffffff", + "#fbfcfd", + "#fbfcfd", + "#f8f9fb", + "#f8f9fb", + "#f5f7fa", + "#b1c3da", + "#738ead", + "#4b5d73", + "#252f3b", + ], + fgDark: WHITE, + fgLight: BLACK, + }, +}); + +const clairObscurTheme: MantineThemeOverride = { + ...baseTheme, + colors: { + ...baseTheme.colors, + ...clairObscurThemeColors, + }, +}; + +const THEME: ToolkitThemeDefinition = { + label: "Clair Obscur", + className: GAME_ID, + theme: clairObscurTheme, +}; + +export { clairObscurTheme, THEME }; diff --git a/src/games/clairobscur/item-data.ts/characters.ts b/src/games/clairobscur/item-data.ts/characters.ts new file mode 100644 index 0000000..8c1437e --- /dev/null +++ b/src/games/clairobscur/item-data.ts/characters.ts @@ -0,0 +1,7 @@ +import type { BaseClairObscurItem } from "#/games/clairobscur/types/local-item"; + +type ClairObscurCharacterItem = BaseClairObscurItem & {}; + +const CHARACTERS: ClairObscurCharacterItem[] = []; // TODO: Populate + +export { CHARACTERS, type ClairObscurCharacterItem }; diff --git a/src/games/clairobscur/types/equippable-by.ts b/src/games/clairobscur/types/equippable-by.ts new file mode 100644 index 0000000..903f7b6 --- /dev/null +++ b/src/games/clairobscur/types/equippable-by.ts @@ -0,0 +1,5 @@ +import type { ClairObscurCharacter } from "@/prisma"; + +type EquippableBy = ClairObscurCharacter[] | "ANY"; + +export type { EquippableBy }; diff --git a/src/games/clairobscur/types/item-locations.ts b/src/games/clairobscur/types/item-locations.ts new file mode 100644 index 0000000..9090049 --- /dev/null +++ b/src/games/clairobscur/types/item-locations.ts @@ -0,0 +1,4 @@ +// TODO: This is dummy data +type ClairObscurItemLocation = "Overgrowth" | "Underdocks" | "Hive" | "Glory"; + +export type { ClairObscurItemLocation }; diff --git a/src/games/clairobscur/types/local-item.ts b/src/games/clairobscur/types/local-item.ts new file mode 100644 index 0000000..ccb1200 --- /dev/null +++ b/src/games/clairobscur/types/local-item.ts @@ -0,0 +1,90 @@ +import type { AppItem } from "#/features/game/item/types/app-item"; +import type { ClairObscurCharacterItem } from "#/games/clairobscur/item-data.ts/characters"; +import type { ClairObscurItemLocation } from "#/games/clairobscur/types/item-locations"; +import type { + ClairObscurCharacter, + ClairObscurDLC, + ClairObscurItemCategory, +} from "@/prisma"; + +// Placeholder until we have actual tags to use +type ClairObscurInlineTags = string[]; +type ClairObscurCommunityTags = string[]; +type ClairObscurSearchableTags = string[]; + +type ClairObscurLinkedItem = Partial<{ + character: { name: ClairObscurCharacter }; +}>; + +type ItemModifiers = { + block: number; + blockX: number; + channel: Array<{ type: string; amount: number }>; + damage: number; + damageX: number; + dexterity: number; + doom: number; + draw: number; + energy: number; + exhaust: boolean; + focus: number; + gold: number; + heal: number; + health: number; + intangible: number; + poison: number; + plating: number; + retainHand: boolean; + stars: number; + strength: number; + summon: number; + summonPerTurn: number; + thorns: number; + vulnerable: number; + weak: number; + + enemyDamage: number; + enemyDamageX: number; + enemyHealth: number; + enemyStrength: number; +}; + +type ModifierTrigger = + | number // number of turns + | "attack" + | "cards added to deck" + | "combat start" + | "combat" + | "combat end" + | "each turn" + | "enter shop" + | "first three turns" + | "first time lose HP" + | "first time play Power" + | "next attack" + | "permanent" + | "shuffle draw pile"; + +/** + * Shared base item type for Slay The Spire 2 items + * Not intended to be used directly - use ClairObscurItem or specific item types instead + */ +type BaseClairObscurItem = AppItem< + ClairObscurItemCategory, + TItemSubcategory, + ClairObscurInlineTags, + ClairObscurCommunityTags, + ClairObscurSearchableTags, + ClairObscurLinkedItem +> & { + wikiUrl: string | undefined; + dlc: ClairObscurDLC; + location: ClairObscurItemLocation | undefined; + modifiers: + | Array<{ modifier: Partial; trigger: ModifierTrigger }> + | undefined; +}; + +type ClairObscurLocalItem = ClairObscurCharacterItem; // TODO Add other item types + +export type { BaseClairObscurItem, ClairObscurLocalItem };