<!-- PART OF: ARCHITECTURE.md — The 20-Minute Hero Complete Architecture -->
<!-- DOCUMENT: 05-config-social-avatar.md -->
<!-- CONTENTS: Configuration, Design Decisions, Social & Tokens, Universe & Avatar System, Wallet -->
<!-- SPLIT: lines 5581–7037 of original file -->

## 9. Configuration & Environment

### Zasada podziału

```
┌─────────────────────────────────────────────────────────────┐
│  .env  (infrastruktura — wymaga restartu)                    │
│  ✓ Connection strings, API keys, sekrety JWT                 │
│  ✗ NIE: nic gameplay, nic AI params, nic rate limits         │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  game_config w DB + Redis TTL 5min  (hot-reload)             │
│  ✓ Wszystkie wartości gameplay (XP, timery, limity)          │
│  ✓ Rate limity                                               │
│  ✓ Upload limits                                             │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│  prompt_templates w DB + Redis TTL 5min  (hot-reload)        │
│  ✓ System prompts i user templates (Handlebars/Mustache)     │
│  ✓ Parametry modelu: temperature, max_tokens, model name     │
└─────────────────────────────────────────────────────────────┘
```

### Config — tylko infrastruktura

```typescript
// packages/api/src/config.ts
import { z } from 'zod'
import 'dotenv/config'

const envSchema = z.object({
  // Database
  DATABASE_URL: z.string().url(), // postgresql://user:pass@host/20hero

  // Auth — sekrety, nie parametry gameplay
  JWT_SECRET: z.string().min(64), // min 64 losowych znaków (openssl rand -hex 32)
  ACCESS_TOKEN_EXPIRE_MINUTES: z.coerce.number().default(15), // krótki czas — rotacja refresh tokena
  REFRESH_TOKEN_EXPIRE_DAYS: z.coerce.number().default(30),

  // Firebase
  FIREBASE_CREDENTIALS_PATH: z.string().default('/run/secrets/firebase.json'),

  // CORS
  ALLOWED_ORIGINS: z.string().default('https://app.20hero.pl'), // lista oddzielona przecinkami

  // AI — tylko klucz API; model i parametry są w prompt_templates
  GEMINI_API_KEY: z.string(),

  // External APIs
  NOMINATIM_USER_AGENT: z.string().default('20hero/1.0'),
  // Open-Meteo nie wymaga klucza API (https://open-meteo.com)

  // Storage
  AWS_ACCESS_KEY_ID: z.string(),
  AWS_SECRET_ACCESS_KEY: z.string(),
  AWS_REGION: z.string().default('eu-central-1'),
  S3_BUCKET_RAW: z.string().default('hero-proofs-raw'),
  S3_BUCKET_PUBLIC: z.string().default('hero-proofs-public'),
  CDN_BASE_URL: z.string(), // https://cdn.20hero.app

  // Redis
  REDIS_URL: z.string().default('redis://localhost:6379'),
  CONFIG_CACHE_TTL: z.coerce.number().default(300), // sekundy — jak szybko propagują się zmiany config

  // DB connection pool (pg / postgres.js)
  DB_POOL_SIZE: z.coerce.number().default(20),         // connections per worker process
  DB_MAX_OVERFLOW: z.coerce.number().default(10),      // extra connections under burst (total: 30)
  DB_POOL_RECYCLE_SEC: z.coerce.number().default(3600), // recycle connections after 1h

  // Sentry
  SENTRY_DSN: z.string().default(''), // empty = disabled
  ENVIRONMENT: z.enum(['development', 'staging', 'production']).default('production'),
  APP_VERSION: z.string().default('0.1.0'),

  PORT: z.coerce.number().default(3000),
})

export type Config = z.infer<typeof envSchema>
export const config = envSchema.parse(process.env)
```

### configService — jeden punkt dostępu do game_config i prompt_templates

```typescript
// packages/api/src/services/config-service.ts
import { redis } from '@20hero/db/redis'
import { db } from '@20hero/db'
import { gameConfig, promptTemplates } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import { config } from '../config'

export class ConfigNotFoundError extends Error {}

export async function getGameConfig(key: string): Promise<Record<string, unknown>> {
  // Pobierz game_config[key] z cache lub DB
  const cached = await redis.get(`cfg:${key}`)
  if (cached) return JSON.parse(cached)

  const [row] = await db
    .select()
    .from(gameConfig)
    .where(eq(gameConfig.key, key))
    .limit(1)

  if (!row) throw new ConfigNotFoundError(`game_config key '${key}' not found`)

  const data = row.value as Record<string, unknown>
  await redis.setex(`cfg:${key}`, config.CONFIG_CACHE_TTL, JSON.stringify(data))
  return data
}

export async function getPrompt(key: string): Promise<PromptTemplate> {
  // Pobierz prompt_templates[key] z cache lub DB
  const cached = await redis.get(`prompt:${key}`)
  if (cached) return JSON.parse(cached) as PromptTemplate

  const [row] = await db
    .select()
    .from(promptTemplates)
    .where(eq(promptTemplates.key, key))
    .limit(1)

  if (!row) throw new ConfigNotFoundError(`prompt_template key '${key}' not found`)

  const tmpl = row as unknown as PromptTemplate
  await redis.setex(`prompt:${key}`, config.CONFIG_CACHE_TTL, JSON.stringify(tmpl))
  return tmpl
}

export async function setGameConfig(
  key: string,
  value: Record<string, unknown>,
  adminId: string,
): Promise<void> {
  // Zaktualizuj game_config i natychmiast zinwaliduj cache
  await db
    .update(gameConfig)
    .set({ value, updatedAt: new Date(), updatedBy: adminId })
    .where(eq(gameConfig.key, key))
  await redis.del(`cfg:${key}`) // Natychmiast — nie czeka na TTL
}
```

### .env.example

```bash
# === INFRASTRUKTURA (jedyne co tu trafia) ===

DATABASE_URL=postgresql://hero:pass@localhost/20hero
REDIS_URL=redis://localhost:6379

# Auth — generuj: openssl rand -hex 32
JWT_SECRET=change-me-in-production-min-64-chars
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=30

# Firebase — ścieżka do service account JSON (pobierz z Firebase Console)
# Na serwerze: montuj jako Docker secret (patrz Sekcja 28 — Deployment Strategy)
FIREBASE_CREDENTIALS_PATH=/run/secrets/firebase.json

# CORS — lista domen oddzielona przecinkami
ALLOWED_ORIGINS=https://app.20hero.pl

GEMINI_API_KEY=
NOMINATIM_USER_AGENT=20hero/1.0
# Pogoda: Open-Meteo (https://open-meteo.com) — brak klucza API, darmowy do 10k req/dzień

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=eu-central-1
S3_BUCKET_RAW=hero-proofs-raw
S3_BUCKET_PUBLIC=hero-proofs-public
CDN_BASE_URL=https://cdn.20hero.app

CONFIG_CACHE_TTL=300

# === NIE DODAWAJ TU: XP, rate limits, timery, modele AI ===
# === To wszystko jest w game_config / prompt_templates w DB ===
```

---

## 10. Key Design Decisions & Rationale

**Auth: Firebase-only (Google + Apple Sign-In) od dnia 1:** Brak email/password — eliminuje klasy ataków (brute-force haseł, credential stuffing, reset-password flows) i upraszcza onboarding. Kolumny `firebase_uid` + `auth_provider` w tabeli `users`. Backend weryfikuje Firebase ID token offline (JWKS cache) przy każdym requestcie — brak własnych access/refresh tokenów. Firebase SDK auto-odnawia token po stronie mobilnej. Szczegóły: Sekcja 14.

**Upload: presigned URL — telefon uploaduje bezpośrednio do S3:** Backend zwraca tylko podpisany URL (1 mały request), plik 100MB idzie wprost z telefonu do S3. Serwer nie jest wąskim gardłem przy wielu równoległych uploadach. EXIF wyciągany z pierwszych 64KB — nie ściągamy całego pliku.

**Quest dedup: hash typu questa, nie quest_id:** Hashujemy `(quest_tags_sorted, location_type, geohash_4char)` zamiast pełnego ID questa. Dzięki temu unikamy powtórzenia KATEGORII questa (np. "shadow photography w parku") nawet jeśli tekst był inny. Hash przekazywany do AI jako "avoid these patterns" — AI sama dobiera urozmaicenie.

**Zero hardcoded gameplay settings:** Każda wartość gameplay (XP, timery, rate limity, progi AI, rozkład difficultly, parametry modeli, prompty) żyje w `game_config` lub `prompt_templates` w DB, z 5-minutowym cache w Redis. Zmiana balansu gry = jeden SQL update, bez deploymentu, bez restartu. Reguła: jeśli wartość może się zmienić po starcie produkcji — trafia do DB, nie do kodu. Jedyne wyjątki: connection strings i sekrety (`.env`). Nauka z poprzednich projektów.

**PostgreSQL over SQLite:** The app has concurrent writes (votes, XP events), spatial queries (geohash-based nearby quests), and multiple async workers — PostgreSQL handles all of this cleanly. PostGIS extension adds optional native GPS radius queries.

**Geohash for location bucketing:** Rather than full PostGIS sphere queries on every feed request, geohash prefix matching (6-char = ~1.2km squares) allows efficient indexing for "nearby completions" feature without spatial extension complexity.

**AI confidence thresholds:** Two-tier system — high confidence (≥0.85 pass) routes to community voting for XP bonus, very high confidence (≥0.95) auto-completes. This prevents the AI from being the sole arbiter while keeping voting load manageable.

**XP Ledger pattern:** Immutable append-only ledger rather than mutable counter. Provides full audit trail, enables rollback for disputed proofs, and makes streak/leaderboard recalculations trivial.

**Quest generation cooldown (3/hour):** Prevents AI cost abuse while allowing re-generation if user dislikes the quest. Rate limit is per-character, tracked in Redis.

**Separate S3 buckets (raw vs public):** Raw uploads are private — only moved to public CDN after AI verification passes. Prevents unverified content from reaching the public feed.

**Onboarding as stateful conversation in DB:** Full message history stored in `onboarding_sessions.messages` (JSONB). Allows resuming interrupted onboarding sessions and replaying for debugging.

**Monetization: 3-layer model (F2P core + IAP + B2B) — bez Stripe/KYC przed Phase 2:**

Gra jest free-to-play z opcjonalnymi zakupami. Przychód przed blockchain Phase 2 pochodzi z 3 warstw:

