refactor: improve bounty extraction logic and enhance character selection in infinite mode

This commit is contained in:
2026-03-14 17:09:33 +01:00
parent 3bd2506c2f
commit 57a0427e77
5 changed files with 233 additions and 126 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { User } from 'better-auth/types';
import { resolve } from '$app/paths';
interface Props {
user: (User & { isAdmin?: boolean }) | null;
@@ -59,7 +60,7 @@
{user.name?.charAt(0).toUpperCase() || 'U'}
</div>
{/if}
<span class="max-w-[150px] truncate text-sm font-semibold text-slate-100">
<span class="max-w-37.5 truncate text-sm font-semibold text-slate-100">
{user.name || 'Utilisateur'}
</span>
<svg
@@ -77,15 +78,15 @@
class="absolute right-0 top-full mt-2 w-48 rounded-xl border border-white/10 bg-slate-900/95 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
>
<a
href="/profile"
href={resolve("/profile")}
onclick={closeMenu}
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-slate-100 transition hover:bg-white/5 hover:text-amber-100 first:rounded-t-xl"
>
Voir mon profil
</a>
{#if (user as any).isAdmin}
{#if (user).isAdmin}
<a
href="/admin"
href={resolve("/admin")}
onclick={closeMenu}
class="block border-b border-white/5 px-4 py-3 text-sm font-semibold text-amber-300 transition hover:bg-white/5 hover:text-amber-200"
>
@@ -102,7 +103,7 @@
{/if}
{:else}
<a
href="/login"
href={resolve("/login")}
class="rounded-full bg-amber-300 px-5 py-2.5 text-sm font-semibold text-slate-900 transition hover:bg-amber-200"
>
Se connecter

View File

@@ -2,13 +2,16 @@ import { db } from '$lib/server/db';
import { character, devilFruit, arc, user } from '$lib/server/db/schema';
import { getOrCreateTodayCharacter, getTodayCharacterWinsCount } from '$lib/server/daily-character';
import type { PageServerLoad } from './$types';
import { count, eq } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const [characters, devilFruits, arcs, users] = await Promise.all([
db.select().from(character),
db.select().from(devilFruit),
db.select().from(arc),
db.select().from(user)
const [totalCharacters, totalDevilFruits, totalArcs, totalUsers, adminUsers, charactersInDaily] = await Promise.all([
db.select({ count: count() }).from(character),
db.select({ count: count() }).from(devilFruit),
db.select({ count: count() }).from(arc),
db.select({ count: count() }).from(user),
db.select({ count: count() }).from(user).where(eq(user.isAdmin, true)),
db.select({ count: count() }).from(character).where(eq(character.isInDailyMode, true))
]);
// Get today's daily character and count wins
@@ -21,12 +24,12 @@ export const load: PageServerLoad = async () => {
return {
stats: {
totalCharacters: characters.length,
charactersInDaily: characters.filter((c) => c.isInDailyMode).length,
totalDevilFruits: devilFruits.length,
totalArcs: arcs.length,
totalUsers: users.length,
adminUsers: users.filter((u) => u.isAdmin).length,
totalCharacters: totalCharacters[0].count,
charactersInDaily: charactersInDaily[0].count,
totalDevilFruits: totalDevilFruits[0].count,
totalArcs: totalArcs[0].count,
totalUsers: totalUsers[0].count,
adminUsers: adminUsers[0].count,
dailyCharacterWins
}
};

View File

@@ -4,7 +4,7 @@ import { getAllCharacters } from '$lib/server/daily-character';
import { like } from 'drizzle-orm';
export async function load() {
let characters = await getAllCharacters();
const characters = await getAllCharacters();
// Load column visibility config
const columnConfig = await db

View File

@@ -1,16 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import CharacterSearchInput from '$lib/components/CharacterSearchInput.svelte';
import GuessHistoryTable from '$lib/components/GuessHistoryTable.svelte';
import WinPanel from '$lib/components/WinPanel.svelte';
import HintsPanel from '$lib/components/HintsPanel.svelte';
import type { CharacterWithRelations } from '$lib/server/daily-character.js';
export let data;
let selectedCharacters: any[] = [];
let currentCharacter: any = null;
let selectedCharacters: CharacterWithRelations[] = [];
let currentCharacter: CharacterWithRelations | null = null;
let isLoaded = false;
let score = 0;
type ArcFilterOption = { id: string; name: string };
let allCharacters: CharacterWithRelations[] = [];
let characters: CharacterWithRelations[] = [];
let availableArcs: ArcFilterOption[] = [];
let hasWon = false;
let columnVisibility: Record<string, boolean> = {};
const columnDisplayNames: Record<string, string> = {
status: 'Statut',
@@ -35,13 +41,85 @@
arcs: [] as string[]
};
let wasOriginAvailable = false;
let wasFruitAvailable = false;
let wasAffiliationAvailable = false;
let showOriginUnlock = false;
let showFruitUnlock = false;
let showAffiliationUnlock = false;
let isGeckoMoriaWin = false;
let originUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let fruitUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
let affiliationUnlockTimeout: ReturnType<typeof setTimeout> | null = null;
function clearUnlockTimeout(timeout: ReturnType<typeof setTimeout> | null) {
if (timeout) {
clearTimeout(timeout);
}
}
function pulseUnlock(type: 'origin' | 'fruit' | 'affiliation') {
if (type === 'origin') {
clearUnlockTimeout(originUnlockTimeout);
showOriginUnlock = true;
originUnlockTimeout = setTimeout(() => {
showOriginUnlock = false;
originUnlockTimeout = null;
}, 600);
return;
}
if (type === 'fruit') {
clearUnlockTimeout(fruitUnlockTimeout);
showFruitUnlock = true;
fruitUnlockTimeout = setTimeout(() => {
showFruitUnlock = false;
fruitUnlockTimeout = null;
}, 600);
return;
}
clearUnlockTimeout(affiliationUnlockTimeout);
showAffiliationUnlock = true;
affiliationUnlockTimeout = setTimeout(() => {
showAffiliationUnlock = false;
affiliationUnlockTimeout = null;
}, 600);
}
function syncHintAvailability(previousGuessCount: number, nextGuessCount: number, animateUnlocks = false) {
const nextOriginAvailable = nextGuessCount >= 5;
const nextFruitAvailable = nextGuessCount >= 10;
const nextAffiliationAvailable = nextGuessCount >= 15;
if (animateUnlocks && nextOriginAvailable && previousGuessCount < 5) {
pulseUnlock('origin');
}
if (animateUnlocks && nextFruitAvailable && previousGuessCount < 10) {
pulseUnlock('fruit');
}
if (animateUnlocks && nextAffiliationAvailable && previousGuessCount < 15) {
pulseUnlock('affiliation');
}
if (!nextOriginAvailable) {
showOriginUnlock = false;
clearUnlockTimeout(originUnlockTimeout);
originUnlockTimeout = null;
}
if (!nextFruitAvailable) {
showFruitUnlock = false;
clearUnlockTimeout(fruitUnlockTimeout);
fruitUnlockTimeout = null;
}
if (!nextAffiliationAvailable) {
showAffiliationUnlock = false;
clearUnlockTimeout(affiliationUnlockTimeout);
affiliationUnlockTimeout = null;
}
}
// Load from localStorage on mount
onMount(() => {
@@ -56,6 +134,7 @@
try {
columnVisibility = JSON.parse(storedColumnVisibility);
} catch (e) {
console.error('Failed to parse column visibility', e);
columnVisibility = data.columnVisibility || {};
}
} else {
@@ -86,18 +165,19 @@
const historyIds = JSON.parse(storedHistoryIds);
// Find the character object by ID
currentCharacter = characters.find((c: any) => c.id === charId);
currentCharacter = characters.find((c: CharacterWithRelations) => c.id === charId) || null;
// Find all character objects by their IDs
selectedCharacters = historyIds
.map((id: string) => characters.find((c: any) => c.id === id))
.filter((c: any) => c !== undefined);
.map((id: string) => characters.find((c: CharacterWithRelations) => c.id === id))
.filter((c: CharacterWithRelations | undefined) => !!c) as CharacterWithRelations[];
// If character not found, generate a new one
if (!currentCharacter) {
generateNewCharacter();
}
} catch (e) {
console.error('Failed to parse character data', e);
// If parsing fails, generate a new character
generateNewCharacter();
}
@@ -105,9 +185,16 @@
generateNewCharacter();
}
syncHintAvailability(0, selectedCharacters.length);
isLoaded = true;
});
onDestroy(() => {
clearUnlockTimeout(originUnlockTimeout);
clearUnlockTimeout(fruitUnlockTimeout);
clearUnlockTimeout(affiliationUnlockTimeout);
});
// Save score to localStorage whenever it changes
$: if (isLoaded) {
localStorage.setItem('infiniteScore', score.toString());
@@ -130,26 +217,30 @@
// Save selected character IDs to localStorage whenever it changes
$: if (isLoaded) {
const selectedIds = selectedCharacters.map((c: any) => c.id);
const selectedIds = selectedCharacters.map((c: CharacterWithRelations) => c.id);
localStorage.setItem('infiniteSelectedCharacterIds', JSON.stringify(selectedIds));
}
$: allCharacters = data.characters || [];
// Extract unique arcs from all characters
$: availableArcs = [
...new Map(
$: {
const arcMap = new Map<string, ArcFilterOption>(
allCharacters
.filter((char: any) => char.arcId && char.arcName)
.map((char: any) => [char.arcId, { id: char.arcId, name: char.arcName }])
).values()
]
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
.filter(
(char: CharacterWithRelations): char is CharacterWithRelations & { arcId: string; arcName: string } =>
typeof char.arcId === 'string' && char.arcId.length > 0 && typeof char.arcName === 'string' && char.arcName.length > 0
)
.map((char: CharacterWithRelations & { arcId: string; arcName: string }) => [char.arcId, { id: char.arcId, name: char.arcName }])
);
availableArcs = [...arcMap.values()].sort((a, b) => a.name.localeCompare(b.name));
}
// Filter characters based on selected filters
$: characters = allCharacters.filter((char: any) => {
$: characters = allCharacters.filter((char: CharacterWithRelations) => {
// Gender filter
if (characterFilters.gender.length > 0 && !characterFilters.gender.includes(char.gender)) {
if (characterFilters.gender.length > 0 && (char.gender == null || !characterFilters.gender.includes(char.gender))) {
return false;
}
@@ -185,48 +276,26 @@
}
// Arc filter
if (characterFilters.arcs.length > 0 && !characterFilters.arcs.includes(char.arcId)) {
if (characterFilters.arcs.length > 0 && (char.arcId == null || !characterFilters.arcs.includes(char.arcId))) {
return false;
}
return true;
});
$: hasWon = currentCharacter && selectedCharacters.some(char => char.id === currentCharacter.id);
$: {
const currentCharacterId = currentCharacter?.id;
hasWon = currentCharacterId != null && selectedCharacters.some(char => char.id === currentCharacterId);
}
$: if (hasWon && currentCharacter?.id === 'gecko_moria_gecko_moria') {
isGeckoMoriaWin = true;
} else if (!hasWon) {
isGeckoMoriaWin = false;
}
// Hint availability tracking for unlock animations
$: isOriginAvailable = selectedCharacters.length >= 5;
$: isFruitAvailable = selectedCharacters.length >= 10;
$: isAffiliationAvailable = selectedCharacters.length >= 15;
// Track hint unlocks
$: if (isLoaded) {
if (isOriginAvailable && !wasOriginAvailable) {
showOriginUnlock = true;
setTimeout(() => (showOriginUnlock = false), 600);
}
wasOriginAvailable = isOriginAvailable;
if (isFruitAvailable && !wasFruitAvailable) {
showFruitUnlock = true;
setTimeout(() => (showFruitUnlock = false), 600);
}
wasFruitAvailable = isFruitAvailable;
if (isAffiliationAvailable && !wasAffiliationAvailable) {
showAffiliationUnlock = true;
setTimeout(() => (showAffiliationUnlock = false), 600);
}
wasAffiliationAvailable = isAffiliationAvailable;
}
function generateNewCharacter() {
if (characters.length === 0) return;
currentCharacter = characters[Math.floor(Math.random() * characters.length)];
syncHintAvailability(selectedCharacters.length, 0);
selectedCharacters = [];
}
@@ -235,11 +304,18 @@
selectCharacter(character);
}
function selectCharacter(character: any) {
function selectCharacter(character: CharacterWithRelations) {
const current = currentCharacter;
if (!current) {
return;
}
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [character, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
// Check if player won
if (character.id === currentCharacter.id) {
if (character.id === current.id) {
// Increment score (saved to localStorage via reactive statement)
score++;
// Don't auto-generate next character - wait for user to click "Recommencer"
@@ -265,10 +341,16 @@
}
function revealAnswer() {
if (!currentCharacter) {
return;
}
// Reset score (strike)
score = 0;
// Add the current character as the correct answer
const previousGuessCount = selectedCharacters.length;
selectedCharacters = [currentCharacter, ...selectedCharacters];
syncHintAvailability(previousGuessCount, selectedCharacters.length, isLoaded);
}
function toggleGenderFilter(gender: string) {
@@ -396,16 +478,22 @@
<style>
@keyframes shadow-pulse {
0% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
50% {
text-shadow: 0 0 60px rgba(0, 0, 0, 0.9), 0 0 100px rgba(30, 30, 30, 1),
text-shadow:
0 0 60px rgba(0, 0, 0, 0.9),
0 0 100px rgba(30, 30, 30, 1),
inset 0 0 50px rgba(0, 0, 0, 0.7);
opacity: 0.9;
}
100% {
text-shadow: 0 0 20px rgba(0, 0, 0, 0.5), 0 0 40px rgba(50, 50, 50, 0.8);
text-shadow:
0 0 20px rgba(0, 0, 0, 0.5),
0 0 40px rgba(50, 50, 50, 0.8);
opacity: 1;
}
}
@@ -461,18 +549,22 @@
</svelte:head>
<main
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin ? 'moria-screen-chaos' : ''}"
class="relative min-h-screen overflow-hidden bg-slate-950 text-slate-100 {isGeckoMoriaWin
? 'moria-screen-chaos'
: ''}"
>
<div class="absolute inset-0 bg-gradient-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"></div>
<div
class="absolute inset-0 mix-blend-screen opacity-20 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)]"
class="absolute inset-0 bg-linear-to-br from-slate-950/85 via-slate-900/60 to-slate-950/80"
></div>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,215,84,0.35),transparent_55%)] opacity-20 mix-blend-screen"
></div>
<div class="relative mx-auto flex min-h-screen w-full max-w-6xl flex-col px-6 py-8 sm:py-10">
<header class="flex flex-col items-start gap-6 w-full">
<header class="flex w-full flex-col items-start gap-6">
<div class="flex w-full items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-black uppercase tracking-[0.25em] text-amber-50 sm:text-5xl">
<h1 class="text-3xl font-black tracking-[0.25em] text-amber-50 uppercase sm:text-5xl">
Mode Infini
</h1>
<p class="mt-2 text-2xl font-bold text-amber-300">Score: {score}</p>
@@ -496,11 +588,7 @@
{#if currentCharacter}
{#if hasWon}
<div>
<WinPanel
selectedCharacter={currentCharacter}
{selectedCharacters}
{isGeckoMoriaWin}
/>
<WinPanel selectedCharacter={currentCharacter} {selectedCharacters} {isGeckoMoriaWin} />
<button
type="button"
onclick={nextCharacter}
@@ -518,7 +606,7 @@
{showFruitUnlock}
{showAffiliationUnlock}
/>
<div class="flex justify-center mt-2">
<div class="mt-2 flex justify-center">
<button
type="button"
onclick={revealAnswer}
@@ -535,7 +623,9 @@
/>
{/if}
{:else}
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur">
<div
class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_24px_60px_rgba(0,0,0,0.45)] backdrop-blur"
>
<p class="text-center text-slate-300">Chargement du personnage...</p>
</div>
{/if}
@@ -550,30 +640,34 @@
<!-- Character Filters -->
<section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Filtres de personnages</h3>
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
Filtres de personnages
</h3>
{#if characterFilters.gender.length > 0 || characterFilters.hasHaki || characterFilters.hasDevilFruit !== null || characterFilters.status.length > 0 || characterFilters.hasHeight || characterFilters.hasOrigin || characterFilters.arcs.length > 0}
<button
type="button"
onclick={clearAllFilters}
class="text-xs text-red-300 hover:text-red-200 transition"
class="text-xs text-red-300 transition hover:text-red-200"
>
Réinitialiser
</button>
{/if}
</div>
<div class="space-y-3">
<!-- Gender Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Genre</p>
<p class="mb-2 text-xs text-slate-400">Genre</p>
<div class="flex flex-wrap gap-2">
{#each ['Male', 'Female'] as gender}
{#each ['Male', 'Female'] as gender (gender)}
<button
type="button"
onclick={() => toggleGenderFilter(gender)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(gender)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.gender.includes(
gender
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
@@ -585,13 +679,15 @@
<!-- Status Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Statut</p>
<p class="mb-2 text-xs text-slate-400">Statut</p>
<div class="flex flex-wrap gap-2">
{#each ['Alive', 'Dead', 'Unknown'] as status}
{#each ['Alive', 'Dead', 'Unknown'] as status (status)}
<button
type="button"
onclick={() => toggleStatusFilter(status)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(status)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.status.includes(
status
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
@@ -603,7 +699,7 @@
<!-- Haki Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Capacités</p>
<p class="mb-2 text-xs text-slate-400">Capacités</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
@@ -617,20 +713,25 @@
<button
type="button"
onclick={toggleDevilFruitFilter}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit === true
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.hasDevilFruit ===
true
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: characterFilters.hasDevilFruit === false
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
? 'border-purple-300/50 bg-purple-300/10 text-purple-100 hover:bg-purple-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
{characterFilters.hasDevilFruit === null ? 'Fruit (Tous)' : characterFilters.hasDevilFruit ? 'Avec Fruit' : 'Sans Fruit'}
{characterFilters.hasDevilFruit === null
? 'Fruit (Tous)'
: characterFilters.hasDevilFruit
? 'Avec Fruit'
: 'Sans Fruit'}
</button>
</div>
</div>
<!-- Informations Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Informations</p>
<p class="mb-2 text-xs text-slate-400">Informations</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
@@ -652,16 +753,18 @@
</button>
</div>
</div>
<!-- Arc Filter -->
<div>
<p class="text-xs text-slate-400 mb-2">Arcs</p>
<p class="mb-2 text-xs text-slate-400">Arcs</p>
<div class="flex flex-wrap gap-2">
{#each availableArcs as arc (arc.id)}
<button
type="button"
onclick={() => toggleArcFilter(arc.id)}
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(arc.id)
class="rounded-full border px-2.5 py-1 text-xs font-medium transition-colors {characterFilters.arcs.includes(
arc.id
)
? 'border-amber-300/50 bg-amber-300/10 text-amber-100 hover:bg-amber-300/20'
: 'border-white/20 bg-slate-900/40 text-slate-400 hover:bg-slate-900/60'}"
>
@@ -670,9 +773,12 @@
{/each}
</div>
</div>
<p class="text-xs text-slate-500 mt-2">
{characters.length} personnage{characters.length > 1 ? 's' : ''} disponible{characters.length > 1 ? 's' : ''}
<p class="mt-2 text-xs text-slate-500">
{characters.length} personnage{characters.length > 1 ? 's' : ''} disponible{characters.length >
1
? 's'
: ''}
</p>
</div>
</div>
@@ -680,11 +786,15 @@
<!-- Column Visibility Toggle -->
<section class="mt-6">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 sm:p-4 backdrop-blur">
<div class="rounded-2xl border border-white/10 bg-white/5 p-3 backdrop-blur sm:p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<h3 class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-200">Colonnes</h3>
<h3 class="text-xs font-semibold tracking-[0.2em] text-amber-200 uppercase">
Colonnes
</h3>
<p class="text-xs text-slate-400">
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(columnVisibility).length}
{Object.values(columnVisibility).filter(Boolean).length}/{Object.keys(
columnVisibility
).length}
</p>
</div>
<div class="flex flex-wrap gap-2">