diff --git a/public/easter-egg/man.ogg b/public/easter-egg/man.ogg
new file mode 100644
index 0000000..3303b64
Binary files /dev/null and b/public/easter-egg/man.ogg differ
diff --git a/public/easter-egg/snd_egg.wav b/public/easter-egg/snd_egg.wav
new file mode 100644
index 0000000..0f356d9
Binary files /dev/null and b/public/easter-egg/snd_egg.wav differ
diff --git a/public/easter-egg/snd_text.wav b/public/easter-egg/snd_text.wav
new file mode 100644
index 0000000..dac4718
Binary files /dev/null and b/public/easter-egg/snd_text.wav differ
diff --git a/public/easter-egg/tree.gif b/public/easter-egg/tree.gif
new file mode 100644
index 0000000..a27de11
Binary files /dev/null and b/public/easter-egg/tree.gif differ
diff --git a/src/app/egg/page.tsx b/src/app/egg/page.tsx
new file mode 100644
index 0000000..3cb10ad
--- /dev/null
+++ b/src/app/egg/page.tsx
@@ -0,0 +1,11 @@
+import type { Metadata } from "next"
+import { ForgottenManRoom } from "@/components/easter-egg/room"
+
+export const metadata: Metadata = {
+ title: { absolute: "???" },
+ robots: { index: false, follow: false },
+}
+
+export default function EggPage() {
+ return
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 62e5cb9..a88527d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,5 +1,6 @@
import { Background } from "@/components/background"
import { ConditionalFooter } from "@/components/conditional-footer"
+import { ForgottenManTrigger } from "@/components/easter-egg/trigger"
import { Footer } from "@/components/footer"
import { Header } from "@/components/header/header"
import { ThemeProvider } from "@/components/theme-provider"
@@ -75,6 +76,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
disableTransitionOnChange
>
+
{children}
diff --git a/src/components/easter-egg/constants.ts b/src/components/easter-egg/constants.ts
new file mode 100644
index 0000000..432ed12
--- /dev/null
+++ b/src/components/easter-egg/constants.ts
@@ -0,0 +1,71 @@
+import { matchesRoutePrefix } from "@/utils/route-match"
+import type { DialogueStep, NavCategory } from "./types"
+
+// omaggio al Forgotten Man / Egg di Deltarune: stanza nascosta raggiungibile solo andando avanti e indietro
+export const FORGOTTEN_MAN_PATH = "/egg"
+export const TRIGGER_CHANCE = 0.02
+export const OBTAINED_STORAGE_KEY = "polinetwork:forgotten-man-egg"
+// separato dal precedente: si imposta solo se l'uovo viene accettato
+export const RECEIVED_STORAGE_KEY = "polinetwork:forgotten-man-egg-received"
+
+const HOME_PATH = "/"
+const GROUPS_PREFIX = "/groups"
+
+export function getCategory(pathname: string): NavCategory {
+ if (pathname === HOME_PATH) return "home"
+ if (matchesRoutePrefix(pathname, GROUPS_PREFIX)) return "groups"
+ return "other"
+}
+
+const ASSETS_DIR = "/easter-egg"
+export const MUSIC_SRC = { ogg: `${ASSETS_DIR}/man.ogg`, mp3: `${ASSETS_DIR}/man.mp3` }
+export const MUSIC_VOLUME = 0.7
+export const TEXT_SOUND_SRC = `${ASSETS_DIR}/snd_text.wav`
+export const TEXT_SOUND_VOLUME = 0.8
+export const EGG_SOUND_SRC = `${ASSETS_DIR}/snd_egg.wav`
+export const EGG_SOUND_VOLUME = 0.8
+
+// tutte le misure della stanza sono in "pixel di gioco" (--px) e in tile da 20 pixel di gioco
+export const TILE = 20
+export const TREE = {
+ src: `${ASSETS_DIR}/tree.gif`,
+ // dimensioni native della gif e posizione rispetto all'angolo in alto a sinistra della stanza
+ width: 106,
+ height: 92,
+ left: 20,
+ top: -21,
+}
+
+export const COLORS = {
+ floor: "#612c61",
+ border: "#ffffff",
+ borderInner: "#9da2c4",
+ sparkle: "#aaffe6",
+ shadowTop: "#4646b0",
+ shadowBottom: "#a7a7c9",
+ soul: "#ff0000",
+}
+
+export const TYPE_DELAY = 35
+export const PUNCTUATION_DELAY = 200
+export const DIALOGUE_START_DELAY = 1500
+
+export const DIALOGUE: DialogueStep[] = [
+ "(C'è qualcuno dietro l'albero.)",
+ "Ciao.",
+ "Ti ho visto. Avanti e indietro. Avanti e indietro.",
+ "Anch'io facevo così, una volta.",
+ "Poi, un giorno, la pagina non si è più caricata.",
+ "E adesso sono qui.",
+ "Ho un uovo.",
+ {
+ text: "Vuoi l'uovo?",
+ choices: [
+ { label: "Sì", reply: ["Bene.", { text: "(Hai ricevuto un Uovo.)", event: "receive-egg" }] },
+ { label: "No", reply: ["..."] },
+ ],
+ },
+]
+
+// ciò che compare interagendo di nuovo dopo la fine del dialogo
+export const EPILOGUE: DialogueStep[] = ["(Non c'è nessuno dietro l'albero.)"]
diff --git a/src/components/easter-egg/dialogue-box.tsx b/src/components/easter-egg/dialogue-box.tsx
new file mode 100644
index 0000000..bca4117
--- /dev/null
+++ b/src/components/easter-egg/dialogue-box.tsx
@@ -0,0 +1,121 @@
+import type { CSSProperties, Dispatch, ReactNode } from "react"
+import { cn } from "@/lib/utils"
+import { COLORS } from "./constants"
+import type { DialogueAction, DialogueState } from "./dialogue"
+import { stepChoices, stepText } from "./dialogue"
+import { pixelFont } from "./font"
+import { PixelSprite } from "./pixel-sprite"
+import { CORNER_OFFSET, CORNER_PALETTE, CORNER_ROWS, CORNER_SIZE, SOUL_PALETTE, SOUL_ROWS } from "./sprites"
+
+// box nativo di Deltarune: 288x76 pixel di gioco, scalato a multipli interi per tenere i pixel nitidi
+const BOX_PX = "max(2px, min(4px, round(down, min(90vw / 288, 32svh / 76), 1px)))"
+const LINE_HEIGHT = 18
+
+const px = (n: number) => `calc(${n} * var(--px))`
+
+const CORNERS = [
+ "top-0 left-0",
+ "top-0 right-0 -scale-x-100",
+ "bottom-0 left-0 -scale-y-100",
+ "right-0 bottom-0 -scale-100",
+] as const
+
+// ombra blu sfumata dall'alto verso il basso di ogni riga, come nel gioco
+const SHADOW_STYLE: CSSProperties = {
+ translate: `${px(1)} ${px(1)}`,
+ // backgroundImage: `linear-gradient(${COLORS.shadowTop} ${px(5)}, ${COLORS.shadowBottom} ${px(13)})`,
+ backgroundSize: `100% ${px(LINE_HEIGHT)}`,
+}
+
+function ShadowedText({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ {children}
+
+ )
+}
+
+function TypedText({ text, typed }: { text: string; typed: number }) {
+ // il testo ancora da scrivere occupa già il suo spazio, così le parole non saltano a capo mentre compaiono
+ return (
+
+ {text.slice(0, typed)}
+ {text.slice(typed)}
+
+ )
+}
+
+export function DialogueBox({ state, dispatch }: { state: DialogueState; dispatch: Dispatch
}) {
+ const { open } = state
+ if (!open) return null
+
+ const text = stepText(open.step)
+ const choices = open.typed >= text.length ? stepChoices(open.step) : null
+
+ return (
+
+ {CORNERS.map((position) => (
+
+ ))}
+
+
+ {text}
+
+
+ *
+
+
+
+ {choices && (
+
+ {choices.map((choice, i) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/src/components/easter-egg/dialogue.ts b/src/components/easter-egg/dialogue.ts
new file mode 100644
index 0000000..a15c2f8
--- /dev/null
+++ b/src/components/easter-egg/dialogue.ts
@@ -0,0 +1,75 @@
+import { DIALOGUE, EPILOGUE } from "./constants"
+import type { DialogueStep } from "./types"
+
+type OpenDialogue = { step: DialogueStep; queue: DialogueStep[]; typed: number; selected: number }
+export type DialogueState = { open: OpenDialogue | null; finished: boolean }
+export type DialogueAction =
+ | { type: "start" }
+ | { type: "tick" }
+ | { type: "confirm" }
+ | { type: "skip" }
+ | { type: "move" }
+ | { type: "select"; option: number }
+ | { type: "choose"; option: number }
+
+export const initialDialogueState: DialogueState = { open: null, finished: false }
+
+export function stepText(step: DialogueStep): string {
+ return typeof step === "string" ? step : step.text
+}
+
+export function stepChoices(step: DialogueStep) {
+ return typeof step !== "string" && "choices" in step ? step.choices : null
+}
+
+export function stepEvent(step: DialogueStep) {
+ return typeof step !== "string" && "event" in step ? step.event : null
+}
+
+function openNext(queue: DialogueStep[]): OpenDialogue | null {
+ const [step, ...rest] = queue
+ return step === undefined ? null : { step, queue: rest, typed: 0, selected: 0 }
+}
+
+function isTyped(open: OpenDialogue): boolean {
+ return open.typed >= stepText(open.step).length
+}
+
+export function dialogueReducer(state: DialogueState, action: DialogueAction): DialogueState {
+ const { open } = state
+
+ if (!open) {
+ if (action.type === "start" && !state.finished) return { ...state, open: openNext(DIALOGUE) }
+ // a dialogo finito, interagire di nuovo mostra l'epilogo
+ if (action.type === "confirm" && state.finished) return { ...state, open: openNext(EPILOGUE) }
+ return state
+ }
+
+ const choices = isTyped(open) ? stepChoices(open.step) : null
+
+ switch (action.type) {
+ case "tick":
+ return isTyped(open) ? state : { ...state, open: { ...open, typed: open.typed + 1 } }
+ case "skip":
+ return { ...state, open: { ...open, typed: stepText(open.step).length } }
+ case "move":
+ if (!choices) return state
+ return { ...state, open: { ...open, selected: (open.selected + 1) % choices.length } }
+ case "select":
+ if (!choices) return state
+ return { ...state, open: { ...open, selected: action.option } }
+ case "choose": {
+ const choice = choices?.[action.option]
+ if (!choice) return state
+ return { ...state, open: openNext([...choice.reply, ...open.queue]) }
+ }
+ case "confirm": {
+ if (!isTyped(open)) return dialogueReducer(state, { type: "skip" })
+ if (choices) return dialogueReducer(state, { type: "choose", option: open.selected })
+ const next = openNext(open.queue)
+ return { open: next, finished: state.finished || !next }
+ }
+ case "start":
+ return state
+ }
+}
diff --git a/src/components/easter-egg/font.ts b/src/components/easter-egg/font.ts
new file mode 100644
index 0000000..8f4db4b
--- /dev/null
+++ b/src/components/easter-egg/font.ts
@@ -0,0 +1,7 @@
+import { Press_Start_2P } from "next/font/google"
+
+// stesse metriche del font dei dialoghi di Deltarune: glifi 8x8 a spaziatura fissa
+export const pixelFont = Press_Start_2P({
+ weight: "400",
+ subsets: ["latin"],
+})
diff --git a/src/components/easter-egg/pixel-sprite.tsx b/src/components/easter-egg/pixel-sprite.tsx
new file mode 100644
index 0000000..d2dc785
--- /dev/null
+++ b/src/components/easter-egg/pixel-sprite.tsx
@@ -0,0 +1,39 @@
+import type { CSSProperties, ReactElement } from "react"
+
+type PixelSpriteProps = {
+ rows: readonly string[]
+ palette: Record
+ className?: string
+ style?: CSSProperties
+}
+
+/** Disegna pixel art da una mappa di caratteri: ogni carattere è un pixel, colorato secondo `palette`. */
+export function PixelSprite({ rows, palette, className, style }: PixelSpriteProps) {
+ const width = rows[0]?.length ?? 0
+ const rects: ReactElement[] = []
+
+ rows.forEach((row, y) => {
+ let x = 0
+ while (x < row.length) {
+ const char = row.charAt(x)
+ let end = x + 1
+ while (end < row.length && row[end] === char) end++
+ const fill = palette[char]
+ // biome-ignore lint/suspicious/noArrayIndexKey: lo sprite è statico, le coordinate sono un id stabile
+ if (fill) rects.push()
+ x = end
+ }
+ })
+
+ return (
+
+ )
+}
diff --git a/src/components/easter-egg/room.tsx b/src/components/easter-egg/room.tsx
new file mode 100644
index 0000000..0ecf4a8
--- /dev/null
+++ b/src/components/easter-egg/room.tsx
@@ -0,0 +1,225 @@
+"use client"
+
+import Image from "next/image"
+import type { CSSProperties } from "react"
+import { useEffect, useReducer, useRef, useSyncExternalStore } from "react"
+import NotFound from "@/app/not-found"
+import {
+ COLORS,
+ DIALOGUE_START_DELAY,
+ EGG_SOUND_SRC,
+ EGG_SOUND_VOLUME,
+ MUSIC_SRC,
+ MUSIC_VOLUME,
+ PUNCTUATION_DELAY,
+ TEXT_SOUND_SRC,
+ TEXT_SOUND_VOLUME,
+ TILE,
+ TREE,
+ TYPE_DELAY,
+} from "./constants"
+import { dialogueReducer, initialDialogueState, stepEvent, stepText } from "./dialogue"
+import { DialogueBox } from "./dialogue-box"
+import { hasRoomTicket, markEggReceived, subscribeRoomTicket } from "./storage"
+
+// la stanza è larga ~8 tile e alta ~9.5: scala a multipli interi finché ci sta nello schermo
+const ROOM_PX = "max(1px, round(down, min(100vw / 170, 100svh / 190), 1px))"
+
+const tiles = (n: number) => `calc(${n * TILE} * var(--px))`
+const px = (n: number) => `calc(${n} * var(--px))`
+
+// pavimento in tile, rispetto all'angolo in alto a sinistra della parte larga della stanza
+const FLOOR = [
+ { x: 1, y: 0, w: 4, h: 1 },
+ { x: 0, y: 1, w: 6, h: 3 },
+ { x: 1, y: 4, w: 4, h: 1 },
+] as const
+
+const PUNCTUATION = new Set([".", ",", "?", "!"])
+// come nel gioco, spazi e punteggiatura non fanno suono
+const VOICED = /[\p{L}\p{N}]/u
+const CONFIRM_KEYS = new Set(["Enter", " ", "z", "Z"])
+const SKIP_KEYS = new Set(["x", "X", "Shift"])
+const MOVE_KEYS = new Set(["ArrowLeft", "ArrowRight", "a", "A", "d", "D"])
+
+export function ForgottenManRoom() {
+ // null durante l'SSR/idratazione, poi true solo se ci si arriva dal trigger in navigazione
+ const allowed = useSyncExternalStore(subscribeRoomTicket, hasRoomTicket, () => null)
+
+ if (allowed === null) return
+ if (!allowed) return
+ return
+}
+
+function Room() {
+ const [state, dispatch] = useReducer(dialogueReducer, initialDialogueState)
+ const { open } = state
+ const soundsRef = useRoomAudio()
+ const step = open?.step
+
+ useEffect(() => {
+ const timeout = setTimeout(() => dispatch({ type: "start" }), DIALOGUE_START_DELAY)
+ return () => clearTimeout(timeout)
+ }, [])
+
+ useEffect(() => {
+ if (!open) return
+ const text = stepText(open.step)
+ if (open.typed >= text.length) return
+ const delay = PUNCTUATION.has(text[open.typed - 1] ?? "") ? PUNCTUATION_DELAY : TYPE_DELAY
+ const timeout = setTimeout(() => {
+ if (VOICED.test(text.charAt(open.typed))) soundsRef.current?.text()
+ dispatch({ type: "tick" })
+ }, delay)
+ return () => clearTimeout(timeout)
+ }, [open, soundsRef])
+
+ useEffect(() => {
+ if (!step || stepEvent(step) !== "receive-egg") return
+ markEggReceived()
+ soundsRef.current?.egg()
+ }, [step, soundsRef])
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.repeat) return
+ if (CONFIRM_KEYS.has(event.key)) dispatch({ type: "confirm" })
+ else if (SKIP_KEYS.has(event.key)) dispatch({ type: "skip" })
+ else if (MOVE_KEYS.has(event.key)) dispatch({ type: "move" })
+ else return
+ event.preventDefault()
+ }
+ window.addEventListener("keydown", onKeyDown)
+ return () => window.removeEventListener("keydown", onKeyDown)
+ }, [])
+
+ useEffect(() => {
+ const { overflow } = document.body.style
+ document.body.style.overflow = "hidden"
+ return () => {
+ document.body.style.overflow = overflow
+ }
+ }, [])
+
+ return (
+ // biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: la tastiera è gestita a livello di window
+ dispatch({ type: "confirm" })}
+ >
+
+ {FLOOR.map(({ x, y, w, h }) => (
+
+ ))}
+ {/* il sentiero prosegue fino al fondo dello schermo */}
+
+
+
+
+
+
+ )
+}
+
+async function loadBuffer(context: AudioContext, sources: string[], signal: AbortSignal) {
+ // non tutti i browser decodificano ogg (es. Safari meno recente): in quel caso si prova la sorgente successiva
+ for (const src of sources) {
+ try {
+ const response = await fetch(src, { signal })
+ if (!response.ok) continue
+ return await context.decodeAudioData(await response.arrayBuffer())
+ } catch {
+ if (signal.aborted) return null
+ }
+ }
+ return null
+}
+
+type RoomSounds = { text: () => void; egg: () => void }
+
+/**
+ * Musica in loop senza stacchi ed effetti sonori, tutti via Web Audio: partono appena il browser lo consente (al più
+ * al primo input). Restituisce un ref alle funzioni che riproducono gli effetti.
+ */
+function useRoomAudio() {
+ const soundsRef = useRef(null)
+
+ useEffect(() => {
+ const context = new AudioContext()
+ const controller = new AbortController()
+
+ const output = (volume: number) => {
+ const gain = context.createGain()
+ gain.gain.value = volume
+ gain.connect(context.destination)
+ return gain
+ }
+
+ const loadMusic = async () => {
+ const buffer = await loadBuffer(context, [MUSIC_SRC.ogg, MUSIC_SRC.mp3], controller.signal)
+ if (!buffer || controller.signal.aborted) return
+ const source = context.createBufferSource()
+ source.buffer = buffer
+ source.loop = true
+ source.connect(output(MUSIC_VOLUME))
+ source.start()
+ }
+ void loadMusic()
+
+ // ogni riproduzione interrompe la precedente dello stesso effetto invece di sovrapporsi
+ const soundEffect = (src: string, volume: number) => {
+ const gain = output(volume)
+ let buffer: AudioBuffer | null = null
+ let voice: AudioBufferSourceNode | null = null
+ void loadBuffer(context, [src], controller.signal).then((loaded) => {
+ buffer = loaded
+ })
+ return () => {
+ if (!buffer || context.state !== "running") return
+ voice?.stop()
+ voice = context.createBufferSource()
+ voice.buffer = buffer
+ voice.connect(gain)
+ voice.start()
+ }
+ }
+ soundsRef.current = {
+ text: soundEffect(TEXT_SOUND_SRC, TEXT_SOUND_VOLUME),
+ egg: soundEffect(EGG_SOUND_SRC, EGG_SOUND_VOLUME),
+ }
+
+ const resume = () => void context.resume()
+ window.addEventListener("pointerdown", resume)
+ window.addEventListener("keydown", resume)
+ return () => {
+ soundsRef.current = null
+ controller.abort()
+ window.removeEventListener("pointerdown", resume)
+ window.removeEventListener("keydown", resume)
+ void context.close()
+ }
+ }, [])
+
+ return soundsRef
+}
diff --git a/src/components/easter-egg/sprites.ts b/src/components/easter-egg/sprites.ts
new file mode 100644
index 0000000..2c6d8cd
--- /dev/null
+++ b/src/components/easter-egg/sprites.ts
@@ -0,0 +1,38 @@
+import { COLORS } from "./constants"
+
+// angolo in alto a sinistra del box di dialogo, ricalcato pixel per pixel; il bordo parte dall'offset 4
+export const CORNER_OFFSET = 4
+export const CORNER_SIZE = 13
+export const CORNER_ROWS = [
+ "........#....",
+ "........#o...",
+ "........#oo..",
+ ".....#####o..",
+ "......##ooo##",
+ "...#....#oo##",
+ "...##.t.#oooo",
+ "...##...o.o.o",
+ "####o##oooo#.",
+ ".oo#ooo..o...",
+ "..ooooooo....",
+ "....##o.#....",
+ "....##oo.....",
+] as const
+export const CORNER_PALETTE = {
+ ".": "#000000",
+ "#": COLORS.border,
+ o: COLORS.borderInner,
+ t: COLORS.sparkle,
+}
+
+export const SOUL_ROWS = [
+ ".##...##.",
+ "####.####",
+ "#########",
+ "#########",
+ ".#######.",
+ "..#####..",
+ "...###...",
+ "....#....",
+] as const
+export const SOUL_PALETTE = { "#": COLORS.soul }
diff --git a/src/components/easter-egg/storage.ts b/src/components/easter-egg/storage.ts
new file mode 100644
index 0000000..2fd0b05
--- /dev/null
+++ b/src/components/easter-egg/storage.ts
@@ -0,0 +1,48 @@
+import { OBTAINED_STORAGE_KEY, RECEIVED_STORAGE_KEY, TRIGGER_CHANCE } from "./constants"
+
+// vive solo in memoria: un refresh o un accesso diretto all'URL non lo hanno, quindi la stanza resta irraggiungibile
+let hasTicket: boolean = false
+const listeners = new Set<() => void>()
+
+function setTicket(value: boolean) {
+ hasTicket = value
+ for (const listener of listeners) listener()
+}
+
+export function hasRoomTicket(): boolean {
+ return hasTicket
+}
+
+export function subscribeRoomTicket(listener: () => void) {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+/** Una volta usciti dalla stanza non ci si torna, nemmeno con avanti/indietro del browser. */
+export function revokeRoomTicket() {
+ if (hasTicket) setTicket(false)
+}
+
+/** Tira il dado: se esce, segna l'uovo come ottenuto per sempre e concede l'accesso alla stanza. */
+export function rollForgottenMan(): boolean {
+ if (Math.random() >= TRIGGER_CHANCE) return false
+ try {
+ if (localStorage.getItem(OBTAINED_STORAGE_KEY) !== null) return false
+ localStorage.setItem(OBTAINED_STORAGE_KEY, new Date().toISOString())
+ } catch {
+ // senza storage non possiamo garantire che succeda una volta sola
+ return false
+ }
+ setTicket(true)
+ return true
+}
+
+export function markEggReceived() {
+ try {
+ localStorage.setItem(RECEIVED_STORAGE_KEY, new Date().toISOString())
+ } catch {
+ // senza storage l'uovo resta solo un ricordo
+ }
+}
diff --git a/src/components/easter-egg/trigger.tsx b/src/components/easter-egg/trigger.tsx
new file mode 100644
index 0000000..50f91f4
--- /dev/null
+++ b/src/components/easter-egg/trigger.tsx
@@ -0,0 +1,27 @@
+"use client"
+
+import { usePathname, useRouter } from "next/navigation"
+import { useEffect, useRef } from "react"
+import { FORGOTTEN_MAN_PATH, getCategory } from "./constants"
+import { revokeRoomTicket, rollForgottenMan } from "./storage"
+import type { NavCategory } from "./types"
+
+/** Ad ogni passaggio tra home e gruppi c'è una piccola probabilità di finire nella stanza del Forgotten Man. */
+export function ForgottenManTrigger() {
+ const pathname = usePathname()
+ const router = useRouter()
+ const lastCategoryRef = useRef(null)
+
+ useEffect(() => {
+ if (pathname !== FORGOTTEN_MAN_PATH) revokeRoomTicket()
+
+ const category = getCategory(pathname)
+ const lastCategory = lastCategoryRef.current
+ lastCategoryRef.current = category === "other" ? null : category
+
+ if (category === "other" || !lastCategory || lastCategory === category) return
+ if (rollForgottenMan()) router.push(FORGOTTEN_MAN_PATH)
+ }, [pathname, router])
+
+ return null
+}
diff --git a/src/components/easter-egg/types.ts b/src/components/easter-egg/types.ts
new file mode 100644
index 0000000..160b20d
--- /dev/null
+++ b/src/components/easter-egg/types.ts
@@ -0,0 +1,9 @@
+export type NavCategory = "home" | "groups" | "other"
+
+export type DialogueChoice = { label: string; reply: DialogueStep[] }
+export type DialogueEvent = "receive-egg"
+export type DialogueStep =
+ | string
+ | { text: string; choices: [DialogueChoice, DialogueChoice] }
+ // l'evento scatta quando la riga compare
+ | { text: string; event: DialogueEvent }