| Warstwa | Produkt | Cena | Cut platformy |
|---------|---------|------|---------------|
| IAP 1 | Hero Pass (season pass) | $3.99/season | 30% Apple/Google |
| IAP 2 | HERO Token Pack Small | $1.99 → ~100 HERO | 30% Apple/Google |
| IAP 2 | HERO Token Pack Medium | $4.99 → ~275 HERO | 30% Apple/Google |
| IAP 2 | HERO Token Pack Large | $9.99 → ~600 HERO | 30% Apple/Google |
| B2B | Sponsored Quest Campaign | €200–500/kampanię | 0% (direct deal) |

```typescript
// game_config.iap_products (DB, nie hardcode)
iap_products: {
  hero_pass_season: {
    apple_product_id: 'com.20hero.heropass.season',
    google_product_id: 'heropass_season',
    price_usd: 3.99,
    hero_tokens_granted: 0,          // Pass daje reward track, nie tokeny
    season_pass_tier: 'paid',
  },
  hero_tokens_small: {
    apple_product_id: 'com.20hero.tokens.100',
    google_product_id: 'tokens_100',
    price_usd: 1.99,
    hero_tokens_granted: 100,
  },
  hero_tokens_medium: {
    apple_product_id: 'com.20hero.tokens.275',
    google_product_id: 'tokens_275',
    price_usd: 4.99,
    hero_tokens_granted: 275,
  },
  hero_tokens_large: {
    apple_product_id: 'com.20hero.tokens.600',
    google_product_id: 'tokens_600',
    price_usd: 9.99,
    hero_tokens_granted: 600,
  },
}
```

**IAP backend — receipt validation:**

```typescript
// packages/api/src/routes/iap.ts
// POST /v1/iap/validate
interface IAPValidateRequest {
  platform: 'apple' | 'google'
  receipt: string          // Apple: receipt-data; Google: purchaseToken
  product_id: string
}
// Po weryfikacji z Apple/Google serwer → books HERO tokeny lub aktywuje season pass
// Idempotentne: iap_purchases.purchase_token UNIQUE — double-tap safe

// DDL (Migration 003):
// CREATE TABLE iap_purchases (
//   id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
//   user_id UUID NOT NULL REFERENCES users(id),
//   platform TEXT NOT NULL CHECK (platform IN ('apple','google')),
//   product_id TEXT NOT NULL,
//   purchase_token TEXT NOT NULL UNIQUE,   -- idempotency key
//   amount_usd NUMERIC(10,2) NOT NULL,
//   hero_tokens_granted INT NOT NULL DEFAULT 0,
//   validated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
//   raw_receipt JSONB                      -- przechowuj dla audytu
// );
// CREATE INDEX ON iap_purchases(user_id);
//
// CREATE TABLE hero_pass_subscriptions (
//   id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
//   user_id UUID NOT NULL REFERENCES users(id),
//   season_id UUID NOT NULL REFERENCES seasons(id),
//   iap_purchase_id UUID REFERENCES iap_purchases(id),
//   tier TEXT NOT NULL CHECK (tier IN ('free','paid')),
//   activated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
//   UNIQUE(user_id, season_id)
// );
```

**Sponsored Quests B2B (bez Apple/Google tax):**

```typescript
// Bezpośrednia umowa B2B — płatność poza app store (invoice/wire transfer)
// Quest ma flagę is_sponsored=true — wyświetlany w dedykowanej sekcji "Wyzwania Partnera"
// Sponsor nie ma dostępu do PII; widzi tylko agregaty (completions, region, wiek bucket)

// DDL:
// CREATE TABLE sponsors (
//   id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
//   name TEXT NOT NULL,
//   logo_url TEXT,
//   campaign_budget_eur NUMERIC(10,2),
//   campaign_start_at TIMESTAMPTZ,
//   campaign_end_at TIMESTAMPTZ,
//   created_at TIMESTAMPTZ DEFAULT now()
// );
// ALTER TABLE quests ADD COLUMN sponsor_id UUID REFERENCES sponsors(id);
// ALTER TABLE quests ADD COLUMN is_sponsored BOOLEAN NOT NULL DEFAULT FALSE;

// API: POST /v1/admin/sponsors — tylko rola admin
// Sponsored quest → normalny quest flow (AI generuje, liveness check itp.) + sponsor branding
```

**Apple IAP compliance:** Wszystkie "digital goods" (tokeny, pass) MUSZĄ przechodzić przez Apple IAP (30% cut). B2B sponsored quests to umowy z firmami — nie sprzedajemy nic bezpośrednio użytkownikowi przez app, więc bez Apple tax.

**Projekcja przychodów (orientacyjna, 10k MAU):**
- 5% kupuje Hero Pass ($3.99): 500 × $2.79 netto ≈ $1,400/mies.
- 3% kupuje token packs (avg $5): 300 × $3.50 netto ≈ $1,050/mies.
- 2 kampanie B2B/mies. × €350 ≈ $750/mies.
- **Razem ~$3,200/mies. przy 10k MAU** — próg breakeven ~15k MAU przy typowych kosztach infra.

---

---

## 11. Social & Token Architecture

### 11.1 Feed — 3 tryby

```typescript
// packages/api/src/services/feed-service.ts
import { db } from '@20hero/db'
import { sql } from 'drizzle-orm'
import { getGameConfig } from './config-service'
import { encodeGeohash } from '@20hero/utils/geohash'

type FeedMode = 'chronological' | 'trending' | 'following' | 'nearby'

interface FeedPage {
  items: FeedItem[]
  nextCursor: string | null
}

export async function getFeed(
  userId: string,
  mode: FeedMode,
  cursor: string | null,       // Cursor-based pagination (nie offset)
  lat: number | null,
  lng: number | null,
  limit: number = 20,
): Promise<FeedPage> {
  const feedCfg = await getGameConfig('feed_algorithm')

  switch (mode) {
    case 'chronological': {
      const rows = await db.execute(sql`
        SELECT c.*, u.username, u.avatar_url,
               COUNT(r.id) FILTER (WHERE r.reaction_type='upvote') AS upvotes,
               COUNT(cm.id) AS comments_count
        FROM proofs c
        JOIN users u ON c.user_id = u.id
        LEFT JOIN reactions r ON r.proof_id = c.id
        LEFT JOIN comments cm ON cm.proof_id = c.id AND cm.is_deleted = FALSE
        WHERE c.visibility = 'public'
          AND (${cursor} IS NULL OR c.created_at < ${cursor}::timestamptz)
        GROUP BY c.id, u.username, u.avatar_url
        ORDER BY c.created_at DESC
        LIMIT ${limit}
      `)
      return buildPage(rows.rows, limit)
    }

    case 'trending': {
      // score = (upvotes * w_upvote + comments * w_comment) / (age_hours + 2)^1.5
      const wUp = (feedCfg as any).upvote_weight
      const wCm = (feedCfg as any).comment_weight
      const rows = await db.execute(sql`
        SELECT c.*,
          (upvotes * ${wUp} + comments_count * ${wCm})
          / POWER(EXTRACT(EPOCH FROM (NOW()-c.created_at))/3600 + 2, 1.5)
          AS trending_score
        FROM ... ORDER BY trending_score DESC LIMIT ${limit}
      `)
      return buildPage(rows.rows, limit)
    }

    case 'following': {
      const rows = await db.execute(sql`
        SELECT c.* FROM proofs c
        WHERE c.user_id IN (
            SELECT following_id FROM follows WHERE follower_id = ${userId}
        ) AND c.visibility = 'public'
        ORDER BY c.created_at DESC LIMIT ${limit}
      `)
      return buildPage(rows.rows, limit)
    }

    case 'nearby': {
      // Geohash prefix match — bez PostGIS
      const geohashPrefix = encodeGeohash(lat!, lng!, 4) // ~40km
      const rows = await db.execute(sql`
        SELECT c.* FROM proofs c
        JOIN quests q ON c.quest_id = q.id
        WHERE q.geohash LIKE ${geohashPrefix + '%'}
          AND c.visibility = 'public'
        ORDER BY c.created_at DESC LIMIT ${limit}
      `)
      return buildPage(rows.rows, limit)
    }
  }
}
```

### 11.2 Token Reward Flow — per quest completion

```
Quest verified (AI pass lub community vote pass)
  ↓
tokenService.awardQuestTokens(completionId)
  ↓
  1. Pobierz token_rewards z game_config
  2. base_tokens = config[quest.difficulty]
  3. Zastosuj bonusy (class_match, streak, first_quest_daily)
  4. Sprawdź daily_cap (token_ledger.sum_today)
  5. Zapisz do token_ledger (immutable)
  6. UPDATE token_balances SET balance += earned
  ↓
Odpowiedź do klienta: { xp_earned: 175, tokens_earned: 5.0, new_balance: 47.5 }
```

```typescript
// packages/api/src/services/token-service.ts
import { db } from '@20hero/db'
import { tokenBalances, tokenLedger } from '@20hero/db/schema'
import { getGameConfig } from './config-service'
import type { QuestCompletion, Quest, Character } from '@20hero/types'

export interface TokenAwardResult {
  earned: number
  newBalance?: number
  reason?: string
}

export async function awardQuestTokens(
  completion: QuestCompletion,
  quest: Quest,
  character: Character,
): Promise<TokenAwardResult> {
  const cfg = await getGameConfig('token_rewards') as any
  const base: number = cfg.per_difficulty[quest.difficulty]

  // Bonusy — ta sama logika co XP; classMatches obliczana dynamicznie (brak kolumny w DB)
  const classMatches = Boolean(quest.questTags && quest.questTags.includes(character.classKey))
  const bonus = classMatches ? base * cfg.class_match_bonus : 0
  const streak = await getCurrentStreak(completion.userId)
  const mult = resolveStreakMultiplier(streak, cfg.streak_bonus)
  let total = (base + bonus) * mult

  if (await isFirstQuestToday(completion.userId)) {
    total += cfg.first_quest_daily
  }

  // Daily cap
  const earnedToday = await sumTokensToday(completion.userId)
  total = Math.min(total, cfg.daily_cap - earnedToday)
  if (total <= 0) {
    return { earned: 0, reason: 'daily_cap_reached' }
  }

  const result = await db.transaction(async (tx) => {
    const newBalance = await incrementTokenBalance(tx, completion.userId, total)
    await tx.insert(tokenLedger).values({
      userId:        completion.userId,
      eventType:     'quest_reward',
      amount:        total,
      balanceAfter:  newBalance,
      completionId:  completion.id,
      metadata:      { base, classBonus: bonus, streakMult: mult },
    })
    return newBalance
  })

  return { earned: total, newBalance: result }
}
```

### 11.3 Token Withdrawal — 2 fazy (skip fiat)

```
Faza 1 (teraz):    Wirtualne HERO tokeny w DB
                   token_withdrawal.enabled = false w game_config
                   UI: saldo widoczne, przycisk "Wypłać" = "Wkrótce"
                   Brak Stripe, brak KYC — tylko accumulation

Faza 2 (blockchain): Smart contract ERC-20 na Polygon (lub TBD)
                     token_withdrawals tabela wypełniona tx_hash
                     Worker monitoruje confirmations
                     min_withdrawal = 10.0 HERO
```

