feat: implement daily character guessing game with local storage and hint system
- Added character selection and history management using local storage. - Implemented hint system that unlocks based on the number of guesses. - Enhanced UI with animations for hint unlocks and special win conditions. - Created a server endpoint to record wins in the database.
This commit is contained in:
152
src/lib/server/daily-character.ts
Normal file
152
src/lib/server/daily-character.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { arc, character, characterHistory, devilFruit } from '$lib/server/db/schema';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
|
||||
const characterWithRelationsSelect = {
|
||||
id: character.id,
|
||||
name: character.name,
|
||||
gender: character.gender,
|
||||
age: character.age,
|
||||
affiliations: character.affiliations,
|
||||
devilFruitId: character.devilFruitId,
|
||||
devilFruitName: devilFruit.name,
|
||||
devilFruitType: devilFruit.type,
|
||||
hakiObservation: character.hakiObservation,
|
||||
hakiArmament: character.hakiArmament,
|
||||
hakiConqueror: character.hakiConqueror,
|
||||
bounty: character.bounty,
|
||||
height: character.height,
|
||||
origin: character.origin,
|
||||
firstAppearance: character.firstAppearance,
|
||||
pictureUrl: character.pictureUrl,
|
||||
epithets: character.epithets,
|
||||
status: character.status,
|
||||
url: character.url,
|
||||
arcId: character.arcId,
|
||||
arcName: arc.name
|
||||
};
|
||||
|
||||
export type CharacterWithRelations = typeof character.$inferSelect & {
|
||||
devilFruitName: string | null;
|
||||
devilFruitType: string | null;
|
||||
arcName: string | null;
|
||||
};
|
||||
|
||||
function getDateKey(date: Date): string {
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function normalizeDay(date: Date = new Date()): Date {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pickDailyCharacter(characters: CharacterWithRelations[], date: Date): CharacterWithRelations {
|
||||
const dateStr = getDateKey(date);
|
||||
const seed = dateStr.split('-').reduce((acc, value) => acc + parseInt(value), 0);
|
||||
const index = seed % characters.length;
|
||||
return characters[index];
|
||||
}
|
||||
|
||||
export async function getDailyModeCharacters(): Promise<CharacterWithRelations[]> {
|
||||
return db
|
||||
.select(characterWithRelationsSelect)
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.where(eq(character.isInDailyMode, true))
|
||||
.all() as Promise<CharacterWithRelations[]>;
|
||||
}
|
||||
|
||||
export async function getCharacterById(characterId: string): Promise<CharacterWithRelations | null> {
|
||||
const [found] = await db
|
||||
.select(characterWithRelationsSelect)
|
||||
.from(character)
|
||||
.leftJoin(arc, eq(character.arcId, arc.id))
|
||||
.leftJoin(devilFruit, eq(character.devilFruitId, devilFruit.id))
|
||||
.where(eq(character.id, characterId))
|
||||
.limit(1);
|
||||
|
||||
return (found ?? null) as CharacterWithRelations | null;
|
||||
}
|
||||
|
||||
export async function getOrCreateTodayCharacter(
|
||||
characters: CharacterWithRelations[],
|
||||
date: Date = new Date()
|
||||
): Promise<CharacterWithRelations | null> {
|
||||
if (characters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const today = normalizeDay(date);
|
||||
const todayDate = getDateKey(today);
|
||||
|
||||
const [existingEntry] = await db
|
||||
.select()
|
||||
.from(characterHistory)
|
||||
.where(eq(characterHistory.date, todayDate))
|
||||
.limit(1);
|
||||
|
||||
if (existingEntry?.characterId) {
|
||||
return (
|
||||
characters.find((currentCharacter) => currentCharacter.id === existingEntry.characterId) ??
|
||||
(await getCharacterById(existingEntry.characterId))
|
||||
);
|
||||
}
|
||||
|
||||
const recentHistory = await db
|
||||
.select({ characterId: characterHistory.characterId })
|
||||
.from(characterHistory)
|
||||
.orderBy(desc(characterHistory.date))
|
||||
.limit(100);
|
||||
|
||||
const excludedIds = new Set(recentHistory.map((entry) => entry.characterId));
|
||||
const availableCharacters = characters.filter((currentCharacter) => !excludedIds.has(currentCharacter.id));
|
||||
|
||||
const dailyCharacter = pickDailyCharacter(
|
||||
availableCharacters.length > 0 ? availableCharacters : characters,
|
||||
today
|
||||
);
|
||||
|
||||
try {
|
||||
await db.insert(characterHistory).values({
|
||||
characterId: dailyCharacter.id,
|
||||
date: todayDate,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to record daily character:', error);
|
||||
}
|
||||
|
||||
return dailyCharacter;
|
||||
}
|
||||
|
||||
export async function getYesterdayCharacter(
|
||||
date: Date = new Date(),
|
||||
characters?: CharacterWithRelations[]
|
||||
): Promise<CharacterWithRelations | null> {
|
||||
const baseDate = normalizeDay(date);
|
||||
baseDate.setDate(baseDate.getDate() - 1);
|
||||
const yesterdayDate = getDateKey(baseDate);
|
||||
|
||||
const [yesterdayEntry] = await db
|
||||
.select()
|
||||
.from(characterHistory)
|
||||
.where(eq(characterHistory.date, yesterdayDate))
|
||||
.limit(1);
|
||||
|
||||
if (!yesterdayEntry?.characterId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (characters) {
|
||||
return (
|
||||
characters.find((currentCharacter) => currentCharacter.id === yesterdayEntry.characterId) ??
|
||||
(await getCharacterById(yesterdayEntry.characterId))
|
||||
);
|
||||
}
|
||||
|
||||
return getCharacterById(yesterdayEntry.characterId);
|
||||
}
|
||||
@@ -1,16 +1,29 @@
|
||||
import { integer, sqliteTable, text, real } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
// Define haki types
|
||||
export type HakiType = 'Observation' | 'Armament' | 'Conqueror';
|
||||
|
||||
// Define devil fruit types
|
||||
export type DevilFruitType = 'Paramecia' | 'Zoan' | 'Logia' | 'Unknown';
|
||||
|
||||
// Define the site config table schema
|
||||
export const config = sqliteTable('config', {
|
||||
key: text('key').primaryKey(),
|
||||
value: text('value')
|
||||
});
|
||||
|
||||
// Define the arc table schema
|
||||
export const arc = sqliteTable('arc', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
startChapter: integer('startChapter').notNull(),
|
||||
endChapter: integer('endChapter'),
|
||||
url: text('url')
|
||||
});
|
||||
|
||||
// Define the devil fruit table schema
|
||||
export const devilFruit = sqliteTable('devilFruit', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull().unique(),
|
||||
type: text('type').$type<DevilFruitType>()
|
||||
type: text('type').$type<DevilFruitType>(),
|
||||
url: text('url')
|
||||
});
|
||||
|
||||
// Define the character table schema
|
||||
@@ -19,15 +32,66 @@ export const character = sqliteTable('character', {
|
||||
name: text('name').notNull(),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations'),
|
||||
devilFruit: text('devilFruit').references(() => devilFruit.id),
|
||||
haki: text('haki', { mode: 'json' }).$type<HakiType[]>(),
|
||||
bounty: integer('bounty'),
|
||||
// height in meters as a float (e.g. 1.75)
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
|
||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
|
||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
|
||||
bounty: integer('bounty').default(0),
|
||||
height: real('height'),
|
||||
origin: text('origin'),
|
||||
firstAppearance: text('firstAppearance'),
|
||||
pictureUrl: text('pictureUrl')
|
||||
firstAppearance: integer('firstAppearance').notNull(),
|
||||
pictureUrl: text('pictureUrl'),
|
||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||
status: text('status'),
|
||||
arcId: text('arcId').references(() => arc.id),
|
||||
url: text('url'),
|
||||
isInDailyMode: integer('isInDailyMode', { mode: 'boolean' }).default(true)
|
||||
});
|
||||
|
||||
// Define the character override table schema
|
||||
export const characterOverride = sqliteTable('characterOverride', {
|
||||
characterId: text('characterId').primaryKey().references(() => character.id),
|
||||
name: text('name'),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }),
|
||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }),
|
||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }),
|
||||
bounty: integer('bounty'),
|
||||
height: real('height'),
|
||||
origin: text('origin'),
|
||||
firstAppearance: integer('firstAppearance').notNull(),
|
||||
pictureUrl: text('pictureUrl'),
|
||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||
status: text('status'),
|
||||
arcId: text('arcId').references(() => arc.id),
|
||||
url: text('url'),
|
||||
notes: text('notes')
|
||||
});
|
||||
|
||||
// Define the character scrape validation table schema
|
||||
export const characterScrapeValidation = sqliteTable('characterScrapeValidation', {
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
gender: text('gender'),
|
||||
age: integer('age'),
|
||||
affiliations: text('affiliations', { mode: 'json' }).$type<string[]>(),
|
||||
devilFruitId: text('devilFruitId').references(() => devilFruit.id),
|
||||
hakiObservation: integer('hakiObservation', { mode: 'boolean' }).default(false),
|
||||
hakiArmament: integer('hakiArmament', { mode: 'boolean' }).default(false),
|
||||
hakiConqueror: integer('hakiConqueror', { mode: 'boolean' }).default(false),
|
||||
bounty: integer('bounty'),
|
||||
height: real('height'),
|
||||
origin: text('origin'),
|
||||
firstAppearance: integer('firstAppearance').notNull(),
|
||||
pictureUrl: text('pictureUrl'),
|
||||
epithets: text('epithets', { mode: 'json' }).$type<string[]>(),
|
||||
status: text('status'),
|
||||
arcId: text('arcId').references(() => arc.id),
|
||||
url: text('url')
|
||||
});
|
||||
|
||||
// Define the caracter history table schema
|
||||
@@ -36,9 +100,11 @@ export const characterHistory = sqliteTable('characterHistory', {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
characterId: text('characterId').references(() => character.id),
|
||||
date: integer('date'),
|
||||
date: text('date'),
|
||||
won: integer('won').notNull().default(0),
|
||||
createdAt: integer('createdAt').notNull().$default(() => Date.now()),
|
||||
updatedAt: integer('updatedAt').notNull().$default(() => Date.now()),
|
||||
});
|
||||
|
||||
|
||||
export * from './auth.schema';
|
||||
|
||||
Reference in New Issue
Block a user