Decyzja: **pomijamy Fazę fiat (Stripe payouts)** — brak sensu ekonomicznego, dodatkowy KYC,
regulacje MSB (Money Services Business). Bezpośredni skok do blockchain eliminuje całą tę
warstwę. Wybór blockchain (Polygon wiodący kandydat dla gier: niskie koszty, EVM-compatible,
szybkie transakcje) — do decyzji przed Fazą 2, nie blokuje MVP.

Przełączenie Fazy 1→2: zmiana `token_withdrawal.enabled` i `supported_chains` w `game_config`. Zero zmian w kodzie.

> **API Schemas (Faza 2):** `TokenWithdrawalRequest { amount: number, destination: string, chain: 'polygon' }` → `TokenWithdrawalResponse { withdrawal_id: string, status: 'pending', estimated_time: string }`. Endpoint zwraca 503 gdy `token_withdrawal.enabled=false`. Pełna implementacja dopiero w Fazie 2 — nie implementować w MVP.

### 11.4 Anti-farming

Tokeny są możliwe do zdobycia tylko przy:
- AI verification score ≥ `verification_thresholds.community_queue` (nie daje się skamu)
- EXIF timestamp w oknie `quest_started_at ± grace_period`
- `daily_cap` w `game_config` (twarda granica)
- Rate limit questów (3/godzinę z `game_config.rate_limits`)

### 11.5 Nowe API Endpoints

```
# Social Feed
GET  /api/feed?mode=chronological|trending|following|nearby&cursor=...
GET  /api/users/{userId}/completions       → publiczne questy użytkownika

# Reactions
POST /api/completions/{id}/reactions        → { reaction_type: "upvote" }
DELETE /api/completions/{id}/reactions/{type}

# Comments
GET  /api/completions/{id}/comments?cursor=...
POST /api/completions/{id}/comments         → { content: "..." }
DELETE /api/comments/{id}                   → soft delete (własny komentarz)

# Follow
POST   /api/users/{id}/follow
DELETE /api/users/{id}/follow
GET    /api/users/{id}/followers?cursor=...
GET    /api/users/{id}/following?cursor=...

# Leaderboards
GET /api/leaderboards?period=weekly|monthly|alltime&scope=global|city|group&scope_id=...

# Token / Wallet
GET    /api/wallet/balance                  → { balance, total_earned, symbol }
GET    /api/wallet/history?cursor=...       → token_ledger per user
POST   /api/wallet/address                  → { chain, address }
POST   /api/wallet/withdraw                 → { amount, chain } → 503 jeśli disabled
GET    /api/users/{id}/tips                 → napiwki od/do użytkownika
POST   /api/completions/{id}/tip            → { amount } → 503 jeśli disabled
```

---

### 11.6 Guilds — Mid-Term Retention (Day 14–60)

> **Cel retencji**: Guilds to "social anchor" — gracz który należy do gildii ma 2-3× wyższy
> Day-30 retention niż gracz solo. Gildyjne wyzwania tworzą synchronizację aktywności
> (wszyscy grają w tym samym tygodniu) i presję społeczną (nie chcę zawieść gildii).

#### 11.6.1 Mechanika

```
Gildia:
  - Max 20 członków (config: guild.max_members)
  - Założenie: 500 HERO (jednorazowe — §token_shop.social.guild_creation)
  - Lider może mianować 2 oficerów; oficerowie akceptują wnioski
  - Gildia ma własny leaderboard tygodniowy (suma XP wszystkich członków)
  - Gildia ma widoczny "poziom gildii" (skumulowane XP wszystkich — osobna tabela)

Gildyjne Wyzwanie Tygodniowe (GWT):
  - Automatycznie generowane w poniedziałek (BullMQ cron)
  - 1 wspólny cel: np. "Ukończcie łącznie 30 questów tej gildii w tym tygodniu"
  - Postęp widoczny dla wszystkich członków w real-time (Redis counter)
  - Nagroda po ukończeniu: +20% XP dla każdego członka przez 24h
```

#### 11.6.2 DDL

```sql
CREATE TABLE guilds (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name          TEXT NOT NULL UNIQUE,
    description   TEXT,
    leader_id     UUID NOT NULL REFERENCES users(id),
    level         INT NOT NULL DEFAULT 1,
    total_xp      BIGINT NOT NULL DEFAULT 0,  -- suma XP wszystkich członków historycznie
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_public     BOOLEAN NOT NULL DEFAULT true  -- false = tylko z zaproszenia
);

CREATE TABLE guild_members (
    guild_id    UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role        TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('leader','officer','member')),
    joined_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (guild_id, user_id)
);

CREATE TABLE guild_weekly_challenges (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    guild_id        UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
    week_start      DATE NOT NULL,  -- Monday
    target_type     TEXT NOT NULL,  -- 'quest_count' | 'total_xp' | 'hard_quests' | 'unique_locations'
    target_value    INT NOT NULL,
    current_value   INT NOT NULL DEFAULT 0,  -- updated by Redis → synced periodically
    completed_at    TIMESTAMPTZ,
    reward_claimed  BOOLEAN NOT NULL DEFAULT false,
    UNIQUE (guild_id, week_start)
);

CREATE INDEX idx_guild_members_user ON guild_members(user_id);
CREATE INDEX idx_guild_weekly_week  ON guild_weekly_challenges(week_start) WHERE completed_at IS NULL;
```

#### 11.6.3 API

```
# Guilds
POST   /api/guilds                    → { name, description, isPublic } — wymaga 500 HERO
GET    /api/guilds?nearby&city=...    → lista publicznych gildii (szukaj po mieście)
GET    /api/guilds/{id}               → szczegóły + członkowie + aktualny GWT
POST   /api/guilds/{id}/join          → wniosek o dołączenie (lub auto-accept jeśli public)
DELETE /api/guilds/{id}/members/{uid} → wyrzuć członka (leader/officer only)
PATCH  /api/guilds/{id}/members/{uid} → zmień rolę (leader only)

# Guild challenge
GET    /api/guilds/{id}/challenge     → aktualny GWT + progress
POST   /api/guilds/{id}/challenge/claim → odbierz nagrodę po ukończeniu
```

#### 11.6.4 Game config

```python
"groups": {
    "guild_max_members":     20,
    "guild_max_per_user":     1,   # Gracz należy do max 1 gildii
    "guild_officer_count":    2,
    "guild_weekly_challenge": {
        "xp_bonus_duration_h": 24,
        "xp_bonus_pct":        0.20,  # +20% XP dla wszystkich po ukończeniu GWT
    },
    "guild_challenge_targets": {   # Losowany target w poniedziałek
        "quest_count":          {"min": 15, "max": 40},
        "hard_quests":          {"min":  5, "max": 15},
        "unique_locations":     {"min":  8, "max": 20},
    },
},
```

---

### 11.7 Seasons & Events — Long-Term Retention (Day 30–90)

> **Cel retencji**: Sezon to "calendar anchor" — gracz wie że za 3 tygodnie sezon się kończy
> i chce zdążyć z prestige reward. Brak sezonu = brak powodu żeby wrócić jutro.
> Cel: co najmniej 1 nowy sezon co 5-6 tygodni.

#### 11.7.1 Mechanika

```
Sezon:
  - Długość: 4-6 tygodni (konfigurowana w DB)
  - Motyw: Wiosna / Lato / Neon Carnival / Cyber Winter / itp.
  - Seasonal quests: 3-5 tematycznych questów per tydzień sezonu (premium flavor w promptach)
  - Seasonal leaderboard: niezależny od globalnego; reset po sezonie
  - Seasonal rewards: kosmetyki dla top N graczy + wszyscy z ≥ X questami sezonowymi

Season Pass:
  - Free tier: seasonal leaderboard + 1 darmowy cosmetic po 5 questach sezonowych
  - Paid tier (token_shop.seasonal.base = 200 HERO): dodatkowe reward track
    - 10 questów: exclusive title sezonowy
    - 20 questów: exclusive avatar frame
    - 30+ questów: prestige cosmetic (tylko dla posiadaczy season pass w tym sezonie)
```

#### 11.7.2 DDL

```sql
CREATE TABLE seasons (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        TEXT NOT NULL,                -- "Sezon 1: Neonowa Wiosna"
    theme       TEXT NOT NULL,                -- prompt flavor key → game_config
    start_date  DATE NOT NULL,
    end_date    DATE NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT false,
    pass_cost   INT NOT NULL DEFAULT 200,     -- HERO; odpowiada token_shop.seasonal.base
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE season_pass (
    user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    season_id    UUID NOT NULL REFERENCES seasons(id) ON DELETE CASCADE,
    tier         TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','paid')),
    quest_count  INT NOT NULL DEFAULT 0,   -- questy ukończone w sezonie
    purchased_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, season_id)
);

CREATE TABLE seasonal_items (
    id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    season_id UUID NOT NULL REFERENCES seasons(id) ON DELETE CASCADE,
    item_key  TEXT NOT NULL,    -- np. "frame_neon_spring_2026"
    tier      TEXT NOT NULL CHECK (tier IN ('base','prestige')),
    name      TEXT NOT NULL,
    type      TEXT NOT NULL     -- 'frame' | 'title' | 'avatar' | 'quest_theme'
);

CREATE INDEX idx_season_pass_season  ON season_pass(season_id);
CREATE INDEX idx_seasonal_items_seas ON seasonal_items(season_id);
```

#### 11.7.3 Seasonal quest flavor — integracja z AI

Sezonowy motyw wstrzykiwany jako dodatkowy kontekst do `quest_generation` promptu:

```python
# game_config.seasons.current_flavor (aktualizowany per sezon):
"season_flavor": {
    "theme_key": "neon_spring",
    "flavor_injection": "Sezon: Neonowa Wiosna. Priorytetuj questy tematyczne: "
                        "kwitnące drzewa z neonowym tłem, miejskie ogrody o zmroku, "
                        "kontrast natury i technologii. Nie wymagaj tego w każdym queście — "
                        "1-2 na 3 wygenerowane powinny mieć ten klimat.",
    "seasonal_quest_tags": ["neon_spring", "urban_nature", "contrast"],
}
```

#### 11.7.4 API

```
GET  /api/seasons/current             → aktywny sezon + twój postęp w season pass
GET  /api/seasons/current/leaderboard → seasonal leaderboard (top 100)
POST /api/seasons/current/pass        → kup season pass paid tier (odejmuje HERO)
POST /api/seasons/current/claim/{reward_key} → odbierz nagrodę (jeśli spełnione warunki)
GET  /api/seasons/history             → lista zakończonych sezonów + twoje wyniki
```

---

### 11.8 Weekly Challenges — Rytm Tygodniowy (Day 7–∞)

> **Cel retencji**: Tygodniowy reset daje "powód żeby wrócić w poniedziałek".
> Najprostszy mechanizm retencji — efektywny dla wszystkich segmentów graczy.

#### 11.8.1 Mechanika

```
Co poniedziałek 06:00 UTC — BullMQ cron generuje 3 nowe wyzwania na tydzień:
  - ŁATWE   (~1-2 dni aktywności):  "Ukończ 3 questy", "Zdobądź 500 XP"
  - ŚREDNIE (~3-4 dni):             "Ukończ 2 hard questy", "Odwiedź 4 różne dzielnice"
  - TRUDNE  (~5-7 dni):             "Zdobądź 2000 XP w tygodniu", "Ukończ 10 questów"

Nagrody:
  - ŁATWE:   +50 XP, +3 HERO
  - ŚREDNIE: +150 XP, +7 HERO
  - TRUDNE:  +300 XP, +12 HERO
  - Ukończenie wszystkich 3 w jednym tygodniu: +100 XP bonus ("Tygodniowy Bohater")
```

#### 11.8.2 DDL

```sql
CREATE TABLE weekly_challenges (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    week_start  DATE NOT NULL,
    difficulty  TEXT NOT NULL CHECK (difficulty IN ('easy','medium','hard')),
    type        TEXT NOT NULL,  -- 'quest_count' | 'xp_total' | 'hard_quests' | 'unique_neighborhoods' | 'streak_days'
    target      INT NOT NULL,
    xp_reward   INT NOT NULL,
    hero_reward INT NOT NULL,
    UNIQUE (week_start, difficulty)
);

CREATE TABLE weekly_challenge_progress (
    user_id       UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    challenge_id  UUID NOT NULL REFERENCES weekly_challenges(id) ON DELETE CASCADE,
    current       INT NOT NULL DEFAULT 0,
    completed_at  TIMESTAMPTZ,
    reward_claimed BOOLEAN NOT NULL DEFAULT false,
    PRIMARY KEY (user_id, challenge_id)
);

CREATE INDEX idx_wcp_user_week ON weekly_challenge_progress(user_id, challenge_id);
```

#### 11.8.3 Generowanie wyzwań (BullMQ cron)

```typescript
// apps/api/src/jobs/weekly-challenge-generator.ts
// Uruchamiany cron: '0 6 * * 1' (poniedziałek 06:00 UTC)

const CHALLENGE_POOL: Record<string, Array<{ type: string; range: [number, number] }>> = {
  easy: [
    { type: 'quest_count',        range: [3, 4]  },
    { type: 'xp_total',           range: [400, 600] },
    { type: 'streak_days',        range: [3, 3]  },
  ],
  medium: [
    { type: 'hard_quests',        range: [2, 3]  },
    { type: 'quest_count',        range: [6, 8]  },
    { type: 'unique_neighborhoods', range: [3, 5] },
  ],
  hard: [
    { type: 'xp_total',           range: [1500, 2500] },
    { type: 'quest_count',        range: [10, 12] },
    { type: 'hard_quests',        range: [5, 7]  },
  ],
}

export async function generateWeeklyChallenges(weekStart: Date): Promise<void> {
  for (const diff of ['easy', 'medium', 'hard'] as const) {
    const pool    = CHALLENGE_POOL[diff]
    const picked  = pool[Math.floor(Math.random() * pool.length)]
    const target  = randInt(picked.range[0], picked.range[1])
    const rewards = WEEKLY_REWARDS[diff]  // { xp, hero } z game_config

    await db.insert(weeklyChallenges).values({
      weekStart, difficulty: diff,
      type: picked.type, target,
      xpReward: rewards.xp, heroReward: rewards.hero,
    }).onConflictDoNothing()
  }
}
```

#### 11.8.4 API

```
GET  /api/challenges/weekly           → 3 aktualne wyzwania + twój postęp
POST /api/challenges/weekly/{id}/claim → odbierz nagrodę (jeśli completed)
```

---

### 11.9 Retention — Okna czasowe i mechanizmy

| Okno | Mechanizm | Cel |
|------|-----------|-----|
| Day 1 | First quest easy override (§31.3) | Success rate ≥ 70% |
| Day 1–7 | Streak (każdy dzień), push notifications | Nawyk tygodniowy |
| Day 3 | Push: "Twoja passa zaczyna rosnąć" | Re-engagement |
| Day 7 | Weekly challenge reset, streak milestone (5 HERO) | Return on Monday |
| Day 14 | Guild discovery push (jeśli nie w gildii) | Social anchor |
| Day 30 | Season pass mid-point push, streak milestone (20 HERO) | FOMO + reward |
| Day 45 | Season finale leaderboard push (top N) | Competition spike |
| Day 60+ | New season start, guild weekly challenge, narrative chapter unlock | Sustained loop |

**Push notification cadence (konfigurowana w `push_notifications.retention_schedule`):**

```python
"retention_push": {
    "day3_nudge":          true,   # "Twoja passa rośnie — jeden quest dziś?"
    "day7_streak":         true,   # "7 dni! Twoja tarcza passy aktywna"
    "weekly_reset_monday": true,   # "Nowe wyzwania tygodniowe — sprawdź!"
    "guild_challenge_mid": true,   # Środa jeśli GWT < 50% postępu
    "season_ending_3days": true,   # 3 dni przed końcem sezonu
    "inactivity_3days":    true,   # Brak aktywności 3 dni → "Aldric czeka"
    "inactivity_7days":    false,  # Wyłączone — zbyt agresywne (opt-in only)
},
```

---

## 12. Universe & Avatar System

### 12.1 Uniwersum: "Neon & Mana" (Cyber-Fantasy)

Świat gdzie starożytna magia zlała się z technologią w katastrofie zwanej **Wielką Konwergencją**.
Zaklęcia to kod. Runy świecą neonem. Cyfermancerzy hakują rzeczywistość. Korporacje zatrudniają
magów-analityków. Ulice pełne są wszczepów i starożytnych artefaktów.

Gracze są **agentami Gildii Dwudziestominutowych Bohaterów** — tajnej organizacji która wysyła
ludzi w prawdziwy świat, by wykonywali misje zbyt małe dla wielkich korporacji, ale zbyt ważne
by je zignorować.

### 12.2 Klasy postaci — opisy lore

| Klasa | Tytuł wyświetlany | Ulubiony typ questa | Kolor |
|---|---|---|---|
| `techno_mag` | Techno-Mag Danych | Analytical | Elektryczny błękit |
| `chrom_paladin` | Chrom-Paladyn | Physical + Social | Złoto i chrom |
| `widmo_biegacz` | Widmo-Biegacz | Exploration | Ciemna zieleń + cyjan |
| `bio_szaman` | Bio-Szaman | Creative + Exploration | Zieleń + bioluminescencja |
| `inzynier_spoleczny` | Inżynier Społeczny | Social | Czerwień + złoto |
| `kurier_cieni` | Kurier Cieni | Exploration + Physical | Purpura + czerń |
| `architekt_danych` | Architekt Danych | Analytical + Creative | Pomarańcz + błękit |
| `koderyta` | Koderyta | Creative + Ritual | Karmazyn + złoto |

### 12.3 System Avatarów — pełny flow

```
Trigger 1: Koniec onboardingu → generuj Tier 1
  xpService.onCharacterCreated()
    → avatarService.generate(characterId, tier=1)  [background job]

Trigger 2: Level-up na próg tieru
  xpService.awardQuestXp() → level-up detected
    → stary tier = getTier(oldLevel)
    → nowy tier = getTier(newLevel)
    → if nowy > stary:
          avatarService.generate(characterId, tier=nowy)  [background job]
          # Stary avatar pozostaje isCurrent=true do czasu gotowości nowego
          # Po ukończeniu: stary isCurrent=false, nowy isCurrent=true

Tier → Level mapping (nieliniowe — dense early, sparse late):
  T1=L1  T2=L2  T3=L3  T4=L4  T5=L5  T6=L7  T7=L9  T8=L11  T9=L13  T10=L15
  T11=L18  T12=L22  T13=L27  T14=L33  T15=L40  T16=L50  T17=L65  T18=L80  T19=L100  T20=L120

Background job: avatarService.generate(characterId, tier)
  1. Pobierz character z DB (klasa, traits, equipment, name)
  2. Pobierz prompt_template("avatar_generation") z configService
  3. Pobierz game_config("avatar_tiers")[tier]        ← opis wizualny tieru
  4. Pobierz game_config("avatar_class_palettes")[class] ← paleta kolorów klasy
  5. Render Handlebars template → pełny prompt
  5b. Wybierz model z game_config("avatar_models")[tier]:
      Tier 1       → gemini-3-pro-image-preview   (Pro, ~$0.05 — pierwsze wrażenie)
      Tier 2, 3, 4 → gemini-3.1-flash-image-preview (Flash, ~$0.02 — tanie early tiers)
      Tier 5       → gemini-3-pro-image-preview   (Pro, ~$0.05 — pierwszy milestone)
      Tier 6-15    → gemini-3.1-flash-image-preview (Flash, ~$0.02 — mid-game bulk)
      Tier 16-20   → gemini-3-pro-image-preview   (Pro, ~$0.05 — legendary tiers)
  6. Call Gemini image API z wybranym modelem
  7. Upload 512x512 → S3: avatars/{characterId}/tier_{tier}.png
  8. Generuj thumbnail 128x128 (sharp)
  9. Upload thumbnail → S3: avatars/{characterId}/tier_{tier}_thumb.png
  10. UPDATE character_avatars SET status='ready', image_url=..., is_current=TRUE
  11. UPDATE poprzedni avatar SET is_current=FALSE
```

### 12.4 Avatar w UI innych użytkowników

```typescript
// packages/types/src/index.ts

// Wszędzie gdzie pojawia się user — zawsze z avatarem i levelem
export interface UserSummary {
  userId:          string
  username:        string
  level:           number
  classKey:        string           // 'techno_mag'
  classDisplay:    string           // 'Techno-Mag Danych'
  avatarThumbnail: string | null    // 128x128, null jeśli jeszcze generating
  avatarTier:      number           // 1-20 — widoczny "prestiż"
  tokenBalance:    number | null    // null jeśli user ukrył
}
```

Pojawia się w: feed, leaderboard, profil, komentarze, reakcje.

### 12.5 Nowe API Endpoints (avatar)

```
GET  /api/characters/me/avatar          → aktualny avatar + status generowania
POST /api/characters/me/avatar/regenerate  → wymuś regenerację (admin lub po zmianie)
GET  /api/users/{id}/profile            → publiczny profil z avatarem
```

---

### 12.6 Avatar Generation Prompt — Pełny System

> Klucz w `prompt_templates`: `"avatar_generation"`
> Gemini **image** API (nie text) — prompt to jeden ciąg tekstowy, brak system_instruction
> Model per tier: z `game_config("avatar_models")` — Pro lub Flash

#### Problem: Ciągłość Wizualna Między Tierami

Gemini image generation **nie ma img2img** — każdy tier generowany od zera.
Bez rozwiązania: tier 5 wygląda jak zupełnie inna osoba niż tier 1.

**Rozwiązanie: `visual_anchor`** — stały zestaw cech wizualnych zapisany w DB przy tworzeniu postaci.
Przekazywany do KAŻDEGO tier generation prompt. Zapewnia że to ten sam bohater — tylko ewoluuje.

```typescript
// packages/db/src/schema.ts — nowa kolumna w tabeli characters (migracja przy starcie)
// ALTER TABLE characters ADD COLUMN visual_anchor JSONB;

// packages/types/src/index.ts
export interface VisualAnchor {
  distinctiveFeatures: string[]  // 2-3 cechy ZAWSZE widoczne w avatarze
  // ["nosi okulary o stalowych oprawkach", "bystre analityczne oczy",
  //  "mały świecący implant za lewym uchem"]
  silhouette:          string    // ogólna sylwetka / postawa
  // "szczupła, precyzyjna sylwetka — stoi jakby analizował otoczenie"
  artStyle:            string    // styl artystyczny — stały dla całej postaci
  // "anime-adjacent cartoon, bold black outlines, vibrant flat colors"
}
```

**Generowanie `visual_anchor`** — oddzielne wywołanie text AI przy Tier 1:

```typescript
// packages/api/src/services/avatar-service.ts — wywoływane raz, po onboardingu

import { GoogleGenAI } from '@google/genai'
import { config } from '../config'
import { db } from '@20hero/db'
import { characters } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import type { Character, VisualAnchor } from '@20hero/types'

const ai = new GoogleGenAI({ apiKey: config.GEMINI_API_KEY })

export async function generateVisualAnchor(character: Character): Promise<VisualAnchor> {
  // Generuje stałe cechy wizualne na podstawie onboardingu. Jeden text call.
  const prompt = `
Postać: ${character.name}
Klasa: ${CLASS_DISPLAY[character.classKey]} w świecie Cyber-Fantasy Neon & Mana
Cechy osobowości: ${character.personalityTraits.join(', ')}
Ekwipunek: ${JSON.stringify(character.equipment)}
Backstory (fragment): ${character.backstory?.slice(0, 200) ?? ''}

Zaproponuj 2-3 STAŁE cechy wizualne tej postaci które będą widoczne w jej avatarze
na KAŻDYM etapie progressji (tier 1 do tier 20).
Cechy muszą: ewoluować z postacią (rozwijać się, nie znikać), być konkretne wizualnie,
pasować do klasy ${character.classKey}.

Odpowiedź w JSON: distinctiveFeatures (lista string), silhouette (string).
`
  // text call do gemini-flash (tanie, jednorazowe)
  const response = await ai.models.generateContent({
    model: 'gemini-2.0-flash',
    contents: [{ role: 'user', parts: [{ text: prompt }] }],
  })

  const text = response.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}'
  const result = JSON.parse(text) as VisualAnchor

  await db
    .update(characters)
    .set({ visualAnchor: result })
    .where(eq(characters.id, character.id))

  return result
}
```

#### System Prompt (nie używany w image API — zamiast tego preambula w prompcie)

> Gemini `generateImages()` przyjmuje tylko `prompt: string` — brak `system_instruction`.
> Zamiast system prompt, używamy **preambułę stylu** na początku każdego promptu image.

```
# prompt_templates.system_prompt dla "avatar_generation":
# (używany jako prefix w user_template, nie jako osobny param API)

STYL ARTYSTYCZNY: Kreskówkowa ilustracja cyfrowa w stylu anime-adjacent.
Grube czarne obrysy. Żywe, nasycone kolory na ciemnym tle.
Portret 512x512. NIE fotorealistyczny. NIE zdjęcie. NIE tekst ani napisy w obrazie.
NIE watermark. NIE NSFW. Bezpieczny dla wszystkich grup wiekowych.
```

#### User Template (Handlebars — kolumna `user_template`)

```handlebars
{{systemPreamble}}

POSTAĆ: {{character.name}}
KLASA: {{tierData.classDisplay}} — {{classLoreTagline character.classKey}}
PALETA KOLORÓW: {{classPalette}} (dominujące przez CAŁY portret)

STAŁE CECHY WIZUALNE (ZAWSZE widoczne, niezależnie od tieru):
{{#each character.visualAnchor.distinctiveFeatures}}
- {{this}}
{{/each}}
SYLWETKA: {{character.visualAnchor.silhouette}}

TIER {{tier}} Z 20 — "{{tierData.title}}":
{{tierData.visual}}

PROGRESJA — co nowego w tym tierze vs poprzedni:
{{tierProgressionDelta}}

KOMPOZYCJA:
- Portret 3/4 (od klatki piersiowej w górę)
- Bohater w centrum, tło {{extractBackground tierData.visual}}
- Wyraźne gradienty na tle, klasowe kolory dominują obrysy
- Oczy: {{eyeGlowIntensity}}

EKWIPUNEK (widoczny symbolicznie):
{{equipmentVisualDescription}}

NIE rysuj: tekstu, napisów, liter, watermarków.
NIE mieszaj stylów — tylko jeden spójny styl kreskówkowy.
```

#### Zmienne szablonu

```typescript
// packages/api/src/services/avatar-service.ts — buildAvatarPromptContext()

export const CLASS_LORE_TAGLINE: Record<string, string> = {
  techno_mag:          'widzi dane jak strumienie many, optymalizuje rzeczywistość',
  chrom_paladin:       'cybernetyczny rycerz, egzekutor cyfrowej sprawiedliwości',
  widmo_biegacz:       'implanty neuralne + parkour, przechodzi miasto jak duch',
  bio_szaman:          'hakuje ekosystemy biotech, rozmawia z naturą',
  inzynier_spoleczny:  'charyzma + subtelny tech = kontrola narracji',
  kurier_cieni:        'cloaking-ware i magia cienia, dostarcza tajemnice',
  architekt_danych:    'buduje mosty między starą magią a nowym kodem',
  koderyta:            'stare rytuały jako executable code, debug-uje rzeczywistość',
}

// Range-based eye glow rules: [tierMinInclusive, tierMaxExclusive, description]
const EYE_GLOW_RULES: [number, number, string][] = [
  [1,  3,  'normalne ludzkie, lekki blask klasy ledwo widoczny'],
  [3,  6,  'subtelny blask w kolorze klasy, wyraźny tylko w ciemności'],
  [6,  10, 'wyraźny blask klasy, tęczówki podświetlone'],
  [10, 15, 'oczy jak ekrany — pełen kolor klasy, intensywny'],
  [15, 20, 'oczy to źródła światła — blask oświetla twarz'],
  [20, 21, 'oczy niewidoczne — zastąpione czystą energią klasy'],
]

export function getEyeGlow(tier: number): string {
  // Zwraca opis blasku oczu dla danego tieru.
  const rule = EYE_GLOW_RULES.find(([lo, hi]) => lo <= tier && tier < hi)
  return rule ? rule[2] : EYE_GLOW_RULES[EYE_GLOW_RULES.length - 1][2] // fallback: tier 20
}

export const TIER_PROGRESSION_DELTA: Record<number, string> = {
  // Co dodaje się wizualnie w każdym tierze
  1:  'brak — to baseline, pierwszy avatar',
  2:  'pierwszy implant neuralny lub runiczna tatuaż, ledwo widoczny',
  3:  'akcesoria klasy wyraźniejsze, tło bardziej klimatyczne',
  4:  'holograficzne elementy przy dłoniach lub głowie',
  5:  'zbroja/strój kompletniejszy, pierwsze wyraźne cyber-magic fusion',
  6:  'runy na skórze lub dane przepływające przez ciało',
  7:  'zaawansowane implanty widoczne, aura klasy zaczyna się materializować',
  8:  'mini-drony lub magiczne elementy orbitują postać',
  9:  'odznaka Gildii widoczna, komanda w postawie',
  10: 'pełny HUD lub magiczny shield wokół głowy',
  11: 'ciało częściowo translucent — energia widoczna pod skórą',
  12: 'zbroja z absorbowanych systemów, matrix-eyes',
  13: 'runy piszą się same wokół postaci',
  14: 'reality glitch wokół postaci — szczeliny w tkance przestrzeni',
  15: 'symbol klasy świeci żywym światłem w klatce piersiowej',
  16: 'orbitujące kryształy runiczno-cyfrowe wokół całej sylwetki',
  17: 'ciało jest i widmem i ciałem jednocześnie — dual nature',
  18: 'teren wokół postaci reaguje — ruchome glichy podłogi i ścian',
  19: 'forma niedefiniowalna — postać transcenduje wizualny opis',
  20: 'postać jest i polem energii i człowiekiem — void tworzy kontekst',
}

export function equipmentToVisual(equipment: Record<string, string>, classKey: string): string {
  // Tłumaczy equipment na opis wizualny dla image prompt.
  const parts: string[] = []
  if (equipment.weapon)   parts.push(`narzędzie klasy: ${equipment.weapon} — widoczne przy pasie lub w dłoni`)
  if (equipment.armor)    parts.push(`ochrona: ${equipment.armor} — widoczna na ramionach/klatce`)
  if (equipment.artifact) parts.push(`artefakt: ${equipment.artifact} — świecący lub widoczny delikatnie`)
  return parts.length > 0 ? parts.join('\n') : 'standardowy ekwipunek Gildii'
}

export interface AvatarPromptContext {
  systemPreamble:             string
  character:                  Character
  tier:                       number
  tierData:                   Record<string, unknown>
  classLoreTagline:           Record<string, string>
  classPalette:               string
  eyeGlowIntensity:           string
  tierProgressionDelta:       string
  equipmentVisualDescription: string
}

export function buildAvatarPromptContext(
  character: Character,
  tier: number,
  tierData: Record<string, unknown>,
  classPalette: string,
  systemPreamble: string,
): AvatarPromptContext {
  return {
    systemPreamble,
    character,
    tier,
    tierData,
    classLoreTagline:           CLASS_LORE_TAGLINE,
    classPalette,
    eyeGlowIntensity:           getEyeGlow(tier),
    tierProgressionDelta:       TIER_PROGRESSION_DELTA[tier],
    equipmentVisualDescription: equipmentToVisual(character.equipment ?? {}, character.classKey),
  }
}
```

#### Wywołanie Gemini Image API

```typescript
// packages/api/src/services/avatar-service.ts

import { GoogleGenAI } from '@google/genai'
import { config } from '../config'
import { getGameConfig, getPrompt } from './config-service'
import type { Character } from '@20hero/types'

const ai = new GoogleGenAI({ apiKey: config.GEMINI_API_KEY })

export class AvatarGenerationError extends Error {}

export async function generateAvatarImage(
  character: Character,
  tier: number,
): Promise<Buffer> {
  const tierData    = (await getGameConfig('avatar_tiers'))[String(tier)] as Record<string, unknown>
  const classPalette = ((await getGameConfig('avatar_class_palettes')) as any)[character.classKey] as string
  const tmpl        = await getPrompt('avatar_generation')
  const model       = ((await getGameConfig('avatar_models')) as any)[String(tier)] as string

  const context = buildAvatarPromptContext(
    character, tier, tierData, classPalette, tmpl.systemPrompt,
  )
  const imagePrompt = renderTemplate(tmpl.userTemplate, context)

  // Gemini image generation API
  const response = await ai.models.generateContent({
    model,            // "gemini-3-pro-image-preview" lub "gemini-3.1-flash-image-preview"
    contents: [{ role: 'user', parts: [{ text: imagePrompt }] }],
    // number_of_images, aspect_ratio, safety_filter_level passed via generationConfig
  })

  const imageData = response.candidates?.[0]?.content?.parts?.find(
    (p: any) => p.inlineData?.mimeType?.startsWith('image/')
  )?.inlineData?.data

  if (!imageData) {
    throw new AvatarGenerationError(`Gemini returned no images for tier ${tier}`)
  }

  const imageBytes = Buffer.from(imageData, 'base64')

  // Resize do dokładnie 512x512 (sharp) — Gemini może zwrócić inny rozmiar
  const sharp = (await import('sharp')).default
  const resized = await sharp(imageBytes)
    .resize(512, 512, { fit: 'cover' })
    .png({ compressionLevel: 9 })
    .toBuffer()

  return resized
}
```

#### Pełny Background Job z Retry Logic

```typescript
// packages/api/src/jobs/avatar-job.ts

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import sharp from 'sharp'
import * as Sentry from '@sentry/node'
import { config } from '../config'
import { db } from '@20hero/db'
import { characterAvatars, characters } from '@20hero/db/schema'
import { eq, and } from 'drizzle-orm'
import { getGameConfig, getPrompt } from '../services/config-service'
import { buildAvatarPromptContext, equipmentToVisual, TIER_PROGRESSION_DELTA, getEyeGlow, AvatarGenerationError } from '../services/avatar-service'
import { generateVisualAnchor } from '../services/avatar-service'
import { pushService } from '../services/push-service'
import { ai } from '../lib/genai'

// Strategia retry przy pustym response od Gemini:
//
// Attempt 1 (retry=0): Normalny prompt z pełnym visual_anchor i tier_data
// Attempt 2 (retry=1): Złagodzony prompt — usunięte potencjalnie blokujące elementy
//                       (implanty, cyberpunk elements uproszczone do "futuristic accessories")
// Attempt 3 (retry=2): Fallback prompt — tylko klasa + tier + paleta, zero postaci specifics
// After 3 fails:        status="failed", Sentry alert, stary avatar pozostaje current

type PromptVariant = 'full' | 'softened' | 'class_only'

const PROMPT_VARIANTS: Record<number, PromptVariant> = {
  0: 'full',       // Pełny prompt z visual_anchor
  1: 'softened',   // Złagodzony (bez implantów, mniej cyber)
  2: 'class_only', // Tylko klasa + tier + kolor, bez postaci specifics
}

async function buildPromptVariant(
  character: any,
  tier: number,
  tierData: Record<string, unknown>,
  classPalette: string,
  systemPreamble: string,
  variant: PromptVariant,
): Promise<string> {
  if (variant === 'full') {
    const context = buildAvatarPromptContext(character, tier, tierData, classPalette, systemPreamble)
    return renderTemplate((await getPrompt('avatar_generation')).userTemplate, context)
  }

  if (variant === 'softened') {
    // Uproszczony visual_anchor — tylko ubranie i ogólna sylwetka, bez implantów
    const anchor = character.visualAnchor ?? {}
    const softFeatures = (anchor.distinctiveFeatures ?? []).filter(
      (f: string) => !/(implant|cybernetic|neural|chip)/i.test(f),
    )
    const features = softFeatures.length > 0 ? softFeatures : ['distinctive outfit matching their class']
    return `${systemPreamble}

POSTAĆ: ${character.name}
KLASA: ${CLASS_DISPLAY[character.classKey]}
PALETA KOLORÓW: ${classPalette}

CECHY (uproszczone): ${features.join(', ')}
SYLWETKA: ${anchor.silhouette ?? 'confident hero stance'}

TIER ${tier} Z 20 — "${(tierData as any).title}":
${(tierData as any).visual}

KOMPOZYCJA: Portret 3/4, hero w centrum, tło klimatyczne dla tieru.
NIE rysuj: tekstu, napisów, watermarków.`
  }

  // class_only — ostatnia deska ratunku
  const CLASS_THEMES: Record<string, string> = {
    techno_mag:          'cyberpunk data mage with blue holographic elements',
    chrom_paladin:       'chrome armored paladin with golden light',
    widmo_biegacz:       'dark parkour runner with cyan neon accents',
    bio_szaman:          'nature-tech shaman with bioluminescent plants',
    inzynier_spoleczny:  'charismatic social engineer in red and black',
    kurier_cieni:        'shadow courier in purple and black',
    architekt_danych:    'data architect with orange and blue glow',
    koderyta:            'ritual coder with crimson arcane symbols',
  }
  return `${systemPreamble}

A ${CLASS_THEMES[character.classKey] ?? 'mysterious hero'} character portrait.
Tier ${tier} of 20 progression: ${(tierData as any).title}.
Color palette: ${classPalette}.
Anime-adjacent cartoon style. Dark background. No text. No watermarks.`
}

const s3 = new S3Client({ region: config.AWS_REGION })

export async function generateAvatarTierJob(characterId: string, tier: number): Promise<void> {
  const MAX_ATTEMPTS = 3

  let character = await db.query.characters.findFirst({ where: eq(characters.id, characterId) })
  if (!character) throw new Error(`Character not found: ${characterId}`)

  // Upewnij się że visual_anchor istnieje
  if (!character.visualAnchor) {
    await generateVisualAnchor(character as any)
    character = await db.query.characters.findFirst({ where: eq(characters.id, characterId) })
  }

  // Oznacz jako "generating"
  await db
    .insert(characterAvatars)
    .values({ characterId, levelTier: tier, status: 'generating', isCurrent: false })
    .onConflictDoUpdate({
      target: [characterAvatars.characterId, characterAvatars.levelTier],
      set: { status: 'generating', isCurrent: false },
    })

  const tierData    = ((await getGameConfig('avatar_tiers')) as any)[String(tier)]
  const classPalette = ((await getGameConfig('avatar_class_palettes')) as any)[character!.classKey]
  const tmpl        = await getPrompt('avatar_generation')
  const model       = ((await getGameConfig('avatar_models')) as any)[String(tier)]

  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    const variant = PROMPT_VARIANTS[attempt] ?? 'class_only'

    const imagePrompt = await buildPromptVariant(
      character, tier, tierData, classPalette, tmpl.systemPrompt, variant,
    )

    try {
      const response = await ai.models.generateContent({
        model,
        contents: [{ role: 'user', parts: [{ text: imagePrompt }] }],
      })

      const imageData = response.candidates?.[0]?.content?.parts?.find(
        (p: any) => p.inlineData?.mimeType?.startsWith('image/')
      )?.inlineData?.data

      if (!imageData) {
        // Gemini zwrócił puste — nie Exception; retry ręcznie z cooldown
        if (attempt < MAX_ATTEMPTS - 1) {
          const waitMs = 60_000 * (attempt + 1) // 60s, 120s, 180s
          await new Promise((r) => setTimeout(r, waitMs))
          continue
        }
        throw new AvatarGenerationError(
          `Gemini empty response — tier=${tier}, attempt=${attempt + 1}/${MAX_ATTEMPTS}, variant=${variant}`,
        )
      }

      let imageBytes = Buffer.from(imageData, 'base64')

      // Resize do dokładnie 512x512
      imageBytes = await sharp(imageBytes)
        .resize(512, 512, { fit: 'cover' })
        .png({ compressionLevel: 9 })
        .toBuffer()

      // Thumbnail 128x128
      const thumbBytes = await sharp(imageBytes)
        .resize(128, 128, { fit: 'cover' })
        .png({ compressionLevel: 9 })
        .toBuffer()

      // Upload do S3
      const s3Key      = `avatars/${characterId}/tier_${tier}.png`
      const s3KeyThumb = `avatars/${characterId}/tier_${tier}_thumb.png`

      await Promise.all([
        s3.send(new PutObjectCommand({
          Bucket: config.S3_BUCKET_PUBLIC, Key: s3Key,
          Body: imageBytes, ContentType: 'image/png',
        })),
        s3.send(new PutObjectCommand({
          Bucket: config.S3_BUCKET_PUBLIC, Key: s3KeyThumb,
          Body: thumbBytes, ContentType: 'image/png',
        })),
      ])

      const imageUrl = `${config.CDN_BASE_URL}/${s3Key}`
      const thumbUrl = `${config.CDN_BASE_URL}/${s3KeyThumb}`

      // Atomic swap: nowy isCurrent=true, stary isCurrent=false
      await db.transaction(async (tx) => {
        await tx
          .update(characterAvatars)
          .set({ isCurrent: false })
          .where(and(
            eq(characterAvatars.characterId, characterId),
            eq(characterAvatars.isCurrent, true),
          ))
        await tx
          .insert(characterAvatars)
          .values({
            characterId,
            levelTier:       tier,
            imageUrl,
            thumbnailUrl:    thumbUrl,
            status:          'ready',
            isCurrent:       true,
            generationModel: model,
            promptUsed:      imagePrompt.slice(0, 2000),
            generatedAt:     new Date(),
          })
          .onConflictDoUpdate({
            target: [characterAvatars.characterId, characterAvatars.levelTier],
            set: { imageUrl, thumbnailUrl: thumbUrl, status: 'ready', isCurrent: true,
                   generationModel: model, promptUsed: imagePrompt.slice(0, 2000), generatedAt: new Date() },
          })
      })

      // Push notification
      await pushService.notify(character!.userId, {
        type:  'avatar_evolved',
        title: 'Avatar zaktualizowany!',
        body:  `Twój ${tierData.title} jest gotowy.`,
        data:  { tier, thumbnailUrl: thumbUrl },
      })

      return // sukces

    } catch (err) {
      if (attempt < MAX_ATTEMPTS - 1) {
        const waitMs = 60_000 * (attempt + 1)
        await new Promise((r) => setTimeout(r, waitMs))
        continue
      }

      // Wszystkie 3 próby nieudane — graceful degradation
      await db
        .insert(characterAvatars)
        .values({ characterId, levelTier: tier, status: 'failed' })
        .onConflictDoUpdate({
          target: [characterAvatars.characterId, characterAvatars.levelTier],
          set: { status: 'failed' },
        })
      // Stary avatar (poprzedni tier) pozostaje isCurrent=true → user coś widzi
      // Alert do zespołu
      Sentry.captureMessage(
        `Avatar generation failed after 3 attempts: character=${characterId}, tier=${tier}`,
        { level: 'error', extra: { characterClass: character!.classKey, lastVariant: variant } },
      )
      // NIE throw — job kończy się jako "sukces" (stary avatar dalej działa)
      return
    }
  }
}
```

**Podsumowanie retry flow:**

```
Attempt 1 (retry=0): Pełny prompt z visual_anchor
  ↓ Gemini empty response (safety filter)
  wait 60s
Attempt 2 (retry=1): Złagodzony prompt (bez implantów, ogólniejszy opis)
  ↓ Gemini empty response
  wait 120s
Attempt 3 (retry=2): Fallback prompt — tylko klasa + tier, brak character specifics
  ↓ Gemini empty response
  → status="failed", stary avatar pozostaje, Sentry alert
  → NIGDY nie ma pustego avatara u gracza — degradacja, nie crash
```

#### Przykłady — renderowane prompty dla 3 kombinacji

```
--- TECHNO_MAG, TIER 1 (gemini-3-pro-image-preview) ---

STYL ARTYSTYCZNY: Kreskówkowa ilustracja cyfrowa w stylu anime-adjacent.
Grube czarne obrysy. Żywe, nasycone kolory na ciemnym tle.
Portret 512x512. NIE fotorealistyczny. NIE tekst ani napisy. NIE watermark.

POSTAĆ: Kai Protokół
KLASA: Techno-Mag Danych — widzi dane jak strumienie many, optymalizuje rzeczywistość
PALETA KOLORÓW: electric blue, silver, purple (dominujące przez CAŁY portret)

STAŁE CECHY WIZUALNE:
- nosi okulary o stalowych oprawkach
- bystre analityczne oczy zawsze skupione na jednym punkcie
- mały świecący implant za lewym uchem (ledwo widoczny w Tier 1)

SYLWETKA: szczupła, precyzyjna sylwetka — stoi jakby analizował otoczenie

TIER 1 Z 20 — "Rekrut Gildii":
Szary płaszcz i znoszony plecak, jedna glowing runa na ramieniu.
Tło: deszczowa szara ulica.

PROGRESJA: brak — to baseline, pierwszy avatar.

KOMPOZYCJA:
- Portret 3/4 (od klatki piersiowej w górę)
- Bohater w centrum, tło deszczowa szara ulica
- Oczy: normalne ludzkie, lekki blask klasy ledwo widoczny

EKWIPUNEK:
narzędzie klasy: Laptop z crackami — widoczny przy pasie lub w dłoni
ochrona: Przeciwdeszczowa kurtka z NFC-skanerem — widoczna na ramionach
artefakt: Inteligentny notatnik — świecący delikatnie

NIE rysuj tekstu, napisów, liter, watermarków. NIE mieszaj stylów.
```

```
--- BIO_SZAMAN, TIER 10 (gemini-3.1-flash-image-preview) ---

STYL ARTYSTYCZNY: Kreskówkowa ilustracja cyfrowa w stylu anime-adjacent. [...]

POSTAĆ: Mira Korzeni
KLASA: Bio-Szaman — hakuje ekosystemy biotech, rozmawia z naturą
PALETA KOLORÓW: forest green, bioluminescent blue, earth brown

STAŁE CECHY WIZUALNE:
- warkoczy splecione z bioluminescencyjnymi roślinami
- zielonkawy blask skóry w ciemności
- roślinne wzory na przedramionach (jak tatuaże, ale żywe)

SYLWETKA: silna i zakorzeniona, stoi jak drzewo — spokojna siła

TIER 10 Z 20 — "Archon Cyber-Magiczny":
Kompletny strój arcana-tech, HUD holograficzny wokół głowy.
Tło: korporacyjna twierdza z roślinnością przebijającą przez beton.

PROGRESJA: pełny HUD lub magiczny shield wokół głowy; wcześniejsze cechy rozwinięte.

OCZY: oczy jak ekrany — pełen kolor klasy (bioluminescent blue), intensywne.

EKWIPUNEK:
narzędzie klasy: Biotyczny staff z żywymi korzeniami — w dłoni
ochrona: Pancerz z mycelium i nano-włókien — na klatce piersiowej
artefakt: Kryształ z uśpionym ekosystemem — świeci zielenią przy piersi
```

```
--- KURIER_CIENI, TIER 17 (gemini-3-pro-image-preview) ---

STYL ARTYSTYCZNY: Kreskówkowa ilustracja cyfrowa w stylu anime-adjacent. [...]

POSTAĆ: Zero Cień
KLASA: Kurier Cieni — cloaking-ware i magia cienia, dostarcza tajemnice
PALETA KOLORÓW: deep purple, black, silver

STAŁE CECHY WIZUALNE:
- zawsze kaptur — twarz w cieniu z jednym widocznym okiem
- srebrna maska zakrywa dolną połowę twarzy
- cień pod stopami nie pasuje do oświetlenia (zawsze ciemniejszy)

SYLWETKA: chuda, zwinnie złożona — gotowa do natychmiastowego ruchu

TIER 17 Z 20 — "Przebudzony":
Fizyczna forma częściowo zastąpiona energią — widmo i ciało jednocześnie.
Tło: void cities — miasta istniejące między wymiarami.

PROGRESJA: ciało jest i widmem i ciałem jednocześnie — dual nature.

OCZY: oczy to źródła światła — blask oświetla twarz (deep purple).

NIE rysuj tekstu, napisów, liter, watermarków. NIE mieszaj stylów.
```

---

## 13. Wallet & Token Economy — Architektura 3-Fazowa

### 13.1 Fazy wdrożenia

```
Faza 1 — MVP (teraz):        Wirtualne HERO tokeny w DB
  ├── token_balances: saldo widoczne w UI
  ├── token_ledger: pełna historia (immutable)
  ├── token_withdrawal.enabled = false → UI: "Wkrótce"
  ├── wallet_custodial.phase = "virtual" → brak blockchain
  └── ZERO kodu krypto / Web3 / KMS na MVP

Faza 2 — Fiat (Q2+):         Stripe Connect payouts
  ├── custodial_wallets.phase = "fiat"
  ├── KYC przez Sumsub (wallet_custodial.kyc_provider)
  ├── HERO → USD → konto bankowe przez Stripe
  ├── token_withdrawal.enabled = true
  └── Wymagane: Stripe Connect setup + KYC flow

Faza 3 — Blockchain (Q4+):   ERC-20 smart contract
  ├── Polygon lub Base (EVM — niższe opłaty niż Ethereum mainnet)
  ├── Custodial wallets przez AWS KMS (klucze prywatne nigdy w DB)
  ├── token_withdrawals.tx_hash wypełniane z blockchain
  ├── Worker monitoruje confirmations (6 bloków)
  └── custodial_wallets.phase = "crypto", kms_key_arn wypełniony
```

**Przełączenie faz = tylko game_config updates. Zero zmian kodu.**

### 13.2 AWS KMS Pattern (Faza 3)

```typescript
// packages/api/src/services/wallet-service.ts
// Klucze prywatne NIGDY nie wychodzą z AWS KMS

import { KMSClient, CreateKeyCommand, GetPublicKeyCommand, SignCommand } from '@aws-sdk/client-kms'
import { config } from '../config'
import { db } from '@20hero/db'
import { custodialWallets } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'

const kms = new KMSClient({ region: config.AWS_REGION })

export async function createCustodialWallet(userId: string): Promise<string> {
  // Tworzy klucz KMS i zwraca adres EVM.
  const key = await kms.send(new CreateKeyCommand({
    Description: `20hero-wallet-${userId}`,
    KeyUsage:    'SIGN_VERIFY',
    KeySpec:     'ECC_SECG_P256K1',
    Tags:        [{ TagKey: 'user_id', TagValue: userId }],
  }))
  const kmsKeyArn = key.KeyMetadata!.KeyArn!

  // Pobierz klucz publiczny z KMS (prywatny NIGDY nie opuszcza KMS)
  const pubKeyResponse = await kms.send(new GetPublicKeyCommand({ KeyId: kmsKeyArn }))
  const evmAddress = derToEvmAddress(pubKeyResponse.PublicKey!)

  // Zapisz ARN i adres — nigdy klucz prywatny
  await db.insert(custodialWallets).values({
    userId,
    kmsKeyArn,
    evmAddress,
    phase: 'crypto',
  })

  return evmAddress
}

export async function signTransaction(userId: string, txBytes: Uint8Array): Promise<Uint8Array> {
  // Podpisuje transakcję przez KMS (klucz prywatny nigdy nie opuszcza AWS).
  const [wallet] = await db
    .select({ kmsKeyArn: custodialWallets.kmsKeyArn })
    .from(custodialWallets)
    .where(eq(custodialWallets.userId, userId))
    .limit(1)

  const response = await kms.send(new SignCommand({
    KeyId:            wallet.kmsKeyArn,
    Message:          txBytes,
    MessageType:      'RAW',
    SigningAlgorithm: 'ECDSA_SHA_256',
  }))

  return response.Signature!
}
```

### 13.3 Anti Pay-to-Win — guardrail architektoniczny

```typescript
// packages/api/src/services/token-shop-service.ts
import { getGameConfig } from './config-service'
import { db } from '@20hero/db'
import { tokenLedger, tokenBalances, cosmeticPurchases } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'

const BLOCKED_CATEGORIES = new Set(['xp_boost', 'level_skip', 'stat_boost', 'quest_skip'])

export class ServiceUnavailableError extends Error {}
export class ForbiddenError extends Error {}
export class InsufficientBalanceError extends Error {}

export interface PurchaseResult {
  itemKey:    string
  newBalance: number
}

export async function purchaseItem(userId: string, itemKey: string): Promise<PurchaseResult> {
  const shopCfg = await getGameConfig('token_shop') as any

  if (!shopCfg.enabled) {
    throw new ServiceUnavailableError('Token shop not yet active')
  }

  // Znajdź item we wszystkich kategoriach
  const item = findItem(shopCfg.categories, itemKey)

  // GUARDRAIL — architectural, nie konfigurowalny
  if (BLOCKED_CATEGORIES.has(item.category)) {
    throw new ForbiddenError('Pay-to-win items are permanently blocked')
  }

  // Sprawdź saldo
  const [balance] = await db
    .select()
    .from(tokenBalances)
    .where(eq(tokenBalances.userId, userId))
    .limit(1)

  if (!balance || balance.balance < item.price) {
    throw new InsufficientBalanceError('Insufficient token balance')
  }

  const newBalance = await db.transaction(async (tx) => {
    // Pobierz tokeny z salda
    const [updated] = await tx
      .update(tokenBalances)
      .set({ balance: balance.balance - item.price, updatedAt: new Date() })
      .where(eq(tokenBalances.userId, userId))
      .returning({ balance: tokenBalances.balance })

    await tx.insert(tokenLedger).values({
      userId,
      eventType:    'cosmetic_purchase',
      amount:       -item.price,
      balanceAfter: updated.balance,
      metadata:     { itemKey },
    })

    // Zapisz zakup
    await tx.insert(cosmeticPurchases).values({
      userId,
      itemKey,
      itemCategory: item.category,
      pricePaid:    item.price,
    })

    return updated.balance
  })

  return { itemKey, newBalance }
}
```

### 13.4 Blockchain choice rationale

| Sieć | Gas fees | EVM | Płynność | Decyzja |
|---|---|---|---|---|
| Ethereum | Wysoki (~$5-50/tx) | Tak | Najwyższa | Za drogi dla mikrotransakcji |
| **Polygon** | Niski (~$0.001/tx) | Tak | Dobra | **Główny wybór MVP Fazy 3** |
| **Base** | Niski (~$0.001/tx) | Tak | Rosnąca | Zapasowy, jeśli Polygon problemy |
| Solana | Bardzo niski | Nie | Dobra | Oddzielny SDK, komplikuje architekturę |

Rekomendacja: **Polygon PoS** dla Fazy 3. Kompatybilność z Ethereum tooling (ethers.js, viem), niskie opłaty, sprawdzona sieć Layer 2.

### 13.5 Design decision: Tokeny poza MVP

**Decyzja:** Schema tokenów istnieje w DB (token_ledger, token_balances, custodial_wallets, cosmetic_purchases), ale:

1. `token_withdrawal.enabled = false` — wypłaty zablokowane
2. `wallet_custodial.enabled = false` — brak blockchain
3. `token_shop.enabled = false` — brak sklepu kosmetycznego
4. `token_rewards` w game_config jest aktywny → XP i saldo HERO są naliczane od dnia 1

**Efekt:** Gracze widzą swoje saldo HERO rosnące za questy od startu. "Wypłata wkrótce" buduje oczekiwanie. Wdrożenie Fazy 2/3 = `UPDATE game_config SET value = ... WHERE key IN ('token_withdrawal', 'wallet_custodial', 'token_shop')` + deploy nowego kodu. Zero migracji danych.

---

## 14. Authentication & Security

### Decyzja: Firebase Auth (identity) — uproszczony model

> **Zmiana względem poprzedniej architektury**: Usunięto własną parę access + refresh token.
> Firebase ID token używany bezpośrednio jako Bearer token. Eliminuje key rotation,
> audience management, revocation i token versioning jako osobny problem.

**Dlaczego Firebase Auth zamiast własnego OAuth?**
- Google Sign-In: Firebase obsługuje cały flow OAuth2 + token rotation
- Apple Sign-In: **wymagany** przez App Store dla iOS (reguła Apple 4.8)
- Nie przechowujemy haseł — zero ryzyka wycieku credential
- Firebase Admin SDK weryfikuje `id_token` offline (cached JWKS) — brak network call
- Firebase SDK auto-odnawia token po stronie mobilnej — brak potrzeby własnego refresh flow

### Flow — krok po kroku

```
1. App: Firebase SDK → Google/Apple Sign-In
        Firebase SDK zwraca: id_token (JWT podpisany przez Firebase, TTL 1h)

2. App: POST /api/auth/register
        Header: Authorization: Bearer {id_token}
   Backend:
   - firebaseAdmin.auth().verifyIdToken(idToken)  ← weryfikacja offline (JWKS cache)
   - Wyciąga: firebase_uid, email, display_name, photo_url
   - UPSERT users SET ... WHERE firebase_uid = ?
   → Response: { user: UserSummary, isNewUser: boolean }

3. App: każde żądanie API
        Header: Authorization: Bearer {Firebase_ID_token}
        (Firebase SDK automatycznie odnawiia token gdy wygasa)

4. Backend Hono middleware (requireAuth):
   - firebaseAdmin.auth().verifyIdToken(token)  ← weryfikacja offline
   - Wyciąga firebase_uid, ładuje/cachuje UserSummary z Redis (TTL 60s) lub DB
   → UserSummary dostępny w handlerze via c.get('user')

5. Token odnawianie — AUTOMATYCZNE po stronie Firebase SDK
   App nie musi wywoływać żadnego endpointu refresh.
```

> **Kiedy wystawiamy własny token?**
> Tylko admin panel webowy (`POST /api/admin/auth/session` → httpOnly cookie, TTL 8h).
> Tabela `refresh_tokens` jest **usunięta** — nie potrzebna przy tym modelu.

### Hono Middleware (auth)

```typescript
// packages/api/src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import * as admin from 'firebase-admin'
import { redis } from '@20hero/db/redis'
import { db } from '@20hero/db'
import { users } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import type { UserSummary } from '@20hero/types'

export const requireAuth = createMiddleware(async (c, next) => {
  const authHeader = c.req.header('Authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    return c.json({ type: 'unauthorized', title: 'Brak tokenu', status: 401 }, 401)
  }
  const idToken = authHeader.slice(7)

  let decoded: admin.auth.DecodedIdToken
  try {
    // Weryfikacja offline — Firebase SDK cache'uje JWKS, brak network call
    decoded = await admin.auth().verifyIdToken(idToken)
  } catch (err: any) {
    const msg = err?.code === 'auth/id-token-expired' ? 'Token wygasł' : 'Nieprawidłowy token'
    return c.json({ type: 'unauthorized', title: msg, status: 401 }, 401)
  }

  // Cache Redis 60s — unika DB przy każdym żądaniu
  const cacheKey = `user_summary:${decoded.uid}`
  const cached = await redis.get(cacheKey)
  if (cached) {
    c.set('user', JSON.parse(cached) as UserSummary)
    return next()
  }

  const [user] = await db.select().from(users).where(eq(users.firebaseUid, decoded.uid)).limit(1)
  if (!user || !user.isActive) {
    return c.json({ type: 'unauthorized', title: 'Użytkownik nieaktywny lub niezarejestrowany', status: 401 }, 401)
  }

  const summary = toUserSummary(user)
  await redis.setex(cacheKey, 60, JSON.stringify(summary))
  c.set('user', summary)
  return next()
})

export const requireOnboarded = createMiddleware(async (c, next) => {
  const user = c.get('user') as UserSummary
  if (!user.classKey) {
    return c.json({ type: 'forbidden', title: 'Najpierw ukończ onboarding', status: 403 }, 403)
  }
  return next()
})
```

### Implementacja serwisu auth (register/login)

```typescript
// packages/api/src/services/auth-service.ts
import * as admin from 'firebase-admin'
import { db } from '@20hero/db'
import { users } from '@20hero/db/schema'
import { eq } from 'drizzle-orm'
import { redis } from '@20hero/db/redis'
import type { UserSummary } from '@20hero/types'

export class UnauthorizedError extends Error {}

export async function registerOrLogin(idToken: string): Promise<{ user: UserSummary; isNewUser: boolean }> {
  // 1. Weryfikacja Firebase
  let decoded: admin.auth.DecodedIdToken
  try {
    decoded = await admin.auth().verifyIdToken(idToken)
  } catch (e) {
    throw new UnauthorizedError(`Nieprawidłowy Firebase token: ${e}`)
  }

  const firebaseUid = decoded.uid
  const email       = decoded.email ?? ''
  const displayName = decoded.name ?? ''

  // 2. Sprawdź czy nowy użytkownik
  const existing = await db.query.users.findFirst({
    where: eq(users.firebaseUid, firebaseUid),
  })

  // 3. UPSERT użytkownika
  const [user] = await db
    .insert(users)
    .values({ firebaseUid, email, displayName })
    .onConflictDoUpdate({
      target: users.firebaseUid,
      set: { email, displayName, updatedAt: new Date() },
    })
    .returning()

  // 4. Inwalidacja cache (świeże dane po rejestracji)
  await redis.del(`user_summary:${firebaseUid}`)

  return { user: toUserSummary(user), isNewUser: !existing }
}

// Brak createAccessToken / createRefreshToken
// Aplikacja mobilna używa Firebase ID token bezpośrednio jako Bearer token
```

### Wylogowanie

```
POST /api/auth/logout
  Auth: Bearer Firebase_ID_token
  Body: { deviceToken?: string }  // opcjonalny — usuwa FCM token z user_devices
  → Inwaliduje user_summary cache w Redis dla tego urządzenia
  → 204 No Content

// Uwaga: nie ma "revoke" Firebase tokenu po stronie backendu.
// Jeśli potrzeba natychmiastowej blokady (ban): users.isActive = false
// → requireAuth middleware zwróci 401 przy każdym kolejnym żądaniu.
```

### Endpointy

```
POST /api/auth/register       Pierwsza rejestracja / ponowny login — upsert user
POST /api/auth/logout         Inwalidacja cache + opcjonalne usunięcie FCM tokenu
POST /api/auth/device-token   Rejestracja FCM tokenu push (Sekcja 8c)
DELETE /api/auth/device-token Usunięcie FCM tokenu (przy wylogowaniu)

// Usunięte (względem poprzedniej wersji):
// POST /api/auth/firebase    → zastąpiony przez /register
// POST /api/auth/refresh     → nie potrzebny (Firebase SDK odnawiia automatycznie)
```

### Bezpieczeństwo — checklist

| Zagrożenie | Mitygacja |
|-----------|-----------|
| Skradziony Firebase ID token | TTL 1h, HTTPS only, Firebase weryfikuje audience + issuer |
| Firebase token replay | Firebase Admin SDK weryfikuje `iat` + `exp` + audience (offline JWKS) |
| Brute-force `/api/auth/register` | Rate limit: 10 req/min per IP (Hono middleware) |
| SQL injection | Wyłącznie parametrized queries (Drizzle ORM) |
| User session po banie | `users.isActive = false` → 401 przy każdym żądaniu (Redis cache TTL 60s) |
| Utrata urządzenia | Firebase pozwala revoke tokens per-UID z Firebase Console / Admin SDK |

---
