İlk commit: Stepstead backend (FastAPI)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jts 2026-07-11 11:30:24 +03:00
commit ea049b8ccd
45 changed files with 6751 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
# Python
venv/
__pycache__/
*.pyc
*.pyo
.pytest_cache/
# Gizli
.env
.env.*
# Loglar
logs/
*.log

16
cache.py Normal file
View file

@ -0,0 +1,16 @@
import redis.asyncio as aioredis
from config import settings
redis_client: aioredis.Redis = aioredis.from_url(
settings.REDIS_URL,
decode_responses=True,
health_check_interval=30,
)
async def ping() -> bool:
try:
return await redis_client.ping()
except Exception:
return False

35
config.py Normal file
View file

@ -0,0 +1,35 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="ignore")
DATABASE_URL: str
DATABASE_URL_SYNC: str
REDIS_URL: str
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 * 7 # 7 gün
APP_NAME: str = "Stepstead"
APP_URL: str = "https://stepstead.com"
API_URL: str = "https://api.stepstead.com"
DEBUG: bool = False
SMTP_HOST: str = "127.0.0.1"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "Stepstead <noreply@stepstead.com>"
SMTP_TLS: bool = True
MAX_STEPS_PER_MINUTE: int = 200
STEP_SYNC_WINDOW_SECONDS: int = 60
SKILL_NAMES: list[str] = [
"mining", "fishing", "foraging", "woodcutting", "hunting",
"combat", "smithing", "alchemy", "cooking", "construction",
]
settings = Settings()

120
db/export_crafts_json.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""
Stepstead craft veritabanı JSON üretici.
recipes + items /var/www/stepstead.com/db/data/crafts.json
Grup: tarif kodu önekinden. Aile: kod son ekinden (malzeme ailesi).
Çalıştırma: /opt/stepstead/venv/bin/python3 /opt/stepstead/db/export_crafts_json.py
"""
import asyncio
import json
import sys
import asyncpg
sys.path.insert(0, "/opt/stepstead")
from config import settings
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
OUT = "/var/www/stepstead.com/db/data/crafts.json"
GROUPS = [
("saw_", "Kerestecilik"), ("build_", "Marangozluk"),
("craft_bow_", "Okçuluk"), ("craft_arrow_", "Okçuluk"),
("craft_staff_", "Büyücülük"),
("smelt_", "Metalurji"), ("forge_", "Demircilik"),
("craft_axe_", "Demircilik"), ("craft_pick_", "Demircilik"),
("jewel_", "Kuyumculuk"), ("brew_", "Simya"),
("cook_", "Aşçılık"), ("tailor_", "Dericilik"), ("craft_rod", "Balıılık"),
]
FAMILY = {
# odunlar
"oak": "Meşe", "pine": "Çam", "birch": "Huş", "beech": "Kayın", "maple": "Akçaağaç",
"willow": "Söğüt", "ash": "Dişbudak", "cedar": "Sedir", "walnut": "Ceviz",
"cherry": "Kiraz", "yew": "Porsuk", "mahogany": "Maun", "ebony": "Abanoz",
# metaller
"copper": "Bakır", "bronze": "Bronz", "iron": "Demir", "steel": "Çelik",
"silver": "Gümüş", "gold": "Altın", "cu": "Bakır", "fe": "Demir", "stone": "Taş",
# otlar
"mint": "Nane", "sage": "Adaçayı", "thyme": "Kekik", "chamomile": "Papatya",
"rosemary": "Biberiye", "basil": "Fesleğen", "nettle": "Isırgan",
"lemonbalm": "Melisa", "fennel": "Rezene", "lavender": "Lavanta",
"ginseng": "Ginseng", "saffron": "Safran",
# balıklar
"carp": "Sazan", "perch": "Levrek", "anchovy": "Hamsi", "mullet": "Kefal",
"mackerel": "Uskumru", "bonito": "Palamut", "bluefish": "Lüfer", "bream": "Çipura",
"trout": "Alabalık", "pike": "Turna", "catfish": "Yayın", "salmon": "Somon",
"turbot": "Kalkan", "tuna": "Ton Balığı", "sturgeon": "Mersin",
# hayvanlar
"rabbit": "Tavşan", "deer": "Geyik", "boar": "Yaban Domuzu", "wolf": "Kurt", "bear": "Ayı",
}
SKILL_TR = {
"mining": "Madencilik", "fishing": "Balıılık", "foraging": "Toplama",
"woodcutting": "Ağaç Kesme", "hunting": "Avcılık", "combat": "Savaş",
"smithing": "Demircilik", "alchemy": "Simya", "cooking": "Aşçılık",
"construction": "İnşaat",
}
def group_for(code: str) -> str:
for prefix, g in GROUPS:
if code.startswith(prefix):
return g
return "Diğer"
def family_for(code: str) -> str:
return FAMILY.get(code.rsplit("_", 1)[-1], "Genel")
async def main():
conn = await asyncpg.connect(DSN)
item_rows = await conn.fetch("SELECT code, name, rarity, type, effect FROM items")
imap = {r["code"]: dict(r) for r in item_rows}
rows = await conn.fetch("""
SELECT r.code, r.name, r.output_qty, r.skill, r.required_level,
r.xp_reward, r.step_cost, r.ingredients, i.code AS out_code
FROM recipes r JOIN items i ON i.id = r.output_item_id
ORDER BY r.required_level, r.name
""")
await conn.close()
out = []
for r in rows:
oi = imap.get(r["out_code"], {})
effect = oi.get("effect")
if isinstance(effect, str):
effect = json.loads(effect)
ings = json.loads(r["ingredients"]) if isinstance(r["ingredients"], str) else r["ingredients"]
out.append({
"code": r["code"],
"name": r["name"],
"group": group_for(r["code"]),
"family": family_for(r["code"]),
"skill": SKILL_TR.get(r["skill"], r["skill"]),
"level": r["required_level"],
"steps": r["step_cost"],
"xp": r["xp_reward"],
"out_qty": r["output_qty"],
"rarity": oi.get("rarity", "common"),
"type": oi.get("type", ""),
"effect": effect,
"ings": [
{"name": imap.get(g["item_code"], {}).get("name", g["item_code"]),
"qty": g["qty"],
"rarity": imap.get(g["item_code"], {}).get("rarity", "common")}
for g in ings
],
})
groups: dict[str, int] = {}
for o in out:
groups[o["group"]] = groups.get(o["group"], 0) + 1
with open(OUT, "w", encoding="utf-8") as f:
json.dump({"recipes": out, "groups": groups, "total": len(out)}, f, ensure_ascii=False)
print(f"{OUT}: {len(out)} tarif, gruplar: {groups}")
asyncio.run(main())

109
db/export_items_json.py Normal file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Stepstead eşya veritabanı JSON üretici.
items + item_classes + location_resources + recipes items.json
Her eşya için: tür, rarity, yuva, sınıf kullanım kısıtı, kaynak (nereden toplanır/üretilir).
Çalıştırma: /opt/stepstead/venv/bin/python3 /opt/stepstead/db/export_items_json.py
"""
import asyncio
import json
import sys
import asyncpg
sys.path.insert(0, "/opt/stepstead")
from config import settings
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
OUT = "/var/www/stepstead.com/items/data/items.json"
TYPE_TR = {
"material": "Ham Madde", "tool": "Alet", "gear": "Ekipman",
"consumable": "Tüketilebilir", "furniture": "Mobilya",
}
SLOT_TR = {
"weapon": "Silah", "shield": "Kalkan", "chest": "Gövde", "head": "Baş",
"hands": "Eldiven", "feet": "Ayak", "waist": "Bel", "back": "Sırt",
"neck": "Boyun", "finger": "Yüzük", "wrist": "Bilek", "tool": "Alet",
}
SKILL_TR = {
"mining": "Madencilik", "fishing": "Balıılık", "foraging": "Toplama",
"woodcutting": "Ağaç Kesme", "hunting": "Avcılık", "combat": "Savaş",
"smithing": "Demircilik", "alchemy": "Simya", "cooking": "Aşçılık",
"construction": "İnşaat",
}
async def main():
conn = await asyncpg.connect(DSN)
items = await conn.fetch("""
SELECT id, code, name, type, rarity, equip_slot, two_handed,
base_value, effect, description
FROM items ORDER BY type, rarity, name
""")
# sınıf kullanım kısıtları
ic = await conn.fetch("""
SELECT ic.item_id, c.name AS class_name, c.sort_order
FROM item_classes ic JOIN game_classes c ON c.id = ic.class_id
ORDER BY c.sort_order, c.name
""")
classes_by_item: dict[int, list[str]] = {}
for r in ic:
classes_by_item.setdefault(r["item_id"], []).append(r["class_name"])
all_classes = [dict(r) for r in await conn.fetch(
"SELECT name FROM game_classes ORDER BY sort_order, name")]
all_class_names = [c["name"] for c in all_classes]
# kaynak: nereden toplanır (bölge işaretleme)
gathers = await conn.fetch("""
SELECT lr.item_code, l.name AS loc, lr.skill
FROM location_resources lr JOIN locations l ON l.id = lr.location_id
ORDER BY l.name
""")
gather_by_code: dict[str, list[dict]] = {}
for r in gathers:
gather_by_code.setdefault(r["item_code"], []).append(
{"loc": r["loc"], "skill": SKILL_TR.get(r["skill"], r["skill"])})
# kaynak: üretilebilir mi
craftable = set(r["code"] for r in await conn.fetch("""
SELECT i.code FROM recipes r JOIN items i ON i.id = r.output_item_id
"""))
await conn.close()
out = []
for r in items:
eff = r["effect"]
if isinstance(eff, str):
eff = json.loads(eff)
cls = classes_by_item.get(r["id"], [])
# kayıt yoksa ve takılabilir bir eşyaysa: herkes; değilse boş
equip = r["equip_slot"]
out.append({
"code": r["code"],
"name": r["name"],
"type": TYPE_TR.get(r["type"], r["type"]),
"type_code": r["type"],
"rarity": r["rarity"],
"slot": SLOT_TR.get(equip) if equip else None,
"slot_code": equip,
"two_handed": r["two_handed"],
"value": r["base_value"],
"effect": eff,
"desc": r["description"],
"classes": cls, # boş = tüm sınıflar
"gather": gather_by_code.get(r["code"], []),
"craftable": r["code"] in craftable,
})
types: dict[str, int] = {}
for o in out:
types[o["type"]] = types.get(o["type"], 0) + 1
with open(OUT, "w", encoding="utf-8") as f:
json.dump({"items": out, "types": types, "classes": all_class_names,
"total": len(out)}, f, ensure_ascii=False)
print(f"{OUT}: {len(out)} eşya, türler: {types}")
asyncio.run(main())

196
db/init_schema.sql Normal file
View file

@ -0,0 +1,196 @@
-- stepstead.com — initial schema (MVP)
-- Idempotent: safe to re-run.
BEGIN;
-- =========================================================================
-- updated_at trigger
-- =========================================================================
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- =========================================================================
-- 1. AUTH / KULLANICI
-- =========================================================================
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64),
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
email_verify_token VARCHAR(128),
email_verify_sent_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_email_verify_token ON users(email_verify_token)
WHERE email_verify_token IS NOT NULL;
DROP TRIGGER IF EXISTS trg_users_updated_at ON users;
CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pwreset_user ON password_reset_tokens(user_id);
-- =========================================================================
-- 2. KARAKTER
-- =========================================================================
CREATE TABLE IF NOT EXISTS characters (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(32) NOT NULL UNIQUE,
level INTEGER NOT NULL DEFAULT 1 CHECK (level BETWEEN 1 AND 50),
total_xp BIGINT NOT NULL DEFAULT 0,
gold BIGINT NOT NULL DEFAULT 0,
step_balance BIGINT NOT NULL DEFAULT 0,
step_lifetime BIGINT NOT NULL DEFAULT 0,
pos_chunk_x INTEGER NOT NULL DEFAULT 0,
pos_chunk_y INTEGER NOT NULL DEFAULT 0,
pos_x INTEGER NOT NULL DEFAULT 0,
pos_y INTEGER NOT NULL DEFAULT 0,
last_sync_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id);
DROP TRIGGER IF EXISTS trg_characters_updated_at ON characters;
CREATE TRIGGER trg_characters_updated_at BEFORE UPDATE ON characters
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =========================================================================
-- 3. SKILL (hibrit: her skill ayrı XP, karakter level = toplam)
-- =========================================================================
CREATE TABLE IF NOT EXISTS skills (
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
skill_name VARCHAR(32) NOT NULL,
xp BIGINT NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1 CHECK (level BETWEEN 1 AND 50),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (character_id, skill_name)
);
DROP TRIGGER IF EXISTS trg_skills_updated_at ON skills;
CREATE TRIGGER trg_skills_updated_at BEFORE UPDATE ON skills
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =========================================================================
-- 4. ITEM CATALOG + ENVANTER
-- =========================================================================
CREATE TABLE IF NOT EXISTS items (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
type VARCHAR(32) NOT NULL,
rarity VARCHAR(16) NOT NULL DEFAULT 'common',
stack_max INTEGER NOT NULL DEFAULT 99,
icon VARCHAR(128),
description TEXT
);
CREATE TABLE IF NOT EXISTS inventory (
id BIGSERIAL PRIMARY KEY,
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
item_id BIGINT NOT NULL REFERENCES items(id),
qty INTEGER NOT NULL DEFAULT 1 CHECK (qty > 0),
slot INTEGER NOT NULL,
UNIQUE (character_id, slot)
);
CREATE INDEX IF NOT EXISTS idx_inventory_char ON inventory(character_id);
-- =========================================================================
-- 5. HARITA (chunk + node)
-- =========================================================================
CREATE TABLE IF NOT EXISTS map_chunks (
chunk_x INTEGER NOT NULL,
chunk_y INTEGER NOT NULL,
biome VARCHAR(32) NOT NULL,
tile_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (chunk_x, chunk_y)
);
CREATE TABLE IF NOT EXISTS node_spawns (
id BIGSERIAL PRIMARY KEY,
chunk_x INTEGER NOT NULL,
chunk_y INTEGER NOT NULL,
x INTEGER NOT NULL,
y INTEGER NOT NULL,
node_type VARCHAR(32) NOT NULL,
item_id BIGINT REFERENCES items(id),
skill_name VARCHAR(32) NOT NULL,
required_level INTEGER NOT NULL DEFAULT 1,
step_cost INTEGER NOT NULL DEFAULT 10,
xp_reward INTEGER NOT NULL DEFAULT 10,
respawn_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_node_chunk ON node_spawns(chunk_x, chunk_y) WHERE is_active;
-- =========================================================================
-- 6. ADIM LOG (anti-cheat audit)
-- =========================================================================
CREATE TABLE IF NOT EXISTS step_log (
id BIGSERIAL PRIMARY KEY,
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
steps_delta INTEGER NOT NULL,
source VARCHAR(32) NOT NULL,
accepted BOOLEAN NOT NULL DEFAULT TRUE,
reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_step_log_char_ts ON step_log(character_id, ts DESC);
-- =========================================================================
-- 7. PAZAR + TAKAS
-- =========================================================================
CREATE TABLE IF NOT EXISTS market_listings (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
item_id BIGINT NOT NULL REFERENCES items(id),
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price BIGINT NOT NULL CHECK (unit_price > 0),
status VARCHAR(16) NOT NULL DEFAULT 'open',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
closed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_market_open ON market_listings(item_id, unit_price) WHERE status='open';
CREATE TABLE IF NOT EXISTS trade_logs (
id BIGSERIAL PRIMARY KEY,
seller_id BIGINT NOT NULL REFERENCES characters(id),
buyer_id BIGINT NOT NULL REFERENCES characters(id),
item_id BIGINT NOT NULL REFERENCES items(id),
qty INTEGER NOT NULL,
unit_price BIGINT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- =========================================================================
-- 8. SOHBET
-- =========================================================================
CREATE TABLE IF NOT EXISTS chat_messages (
id BIGSERIAL PRIMARY KEY,
channel VARCHAR(32) NOT NULL,
sender_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
text TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_chat_channel_ts ON chat_messages(channel, ts DESC);
COMMIT;

View file

@ -0,0 +1,33 @@
-- 2026-07-04: Market/vendor ekonomisi + lokasyona özel olaylar + günlük hedef/streak
-- İdempotent — tekrar çalıştırılabilir.
-- Vendor fiyatlandırması: rarity bazlı taban değer (gold)
ALTER TABLE items ADD COLUMN IF NOT EXISTS base_value INTEGER NOT NULL DEFAULT 5;
UPDATE items SET base_value = CASE rarity
WHEN 'common' THEN 5
WHEN 'uncommon' THEN 15
WHEN 'rare' THEN 40
WHEN 'epic' THEN 120
WHEN 'legendary' THEN 400
ELSE 5 END;
-- Tool'lar hammaddeden değerli olsun
UPDATE items SET base_value = base_value * 3 WHERE type = 'tool';
-- Olaylar lokasyona hedeflenebilir (NULL = her yerde çıkabilir).
-- Eşleşme: seyahatin VARIŞ lokasyon kodu listede ise.
ALTER TABLE events ADD COLUMN IF NOT EXISTS location_codes TEXT[];
-- Günlük hedef + streak
ALTER TABLE characters ADD COLUMN IF NOT EXISTS daily_steps BIGINT NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN IF NOT EXISTS daily_date DATE;
ALTER TABLE characters ADD COLUMN IF NOT EXISTS streak_days INTEGER NOT NULL DEFAULT 0;
ALTER TABLE characters ADD COLUMN IF NOT EXISTS last_claim_date DATE;
-- Kamp/construction: karakter başına lokasyonda bir kamp (gather adım maliyeti -%25)
CREATE TABLE IF NOT EXISTS camps (
id BIGSERIAL PRIMARY KEY,
character_id BIGINT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
location_id BIGINT NOT NULL REFERENCES locations(id),
built_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(character_id, location_id)
);

View file

@ -0,0 +1,25 @@
-- Rol tabanlı yetkilendirme (2026-07-07)
-- İzin kodları: upload, lore, map_view, map_edit, users ("*" = hepsi)
BEGIN;
CREATE TABLE IF NOT EXISTS roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(40) UNIQUE NOT NULL,
permissions JSONB NOT NULL DEFAULT '[]',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO roles(name, permissions, is_system) VALUES
('admin', '["*"]', TRUE),
('design', '["upload"]', FALSE),
('loremaster', '["lore","map_view"]', FALSE)
ON CONFLICT (name) DO NOTHING;
ALTER TABLE users ADD COLUMN IF NOT EXISTS role_id BIGINT REFERENCES roles(id) ON DELETE SET NULL;
-- Mevcut admin hesapları (ADMIN_EMAILS ile aynı) admin rolüne bağla
UPDATE users SET role_id = (SELECT id FROM roles WHERE name='admin')
WHERE email IN ('jts@jts.rocks','afm@jts.rocks') AND role_id IS NULL;
COMMIT;

View file

@ -0,0 +1,17 @@
-- Stepstead — Ekipman yuvası + sınıf kullanım kısıtı (2026-07-08)
-- items.equip_slot : eşya hangi yuvaya takılır (weapon/shield/head/chest/hands/feet/waist/back/neck/finger/wrist/tool/none)
-- items.two_handed : çift el silah (yay, asa) — yan-el (kalkan) engeller
-- item_classes : eşyayı KULLANABİLEN sınıflar. Kayıt YOKSA = tüm sınıflar kullanabilir (tool, mücevher, deri, tüketilebilir).
BEGIN;
ALTER TABLE items ADD COLUMN IF NOT EXISTS equip_slot varchar(16);
ALTER TABLE items ADD COLUMN IF NOT EXISTS two_handed boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS item_classes (
item_id bigint NOT NULL REFERENCES items(id) ON DELETE CASCADE,
class_id int NOT NULL REFERENCES game_classes(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, class_id)
);
CREATE INDEX IF NOT EXISTS idx_item_classes_class ON item_classes(class_id);
COMMIT;

View file

@ -0,0 +1,24 @@
-- Vendor NPC'leri şehirlere (locations) bağla + oyuncu konsinye stoğu (24s ömürlü).
-- 1) NPC → konum bağı
ALTER TABLE npcs ADD COLUMN IF NOT EXISTS location_id BIGINT REFERENCES locations(id) ON DELETE SET NULL;
-- 7 vendor'ı 3 şehre dağıt (koy=1, pazar=2, liman=3)
UPDATE npcs SET location_id=1 WHERE id IN (4,5,6); -- Köy: Demirci Baldor, Şifacı Elda, Oduncu Corvin
UPDATE npcs SET location_id=2 WHERE id IN (7,9); -- Pazarköy: Demirci Torin, Şifacı Mira
UPDATE npcs SET location_id=3 WHERE id IN (8,10); -- Liman: Balıı Neyla, Demirci Grum
-- 2) Konsinye stok: oyuncuların vendor'a sattığı ürünler, herkese satılık, 24s ömürlü.
-- Vendor'ın sabit stoğu = npc_items (kalıcı). Bu tablo geçici oyuncu malları.
CREATE TABLE IF NOT EXISTS vendor_stock (
id BIGSERIAL PRIMARY KEY,
npc_id BIGINT NOT NULL REFERENCES npcs(id) ON DELETE CASCADE,
item_id BIGINT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
qty INTEGER NOT NULL CHECK (qty > 0),
unit_price INTEGER NOT NULL, -- alıcının ödeyeceği gold (vendor fiyatı)
expires_at TIMESTAMPTZ NOT NULL, -- bu andan sonra kaybolur (satışta yenilenir)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (npc_id, item_id)
);
CREATE INDEX IF NOT EXISTS vendor_stock_npc_idx ON vendor_stock(npc_id);
CREATE INDEX IF NOT EXISTS vendor_stock_exp_idx ON vendor_stock(expires_at);

View file

@ -0,0 +1,3 @@
-- Yol geometrisi: iki lokasyon arası kıvrımlı hat için ara noktalar.
-- waypoints = normalize (0-1) [[x,y],...] — uç noktalar (loc_a, loc_b) hariç.
ALTER TABLE roads ADD COLUMN IF NOT EXISTS waypoints JSONB NOT NULL DEFAULT '[]'::jsonb;

244
db/seed_craftables.py Normal file
View file

@ -0,0 +1,244 @@
"""
250 örnek craftable üretir: item + tarif. Idempotent (code üzerinden ON CONFLICT DO NOTHING).
Gruplar: Kerestecilik, Marangozluk, Okçuluk, Metalurji, Demircilik, Kuyumculuk,
Simya, Aşçılık, Dericilik, Balıılık.
Çalıştırma: cd /opt/stepstead && ./venv/bin/python3 db/seed_craftables.py
"""
import asyncio
import json
import sys
import asyncpg
sys.path.insert(0, "/opt/stepstead")
from config import settings
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
# (kod, Türkçe ad, tier)
WOODS = [
("oak", "Meşe", 1), ("pine", "Çam", 2), ("birch", "Huş", 3), ("beech", "Kayın", 4),
("maple", "Akçaağaç", 5), ("willow", "Söğüt", 6), ("ash", "Dişbudak", 7), ("cedar", "Sedir", 8),
("walnut", "Ceviz", 9), ("cherry", "Kiraz", 10), ("yew", "Porsuk", 11),
("mahogany", "Maun", 12), ("ebony", "Abanoz", 13),
]
METALS = [
("copper", "Bakır", 1), ("bronze", "Bronz", 2), ("iron", "Demir", 3),
("steel", "Çelik", 4), ("silver", "Gümüş", 5), ("gold", "Altın", 6),
]
HERBS = [
("mint", "Nane", 1), ("sage", "Adaçayı", 2), ("thyme", "Kekik", 3), ("chamomile", "Papatya", 4),
("rosemary", "Biberiye", 5), ("basil", "Fesleğen", 6), ("nettle", "Isırgan", 7),
("lemonbalm", "Melisa", 8), ("fennel", "Rezene", 9), ("lavender", "Lavanta", 10),
("ginseng", "Ginseng", 11), ("saffron", "Safran", 12),
]
FISH = [
("carp", "Sazan", 1), ("perch", "Levrek", 2), ("anchovy", "Hamsi", 3), ("mullet", "Kefal", 4),
("mackerel", "Uskumru", 5), ("bonito", "Palamut", 6), ("bluefish", "Lüfer", 7),
("bream", "Çipura", 8), ("trout", "Alabalık", 9), ("pike", "Turna", 10),
("catfish", "Yayın", 11), ("salmon", "Somon", 12), ("turbot", "Kalkan", 13),
("tuna", "Ton Balığı", 14), ("sturgeon", "Mersin", 15),
]
ANIMALS = [
("rabbit", "Tavşan", 1), ("deer", "Geyik", 2), ("boar", "Yaban Domuzu", 3),
("wolf", "Kurt", 4), ("bear", "Ayı", 5),
]
FURNITURE = [("chair", "Sandalye", 3), ("table", "Masa", 4), ("bed", "Yatak", 6),
("cabinet", "Dolap", 5), ("door", "Kapı", 4), ("chest", "Sandık", 5)] # (kod, ad, kereste)
WEAPONS = [("axe", "Balta"), ("pickaxe", "Kazma"), ("sword", "Kılıç"),
("dagger", "Hançer"), ("shield", "Kalkan"), ("armor", "Zırh")]
JEWELRY = [("ring", "Yüzük"), ("necklace", "Kolye"), ("bracelet", "Bilezik")]
LEATHERGOODS = [("bag", "Çanta"), ("belt", "Kemer"), ("boots", "Bot"), ("gloves", "Eldiven")]
def rarity_for(tier: int, max_tier: int) -> str:
r = tier / max_tier
if r <= 0.25:
return "common"
if r <= 0.5:
return "uncommon"
if r <= 0.75:
return "rare"
if r < 1.0:
return "epic"
return "legendary"
items: list[dict] = [] # {code,name,type,rarity,stack_max,description,effect}
recipes: list[dict] = [] # {code,name,out,out_qty,skill,level,xp,steps,ings}
def add_item(code, name, typ, rarity="common", stack=99, desc=None, effect=None):
items.append({"code": code, "name": name, "type": typ, "rarity": rarity,
"stack": stack, "desc": desc, "effect": effect})
def add_recipe(code, name, out, skill, level, xp, steps, ings, out_qty=1):
recipes.append({"code": code, "name": name, "out": out, "out_qty": out_qty,
"skill": skill, "level": min(level, 50), "xp": xp, "steps": steps,
"ings": [{"item_code": c, "qty": q} for c, q in ings]})
def build():
# --- Ham maddeler ---
for w, tr, t in WOODS:
add_item(f"log_{w}", f"{tr} Odunu", "material", rarity_for(t, 13))
for m, tr, t in METALS:
if m in ("bronze", "steel"):
pass # cevheri yok, alaşım
else:
add_item(f"ore_{m}", f"{tr} Cevheri", "material", rarity_for(t, 6))
add_item(f"ingot_{m}", f"{tr} Külçe", "material", rarity_for(t, 6))
add_item("ore_tin", "Kalay Cevheri", "material", "common")
add_item("coal", "Kömür", "material", "common")
for h, tr, t in HERBS:
add_item(f"herb_{h}", f"{tr}", "material", rarity_for(t, 12))
for f, tr, t in FISH:
add_item(f"fish_{f}", f"{tr}", "material", rarity_for(t, 15))
for a, tr, t in ANIMALS:
add_item(f"leather_{a}", f"{tr} Derisi", "material", rarity_for(t, 5))
add_item(f"meat_{a}", f"{tr} Eti", "material", rarity_for(t, 5))
# --- 1) Kerestecilik: 13 kereste ---
for w, tr, t in WOODS:
add_item(f"plank_{w}", f"{tr} Kereste", "material", rarity_for(t, 13))
add_recipe(f"saw_{w}", f"{tr} Kereste", f"plank_{w}", "woodcutting",
max(1, t * 3 - 2), 4 + 2 * t, 2 + t, [(f"log_{w}", 2)], out_qty=2)
# --- 2) Marangozluk: 13 odun × 6 mobilya = 78 ---
for w, wtr, t in WOODS:
for fc, ftr, planks in FURNITURE:
add_item(f"{fc}_{w}", f"{wtr} {ftr}", "furniture", rarity_for(t, 13), stack=10)
add_recipe(f"build_{fc}_{w}", f"{wtr} {ftr}", f"{fc}_{w}", "construction",
max(1, t * 3 - 2), 8 + 3 * t, 4 + t + planks, [(f"plank_{w}", planks)])
# --- 3) Okçuluk: 13 yay + 13 ok = 26 ---
for w, wtr, t in WOODS:
add_item(f"bow_{w}", f"{wtr} Yay", "tool", rarity_for(t, 13), stack=1)
add_recipe(f"craft_bow_{w}", f"{wtr} Yay", f"bow_{w}", "combat",
max(1, t * 3 - 2), 10 + 3 * t, 6 + t, [(f"plank_{w}", 2), ("vine", 1)])
add_item(f"arrow_{w}", f"{wtr} Ok", "material", rarity_for(t, 13))
add_recipe(f"craft_arrow_{w}", f"{wtr} Ok (10)", f"arrow_{w}", "combat",
max(1, t * 3 - 2), 5 + 2 * t, 3 + t, [(f"plank_{w}", 1), ("stone_small", 1)], out_qty=10)
# --- 4) Metalurji: 4 yeni külçe (bakır+demir eritme zaten var) ---
add_recipe("smelt_bronze", "Bronz Külçe", "ingot_bronze", "smithing", 4, 12, 5,
[("ore_copper", 1), ("ore_tin", 1)])
add_recipe("smelt_steel", "Çelik Külçe", "ingot_steel", "smithing", 10, 18, 6,
[("ore_iron", 2), ("coal", 1)])
add_recipe("smelt_silver", "Gümüş Külçe", "ingot_silver", "smithing", 15, 22, 7, [("ore_silver", 2)])
add_recipe("smelt_gold", "Altın Külçe", "ingot_gold", "smithing", 20, 28, 8, [("ore_gold", 2)])
# --- 5) Demircilik: 6 metal × 6 = 36, bakır balta+kazma mevcut → 34 ---
for m, mtr, t in METALS:
for wc, wtr in WEAPONS:
if m == "copper" and wc in ("axe", "pickaxe"):
continue # craft_axe_cu / craft_pick_cu zaten var
code = f"{wc}_{m}"
typ = "tool" if wc in ("axe", "pickaxe") else "gear"
add_item(code, f"{mtr} {wtr}", typ, rarity_for(t, 6), stack=1)
n_ingot = 3 if wc in ("shield", "armor") else 2
add_recipe(f"forge_{wc}_{m}", f"{mtr} {wtr}", code, "smithing",
max(1, t * 5 - 3), 10 + 5 * t, 6 + 2 * t,
[(f"ingot_{m}", n_ingot), ("log_oak", 1)])
# --- 6) Kuyumculuk: gümüş+altın × 3 takı = 6 ---
for m, mtr, t in [("silver", "Gümüş", 5), ("gold", "Altın", 6)]:
for jc, jtr in JEWELRY:
add_item(f"{jc}_{m}", f"{mtr} {jtr}", "gear", rarity_for(t, 6), stack=1)
add_recipe(f"jewel_{jc}_{m}", f"{mtr} {jtr}", f"{jc}_{m}", "smithing",
t * 5, 15 + 4 * t, 8, [(f"ingot_{m}", 1)])
# --- 7) Simya: 12 ot × 3 = 36, nane çayı mevcut → 35 ---
for h, htr, t in HERBS:
if h != "mint": # brew_tea_mint zaten var
add_item(f"tea_{h}", f"{htr} Çayı", "consumable", rarity_for(t, 12),
desc=f"Adım bakiyesini +{40 + 5 * t} yeniler.",
effect={"type": "steps", "amount": 40 + 5 * t})
add_recipe(f"brew_tea_{h}", f"{htr} Çayı", f"tea_{h}", "alchemy",
max(1, t * 4 - 3), 6 + 2 * t, 4, [(f"herb_{h}", 2)])
add_item(f"potion_{h}", f"{htr} İksiri", "consumable", rarity_for(t, 12),
desc=f"Adım bakiyesini +{80 + 10 * t} yeniler.",
effect={"type": "steps", "amount": 80 + 10 * t})
add_recipe(f"brew_potion_{h}", f"{htr} İksiri", f"potion_{h}", "alchemy",
min(50, t * 4), 12 + 3 * t, 6, [(f"herb_{h}", 3), ("vine", 1)])
add_item(f"salve_{h}", f"{htr} Merhemi", "consumable", rarity_for(t, 12),
desc="İyileştirici merhem (gelecek özellik).")
add_recipe(f"brew_salve_{h}", f"{htr} Merhemi", f"salve_{h}", "alchemy",
min(50, t * 4 + 1), 10 + 3 * t, 5, [(f"herb_{h}", 2), ("stone_small", 1)])
# --- 8) Aşçılık: 14 balık (sazan mevcut) + 5 et × 3 = 29 ---
for f, ftr, t in FISH:
if f == "carp":
continue # cook_carp zaten var
add_item(f"cooked_{f}", f"Izgara {ftr}", "consumable", rarity_for(t, 15),
desc=f"Adım bakiyesini +{25 + 3 * t} yeniler.",
effect={"type": "steps", "amount": 25 + 3 * t})
add_recipe(f"cook_{f}", f"Izgara {ftr}", f"cooked_{f}", "cooking",
max(1, t * 3 - 2), 6 + 2 * t, 4, [(f"fish_{f}", 1)])
for a, atr, t in ANIMALS:
for mc, mtr, bonus in [("grilled", "Izgara", 30), ("stew", "Güveç", 50), ("dried", "Kurutulmuş", 40)]:
add_item(f"{mc}_{a}", f"{mtr} {atr} Eti", "consumable", rarity_for(t, 5),
desc=f"Adım bakiyesini +{bonus + 8 * t} yeniler.",
effect={"type": "steps", "amount": bonus + 8 * t})
ings = [(f"meat_{a}", 1)] if mc != "stew" else [(f"meat_{a}", 1), ("herb_mint", 1)]
add_recipe(f"cook_{mc}_{a}", f"{mtr} {atr} Eti", f"{mc}_{a}", "cooking",
min(50, t * 6), 8 + 4 * t, 5, ings)
# --- 9) Dericilik: 5 deri × 4 = 20 ---
for a, atr, t in ANIMALS:
for lc, ltr in LEATHERGOODS:
add_item(f"{lc}_{a}", f"{atr} Derisi {ltr}", "gear", rarity_for(t, 5), stack=1)
add_recipe(f"tailor_{lc}_{a}", f"{atr} Derisi {ltr}", f"{lc}_{a}", "hunting",
min(50, t * 7 - 4), 10 + 5 * t, 6 + t, [(f"leather_{a}", 2), ("vine", 1)])
# --- 10) Balıılık: 2 gelişmiş olta ---
add_item("fishing_rod_fine", "Usta Oltası", "tool", "rare", stack=1)
add_recipe("craft_rod_fine", "Usta Oltası", "fishing_rod_fine", "fishing", 10, 25, 8,
[("plank_willow", 2), ("vine", 2)])
add_item("fishing_rod_master", "Efsane Olta", "tool", "epic", stack=1)
add_recipe("craft_rod_master", "Efsane Olta", "fishing_rod_master", "fishing", 25, 50, 12,
[("plank_yew", 2), ("vine", 3), ("ingot_silver", 1)])
# --- 11) İnşaat ekstra: 3 ---
add_item("campfire", "Kamp Ateşi", "furniture", "common", stack=10)
add_recipe("build_campfire", "Kamp Ateşi", "campfire", "construction", 1, 8, 5,
[("stick", 3), ("stone_small", 2)])
add_item("fence", "Çit", "furniture", "common", stack=50)
add_recipe("build_fence", "Çit", "fence", "construction", 2, 6, 4, [("plank_pine", 1), ("vine", 1)])
add_item("sign", "Tabela", "furniture", "common", stack=20)
add_recipe("build_sign", "Tabela", "sign", "construction", 1, 5, 3, [("plank_oak", 1), ("stick", 1)])
async def main():
build()
print(f"Üretilen: {len(recipes)} tarif, {len(items)} item tanımı")
assert len(recipes) == 250, f"250 bekleniyor, {len(recipes)} üretildi"
conn = await asyncpg.connect(DSN)
ins_i = 0
for it in items:
r = await conn.execute("""
INSERT INTO items (code, name, type, rarity, stack_max, description, effect)
VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (code) DO NOTHING
""", it["code"], it["name"], it["type"], it["rarity"], it["stack"],
it["desc"], json.dumps(it["effect"]) if it["effect"] else None)
ins_i += int(r.endswith("1"))
ins_r = 0
for rc in recipes:
r = await conn.execute("""
INSERT INTO recipes (code, name, output_item_id, output_qty, skill, required_level,
xp_reward, step_cost, ingredients)
SELECT $1,$2,i.id,$3,$4,$5,$6,$7,$8::jsonb FROM items i WHERE i.code=$9
ON CONFLICT (code) DO NOTHING
""", rc["code"], rc["name"], rc["out_qty"], rc["skill"], rc["level"],
rc["xp"], rc["steps"], json.dumps(rc["ings"]), rc["out"])
ins_r += int(r.endswith("1"))
ti = await conn.fetchval("SELECT count(*) FROM items")
tr = await conn.fetchval("SELECT count(*) FROM recipes")
await conn.close()
print(f"DB'ye eklendi: {ins_i} item, {ins_r} tarif. Toplam: {ti} item, {tr} tarif.")
asyncio.run(main())

166
db/seed_enemies_forest.py Normal file
View file

@ -0,0 +1,166 @@
"""
Orman düşmanları 12 çeşit hostile NPC (2026-07-08).
NPC sayfasının yazdığı `npcs` tablosuna eklenir (stance='hostile').
Yer = forest region'larına (map_tags kind='region' category='forest', O-1..O-10)
round-robin dağıtılır. İsim'e göre idempotent (varsa günceller, yoksa ekler).
Çalıştırma:
cd /opt/stepstead && ./venv/bin/python3 db/seed_enemies_forest.py
"""
import asyncio
import sys
sys.path.insert(0, "/opt/stepstead")
import asyncpg # noqa: E402
from config import settings # noqa: E402
def desc(hp, dmg, atk, dc, weak, drops, danger, lore):
"""Özellikleri okunur bir description bloğuna paketle."""
return (
f"{lore}\n\n"
f"⚔ Saldırı türü: {atk}\n"
f"❤ Can (HP): {hp}\n"
f"🗡 Hasar: {dmg}\n"
f"🎯 Savaş zorluğu (combat DC): {dc}\n"
f"💀 Tehlike: {danger}\n"
f"🩹 Zayıflık: {weak}\n"
f"🎁 Düşürdükleri: {drops}"
)
# (name, role, stance, description)
ENEMIES = [
("Mana Solucanı", "Büyülü Yaratık", "hostile",
desc(hp=18, dmg="4-8 (mana yakımı)", atk="Büyü — mana emer",
dc=13, weak="Fiziksel darbe, demir silah",
drops="Mana özü ×1-2, parlak salgı",
danger="Orta",
lore="Yeraltı mana damarlarında beslenen, ışıldayan segmentli bir solucan. "
"Yaklaşana enerjisini emer; öldürüldüğünde içindeki saf mana damlar.")),
("Aç Kurt", "Canavar", "hostile",
desc(hp=22, dmg="6-10 (ısırık)", atk="Yakın dövüş — sürü halinde",
dc=13, weak="Ateş, yüksek zemin",
drops="Kurt eti ×1-2, kurt derisi ×1",
danger="Orta",
lore="Kışın açlıktan gözü dönmüş orman kurdu. Tek başına ürkek ama "
"sürüye katılınca korkusuzca saldırır.")),
("Yaşlı Ağaç Adam", "Bitki Devi", "hostile",
desc(hp=55, dmg="12-18 (dal savurma)", atk="Yakın dövüş — ağır ve yavaş",
dc=16, weak="Ateş, balta (woodcutting)",
drops="Kadim odun ×2-3, canlı kök, meşe palamudu",
danger="Yüksek",
lore="Yüzyıllık bir meşenin uyanmış ruhu. Toprağı sarsan adımlarla yürür; "
"yavaştır ama tek darbesi kemik kırar. Ateşten ölesiye korkar.")),
("Dev Orman Faresi", "Haşere", "hostile",
desc(hp=8, dmg="2-4 (kemirme)", atk="Yakın dövüş — kalabalık",
dc=9, weak="Hemen her şey",
drops="Fare kuyruğu, paslı çöp, bazen 1-3 gold",
danger="Düşük",
lore="İnsan boyunun yarısı kadar, hastalık taşıyan bir kemirgen. Tek başına "
"zararsız ama erzak çuvalını bir gecede boşaltır.")),
("Zehirli Orman Örümceği", "Haşere", "hostile",
desc(hp=16, dmg="5-9 (zehirli ısırık)", atk="Yakın dövüş — zehir + ağ",
dc=14, weak="Ateş, alan silahları",
drops="Örümcek zehri ×1, ipek ×1-2",
danger="Orta",
lore="Karanlık kovuklara dev ağlar geren, avını zehirle felç eden bir örümcek. "
"ı hareketi yavaşlatır, ısırığı damarlarda yanar.")),
("Sarmaşık Cini", "Bitki Ruhu", "hostile",
desc(hp=24, dmg="6-11 (sarma + diken)", atk="Yakın dövüş — kavrayıp sıkar",
dc=14, weak="Ateş, kesici balta",
drops="Canlı sarmaşık ×2, diken tozu, çiçek özü",
danger="Orta",
lore="Küçük ama sinsi bir bitki ruhu. Yaprakların arasına gizlenir, "
"geçeni sarmaşıklarıyla sarıp dikenlerine çeker.")),
("Mantar Spor Yaratığı", "Sporlu Canavar", "hostile",
desc(hp=20, dmg="4-7 (spor bulutu)", atk="Alan — zehirli spor patlaması",
dc=13, weak="Ateş, uzak menzil",
drops="ıldayan mantar ×2, spor tozu, şifa mantarı",
danger="Orta",
lore="Yürüyen dev bir mantar kümesi. Vurulunca zehirli spor bulutu patlatır; "
"yakın dövüşte nefes almak tehlikelidir.")),
("Gölge Kurdu (Alfa)", "Canavar (Elit)", "hostile",
desc(hp=40, dmg="10-16 (gölge pençesi)", atk="Yakın dövüş — hızlı, görünmez sıçrar",
dc=17, weak="ık, gümüş silah",
drops="Gölge postu ×1, alfa dişi, gümüş ore ×1",
danger="Yüksek",
lore="Karanlık ormanın en yaşlı kurt sürüsünün lideri. Gölgelerde erir, "
"arkandan belirir. Işık tuttuğunda bir an donar — tek fırsatın odur.")),
("Orman Haydutu", "Goblin", "hostile",
desc(hp=26, dmg="7-12 (paslı hançer)", atk="Yakın dövüş — pusu + hırsızlık",
dc=14, weak="Cephesel saldırı, gürültü",
drops="Çalıntı kese (5-20 gold), paslı hançer, kuru erzak",
danger="Orta",
lore="Yol kenarında pusu kuran sinsi bir goblin. Önce keseni çalmaya çalışır, "
"başaramazsa hançerine sarılır.")),
("Kanlı Yarasa", "Uçan Haşere", "hostile",
desc(hp=12, dmg="3-6 (kan emme)", atk="Havadan — dalış + kan emer",
dc=11, weak="Ateş, geniş savurma",
drops="Yarasa kanadı ×1-2, kan kesesi",
danger="Düşük",
lore="Mağara ağızlarında ve yaşlı ağaç kovuklarında asılı duran kan emici. "
"Sürü halinde daldığında kanını damla damla çeker.")),
("Bataklık Sülüğü", "Asalak", "hostile",
desc(hp=14, dmg="3-7 (yapışıp emer)", atk="Yakın dövüş — yapışır, sürekli hasar",
dc=12, weak="Tuz, ateş",
drops="Sülük özü, iyileştirici salgı ×1",
danger="Düşük-Orta",
lore="Ormanın nemli bataklık kıyısında saklanan dev sülük. Bacağına yapışır, "
"koparana dek yavaşça kan emer. Salgısı ilginç şekilde şifalıdır.")),
("Lanetli Karga Sürüsü", "Uçan Sürü", "hostile",
desc(hp=30, dmg="5-9 (gaga yağmuru)", atk="Alan — sürü halinde gözlere dalış",
dc=15, weak="Alan silahları, ateş, ses",
drops="Kara tüy ×3, lanetli boncuk, bazen gümüş ore",
danger="Orta-Yüksek",
lore="Tek bir kötü ruhu paylaşan bir karga sürüsü. Ayrı ayrı zayıf ama "
"birlikte gökten kara bir bulut gibi iner, gözleri hedef alır.")),
]
async def main():
dsn = settings.DATABASE_URL.replace("+asyncpg", "")
c = await asyncpg.connect(dsn)
forests = [r["id"] for r in await c.fetch(
"SELECT id FROM map_tags WHERE kind='region' AND category='forest' ORDER BY id"
)]
if not forests:
print("UYARI: forest region bulunamadı, map_tag_id NULL bırakılıyor.")
added = updated = 0
for i, (name, role, stance, description) in enumerate(ENEMIES):
tag = forests[i % len(forests)] if forests else None
existing = await c.fetchval("SELECT id FROM npcs WHERE name=$1", name)
if existing:
await c.execute(
"UPDATE npcs SET role=$2, stance=$3, description=$4, map_tag_id=$5, "
"updated_at=now() WHERE id=$1",
existing, role, stance, description, tag)
updated += 1
else:
await c.execute(
"INSERT INTO npcs(name,role,stance,description,map_tag_id) "
"VALUES($1,$2,$3,$4,$5)",
name, role, stance, description, tag)
added += 1
total = await c.fetchval("SELECT count(*) FROM npcs WHERE stance='hostile'")
print(f"Eklendi: {added}, güncellendi: {updated}. Toplam hostile NPC: {total}")
await c.close()
asyncio.run(main())

134
db/seed_equipment.py Normal file
View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Stepstead Ekipman yuvaları + sınıf kullanım kısıtları + eksik eşyalar (2026-07-08).
Idempotent: tekrar çalıştırılabilir.
Yapar:
1. Caster silahı: 3 Asa (staff) + tarifleri (combat, plank+ingot).
2. Orphan balık fish_mullet'i Fırtına Kıyısı'na kaynak olarak ekler.
3. items.equip_slot + two_handed alanlarını kod önekinden doldurur.
4. item_classes tablosunu doldurur hangi sınıf hangi eşyayı KULLANABİLİR.
(Kayıt yoksa = tüm sınıflar kullanabilir: tool, mücevher, deri, tüketilebilir.)
Çalıştırma: /opt/stepstead/venv/bin/python3 /opt/stepstead/db/seed_equipment.py
"""
import asyncio
import json
import sys
import asyncpg
sys.path.insert(0, "/opt/stepstead")
from config import settings
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
# --- Sınıf id'leri (game_classes) ---
PALADIN, PRIEST, MAGE, HUNTER, WARRIOR, MONK, THIEF, WARLOCK = 2, 3, 4, 5, 6, 7, 8, 9
# --- Yeni caster silahı: Asa (staff) ---
# (code, ad, rarity, req_level, plank_family, cap_ingot, base_value)
STAVES = [
("staff_ash", "Dişbudak Asa", "rare", 20, "plank_ash", "ingot_silver", 130),
("staff_yew", "Porsuk Asa", "epic", 30, "plank_yew", "ingot_gold", 380),
("staff_ebony", "Abanoz Asa", "legendary", 40, "plank_ebony", "ingot_gold", 1250),
]
# --- Ekipman yuvası: kod öneki -> slot ---
SLOT_PREFIX = [
("sword_", "weapon"), ("dagger_", "weapon"), ("bow_", "weapon"), ("staff_", "weapon"),
("shield_", "shield"), ("armor_", "chest"),
("boots_", "feet"), ("gloves_", "hands"), ("belt_", "waist"), ("bag_", "back"),
("necklace_", "neck"), ("ring_", "finger"), ("bracelet_", "wrist"),
("axe_", "tool"), ("pickaxe_", "tool"), ("fishing_rod", "tool"),
]
TWO_HANDED_PREFIX = ("bow_", "staff_")
# --- Sınıf kullanım kısıtı: kod öneki -> izinli sınıf id listesi ---
# Liste boşsa (öneki burada yoksa) eşya herkese açıktır.
CLASS_PREFIX = [
("sword_", [PALADIN, WARRIOR]),
("shield_", [PALADIN, WARRIOR]),
("armor_", [PALADIN, WARRIOR]),
("bow_", [HUNTER]),
("dagger_", [PRIEST, MAGE, HUNTER, MONK, THIEF, WARLOCK]),
("staff_", [PRIEST, MAGE, WARLOCK]),
]
async def upsert_item(conn, code, name, itype, rarity, base_value, description):
return await conn.fetchval("""
INSERT INTO items(code,name,type,rarity,base_value,description)
VALUES($1,$2,$3,$4,$5,$6)
ON CONFLICT(code) DO UPDATE SET name=EXCLUDED.name, type=EXCLUDED.type,
rarity=EXCLUDED.rarity, base_value=EXCLUDED.base_value, description=EXCLUDED.description
RETURNING id
""", code, name, itype, rarity, base_value, description)
async def upsert_recipe(conn, code, name, out_id, skill, lvl, xp, steps, ings):
await conn.execute("""
INSERT INTO recipes(code,name,output_item_id,output_qty,skill,required_level,xp_reward,step_cost,ingredients)
VALUES($1,$2,$3,1,$4,$5,$6,$7,$8::jsonb)
ON CONFLICT(code) DO UPDATE SET name=EXCLUDED.name, output_item_id=EXCLUDED.output_item_id,
skill=EXCLUDED.skill, required_level=EXCLUDED.required_level, xp_reward=EXCLUDED.xp_reward,
step_cost=EXCLUDED.step_cost, ingredients=EXCLUDED.ingredients
""", code, name, out_id, skill, lvl, xp, steps, json.dumps(ings))
async def main():
conn = await asyncpg.connect(DSN)
# 1) Asalar + tarifleri
for code, name, rarity, lvl, plank, ingot, val in STAVES:
iid = await upsert_item(conn, code, name, "gear", rarity, val,
"Büyücü sınıflarının kullandığı çift el asa.")
await upsert_recipe(conn, f"craft_{code}", name, iid, "combat", lvl,
lvl * 4, 12,
[{"qty": 2, "item_code": plank}, {"qty": 1, "item_code": ingot},
{"qty": 1, "item_code": "vine"}])
# 2) Orphan balık -> Fırtına Kıyısı
kiyi = await conn.fetchval("SELECT id FROM locations WHERE code='firtina_kiyisi'")
if kiyi and await conn.fetchval("SELECT 1 FROM items WHERE code='fish_mullet'"):
await conn.execute("""
INSERT INTO location_resources(location_id,item_code,skill,step_cost,xp,cooldown_s)
VALUES($1,'fish_mullet','fishing',6,14,300)
ON CONFLICT(location_id,item_code) DO NOTHING
""", kiyi)
# 3) equip_slot + two_handed
items = await conn.fetch("SELECT id, code FROM items")
for r in items:
code = r["code"]
slot = None
for pref, s in SLOT_PREFIX:
if code.startswith(pref):
slot = s
break
two = any(code.startswith(p) for p in TWO_HANDED_PREFIX)
await conn.execute("UPDATE items SET equip_slot=$1, two_handed=$2 WHERE id=$3",
slot, two, r["id"])
# 4) item_classes — sınıf kullanım kısıtı
await conn.execute("TRUNCATE item_classes")
n = 0
for r in items:
code = r["code"]
for pref, class_ids in CLASS_PREFIX:
if code.startswith(pref):
for cid in class_ids:
await conn.execute(
"INSERT INTO item_classes(item_id,class_id) VALUES($1,$2) ON CONFLICT DO NOTHING",
r["id"], cid)
n += 1
break
slot_counts = await conn.fetch(
"SELECT COALESCE(equip_slot,'-') slot, count(*) c FROM items GROUP BY 1 ORDER BY 2 DESC")
await conn.close()
print(f"Asalar+tarifler eklendi ({len(STAVES)}). item_classes satır: {n}")
print("equip_slot dağılımı:", {r["slot"]: r["c"] for r in slot_counts})
asyncio.run(main())

152
db/seed_events_combat.py Normal file
View file

@ -0,0 +1,152 @@
"""
Lokasyona özel olaylar combat/hunting ırlıklı 8 yeni kart (2026-07-04).
events.location_codes: seyahatin VARIŞ lokasyonu listede ise olay çıkabilir;
NULL = her yerde. Çalıştırma (idempotent):
cd /opt/stepstead && ./venv/bin/python3 db/seed_events_combat.py
"""
import asyncio
import json
import sys
sys.path.insert(0, "/opt/stepstead")
import asyncpg # noqa: E402
from config import settings # noqa: E402
EVENTS = [
{
"code": "kurt_surusu",
"name": "Kurt Sürüsü",
"text": "Alacakaranlıkta parlayan gözler... Bir kurt sürüsü yolunu kesti. Liderleri dişlerini gösteriyor.",
"locations": ["av_topraklari", "karanlik_orman"],
"choices": [
{"text": "⚔ Savaş!", "skill": "combat", "dc": 13,
"success": {"text": "Sürüyü püskürttün, lider kurdu yere serdin.",
"items": [{"code": "meat_wolf", "qty": 2}, {"code": "leather_wolf", "qty": 1}],
"xp": {"skill": "combat", "amount": 60}},
"fail": {"text": "Kurtlar seni kovaladı, kan kaybederek kaçtın.", "steps": -150}},
{"text": "🏃 Kaç", "outcome": {"text": "Uzun bir koşuyla kurtuldun ama yorgun düştün.", "steps": -80}},
],
},
{
"code": "vahsi_domuz",
"name": "Vahşi Domuz",
"text": "Çalılıklar hışırdıyor — kocaman bir yaban domuzu toprağı eşeliyor. Henüz seni fark etmedi.",
"locations": ["av_topraklari", "fisilti_ormani", "akca_koru"],
"choices": [
{"text": "🏹 Sessizce avla", "skill": "hunting", "dc": 12,
"success": {"text": "Tek atışla indirdin. Temiz bir av.",
"items": [{"code": "meat_boar", "qty": 2}, {"code": "leather_boar", "qty": 1}],
"xp": {"skill": "hunting", "amount": 50}},
"fail": {"text": "Dal kırıldı, domuz sana saldırdı! Zor kaçtın.", "steps": -100}},
{"text": "🤫 Rahat bırak", "outcome": {"text": "Domuz homurdanarak uzaklaştı.", "steps": 0}},
],
},
{
"code": "geyik_izi",
"name": "Taze Geyik İzi",
"text": "Toprakta taze geyik izleri var. İzler sık çalılığın içine gidiyor.",
"locations": ["cayirlar", "av_topraklari", "gumus_camlik"],
"choices": [
{"text": "🦌 İzi sür", "skill": "hunting", "dc": 10,
"success": {"text": "İzi sürüp genç bir geyik avladın.",
"items": [{"code": "meat_deer", "qty": 1}, {"code": "leather_deer", "qty": 1}],
"xp": {"skill": "hunting", "amount": 40}},
"fail": {"text": "İzi çalılıkta kaybettin, boşa yürüdün.", "steps": -60}},
{"text": "➡ Yoluna devam et", "outcome": {"text": "Avı başka zamana bıraktın.", "steps": 0}},
],
},
{
"code": "magara_orumcegi",
"name": "Mağara Örümceği",
"text": "Madenin karanlık bir kovuğunda dev bir örümcek ağını germiş. Ağın arkasında parlayan bir cevher damarı var.",
"locations": ["derin_maden", "ejder_dagi", "tas_ocagi"],
"choices": [
{"text": "⚔ Örümceği öldür", "skill": "combat", "dc": 14,
"success": {"text": "Örümceği alt ettin, damardaki cevheri söktün.",
"items": [{"code": "ore_silver", "qty": 2}],
"xp": {"skill": "combat", "amount": 70}},
"fail": {"text": "Ağa dolandın! Kendini zor kurtardın.", "steps": -120}},
{"text": "🕯 Geri çekil", "outcome": {"text": "O gözlerle uğraşmaya değmez. Geri döndün.", "steps": -30}},
],
},
{
"code": "korsan_yagmasi",
"name": "Korsan Artığı",
"text": "Kıyıya vurmuş kırık bir filika... İçinde kilitli küçük bir para sandığı var. Ama iki serseri de sandığı görmüş, sana doğru geliyorlar.",
"locations": ["liman", "firtina_kiyisi"],
"choices": [
{"text": "⚔ Sandık için dövüş", "skill": "combat", "dc": 15,
"success": {"text": "Serserileri kaçırttın. Sandıktan gold çıktı!",
"gold": 40, "xp": {"skill": "combat", "amount": 80}},
"fail": {"text": "İki kişiye gücün yetmedi, dayak yiyip uzaklaştın.", "steps": -180}},
{"text": "🚶 Sandığı onlara bırak", "outcome": {"text": "Belaya bulaşmadın.", "steps": 0}},
],
},
{
"code": "yasli_avci",
"name": "Yaşlı Avcı",
"text": "Kamp ateşinin başında yaşlı bir avcı oturuyor. 'Otur evlat,' diyor, 'sana av hikâyeleri anlatayım.'",
"locations": ["av_topraklari", "cayirlar"],
"choices": [
{"text": "🔥 Otur ve dinle", "outcome": {"text": "Saatlerce iz sürme ve tuzak kurma taktikleri dinledin.",
"xp": {"skill": "hunting", "amount": 45}, "steps": -40}},
{"text": "🙏 Teşekkür edip geç", "outcome": {"text": "Vaktin yoktu, yola devam ettin.", "steps": 0}},
],
},
{
"code": "balikci_aginda",
"name": "Terk Edilmiş Ağ",
"text": "Kıyıda yarısı suda bir balııı duruyor. İçinde hâlâ çırpınan balıklar var ama ağ kayalara dolanmış.",
"locations": ["aynali_gol", "firtina_kiyisi", "liman"],
"choices": [
{"text": "🎣 Ağı çöz", "skill": "fishing", "dc": 11,
"success": {"text": "ı ustaca çözdün, balıklar senin.",
"items": [{"code": "fish_mackerel", "qty": 2}, {"code": "fish_bream", "qty": 1}],
"xp": {"skill": "fishing", "amount": 40}},
"fail": {"text": "Ağ iyice dolandı, üstüne bir de sırılsıklam oldun.", "steps": -70}},
{"text": "🌊 Denize bırak", "outcome": {"text": "Balıkları özgür bıraktın, için rahat.", "steps": 0}},
],
},
{
"code": "ayi_ini",
"name": "Ayı İni",
"text": "Kadim ormanın derinliklerinde bir in... İçeriden horultu geliyor. Girişte yarım kalmış bir bal peteği duruyor.",
"locations": ["kadim_orman", "karanlik_orman"],
"choices": [
{"text": "⚔ İne gir, ayıyla yüzleş", "skill": "combat", "dc": 16,
"success": {"text": "Efsanevi bir dövüştü. Ayıyı devirdin!",
"items": [{"code": "meat_bear", "qty": 2}, {"code": "leather_bear", "qty": 1}],
"xp": {"skill": "combat", "amount": 100}},
"fail": {"text": "Ayı seni köpek gibi kovaladı. Canını zor kurtardın.", "steps": -250}},
{"text": "🍯 Sadece peteği al ve kaç", "skill": "hunting", "dc": 12,
"success": {"text": "Sessizce peteği kapıp uzaklaştın.",
"items": [{"code": "herb_chamomile", "qty": 2}],
"xp": {"skill": "hunting", "amount": 30}},
"fail": {"text": "Ayı uyandı! Peteği bırakıp kaçtın.", "steps": -100}},
{"text": "🚶 Hiç bulaşma", "outcome": {"text": "Uyuyan ayıyı rahat bıraktın.", "steps": 0}},
],
},
]
async def main() -> None:
dsn = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
conn = await asyncpg.connect(dsn)
for ev in EVENTS:
await conn.execute("""
INSERT INTO events (code, name, text, choices, location_codes)
VALUES ($1, $2, $3, $4::jsonb, $5)
ON CONFLICT (code) DO UPDATE
SET name = EXCLUDED.name, text = EXCLUDED.text,
choices = EXCLUDED.choices, location_codes = EXCLUDED.location_codes
""", ev["code"], ev["name"], ev["text"], json.dumps(ev["choices"]), ev["locations"])
total = await conn.fetchval("SELECT count(*) FROM events")
targeted = await conn.fetchval("SELECT count(*) FROM events WHERE location_codes IS NOT NULL")
print(f"OK — toplam {total} olay ({targeted} lokasyona özel)")
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

617
db/seed_quests.py Normal file
View file

@ -0,0 +1,617 @@
#!/usr/bin/env python3
"""
Stepstead Yan görev içeriği (2026-07-08).
WoW / Witcher tarzı ~90 yan görev + görev veren NPC'ler + yeni düşmanlar + görev itemleri.
Yapar (idempotent tekrar çalıştırılabilir):
1. Görev veren NPC'ler (friendly/neutral) → şehir/kasaba/tapınak/maden map_tag'lerine.
2. Yeni düşmanlar (hostile NPC) maden/tapınak/çorak/orman/çayır bölgelerine.
3. Görev itemleri items tablosu. Toplanabilenler location_resources'a (oyun lokasyonu).
Düşmandan düşenler item olarak eklenir (drop düşman ıklamasında yazılı).
4. ~90 yan görev (quests) + adım adım hedefler (quest_objectives).
Seed işareti: quests.author='Görev Tohumu', npcs (giver) description sonuna [seed:quest].
Çalıştırma: cd /opt/stepstead && ./venv/bin/python3 db/seed_quests.py
"""
import asyncio
import sys
sys.path.insert(0, "/opt/stepstead")
import asyncpg # noqa: E402
from config import settings # noqa: E402
SEED_TAG = "Görev Tohumu"
GIVER_MARK = "[seed:quest-giver]"
def edesc(hp, dmg, atk, dc, weak, drops, danger, lore):
return (f"{lore}\n\n⚔ Saldırı türü: {atk}\n❤ Can (HP): {hp}\n🗡 Hasar: {dmg}\n"
f"🎯 Savaş zorluğu (combat DC): {dc}\n💀 Tehlike: {danger}\n"
f"🩹 Zayıflık: {weak}\n🎁 Düşürdükleri: {drops}")
# ─────────────────────────── GÖREV VERENLER ───────────────────────────
# (key, ad, rol, yer_adı[map_tags.name], açıklama)
GIVERS = [
("doran", "Kaptan Doran", "Kale Muhafızı", "Aethelguard Citadel",
"Aethelguard Kalesi'nin sert ama adil muhafız kaptanı. Bölgedeki tehditleri maceracılara havale eder."),
("lysa", "Rahibe Lysa", "Şifacı Rahibe", "Aethelguard Citadel",
"Kale mabedinin şefkatli rahibesi. Hastalara şifalı ot ve kutsal su lazım olur."),
("aldwin", "Tüccar Aldwin", "Tüccar", "Aethelguard Citadel",
"Zengin ama korkak bir tüccar. Kervanları eşkıyalardan çekmediği için sürekli yardım arar."),
("verena", "Simyacı Verena", "Simyacı", "Aethelguard Citadel",
"Meraklı bir simyacı; deneyleri için nadir salgı, zehir ve mantar ister."),
("orin", "Kütüphaneci Orin", "Bilgin", "Aethelguard Citadel",
"Kadim metinlerin peşindeki yaşlı bilgin. Harabelerdeki tabletleri ve tomarları toplatır."),
("bramble", "Hancı Bramble", "Hancı", "Riftwood Crossing",
"Riftwood Geçidi hanının gürültücü sahibi. Müşterileri için et, balık ve dedikodu toplar."),
("kael", "Avcı Ustası Kael", "Avcı Ustası", "Riftwood Crossing",
"Bölgenin en iyi izcisi. Çırak yetiştirir; canavar avı ve deri siparişleri verir."),
("sefa", "Kervan Lideri Sefa", "Kervancı", "Riftwood Crossing",
"Kıtalar arası mal taşıyan cesur kervancı. Yol güvenliği ve teslimat işleri dağıtır."),
("grungni", "Ustabaşı Grungni", "Maden Ustabaşı", "Rivenhaven",
"Rivenhaven madeninin huysuz ustabaşı. Madeni istila eden yaratıklardan ve göçüklerden bıkmış."),
("nadia", "Kâhin Nadia", "Kâhin", "T-1",
"Terk edilmiş tapınağın son kâhini. Huzursuz ölüleri ve bozulan mühürleri dert eder."),
("thariel", "Baş Rahip Thariel", "Baş Rahip", "T-2",
"ık tarikatının baş rahibi. Lanetli sunakları arındıracak ve kutsal emanetleri bulacak eller arar."),
("bram", "Orman Bekçisi Bram", "Korucu", "Whisperwood",
"Whisperwood'un yalnız koruyucusu. Ormanın dengesini bozan her şeyi izler."),
("elindra", "Elf İzci Elindra", "İzci", "Luminara Grove",
"Luminara'nın zarif elf izcisi. Kutsal ağaçları ve nadir çiçekleri korur."),
("morvath", "Münzevi Morvath", "Münzevi Büyücü", "Xylos Prime",
"Ormanın derininde yaşayan tuhaf büyücü. Reagent'ları karşılığında sırlar fısıldar."),
("thessa", "Thessa Mor", "Görev Veren", "Rivenhaven",
"Gizemli görev dağıtıcısı; kimin için çalıştığı belirsiz, ama altını gerçek."),
("garrick", "Nalbant Garrick", "Nalbant", "Riftwood Crossing",
"Kervan atlarına bakan güçlü kuvvetli nalbant. Demir ve kömür sıkıntısı çeker."),
("wystan", "Yaşlı Wystan", "Emekli Şövalye", "Aethelguard Citadel",
"Bir zamanlar ünlü bir şövalye; artık dizleri tutmuyor, işlerini gençlere yaptırıyor."),
("seline", "Balıı Seline", "Balıı Reisi", "Riftwood Crossing",
"Kıyı balıılarının sözü geçen reisi. Fırtına, korsan ve dev balık derdi hiç bitmez."),
]
# ─────────────────────────── YENİ DÜŞMANLAR ───────────────────────────
# (ad, rol, bölge_adı[map_tags.name], desc)
ENEMIES = [
# Maden
("Kobold Madenci", "Kobold", "Rivenhaven",
edesc(20, "5-9 (kazma)", "Yakın dövüş — sürü", 13, "Ateş, dar geçitte kıstırma",
"Kobold kulağı ×1-2, ham bakır, mum", "Orta",
"Madenin karanlık dehlizlerini istila eden küçük, aç gözlü kobold. Parlak her şeyi çalar.")),
("Taş Golemi", "Golem", "Rivenhaven",
edesc(70, "14-20 (yumruk)", "Yakın dövüş — ağır", 17, "Kazma (mining), keskin köşeler",
"Golem çekirdeği ×1, kristal parçası ×2-3", "Yüksek",
"Maden derinliklerinde biriken mananın canlandırdığı taş yığını. Yavaş ama ezici.")),
("Kör Mağara Trolü", "Trol", "M-1",
edesc(90, "16-24 (sopa)", "Yakın dövüş — öfke", 18, "Ateş, ışık (kör eder)",
"Trol derisi ×1-2, kemik sopa, iri diş", "Çok Yüksek",
"Gözleri körelmiş dev bir mağara trolü. Sesi ve kokuyu takip eder; ateşten deliye döner.")),
("Derin Örümcek", "Dev Örümcek", "M-2",
edesc(28, "7-12 (zehir)", "Yakın dövüş — zehirli", 14, "Ateş, geniş alan",
"Örümcek zehir kesesi ×1-2, ipek", "Orta-Yüksek",
"Mağaranın dibinde ağ ören, at büyüklüğünde solgun örümcek. Zehri felç eder.")),
# Tapınak / undead
("Huzursuz İskelet", "Hortlak", "T-1",
edesc(24, "6-10 (paslı kılıç)", "Yakın dövüş", 13, "Kutsal su, kör (blunt) silah",
"İskelet kemiği ×1-2, paslı madeni para", "Orta",
"Mühür bozulunca uyanan eski muhafız iskeletleri. Kutsal su karşısında dağılır.")),
("Lanetli Rahip Hayaleti", "Hayalet (Elit)", "T-2",
edesc(60, "12-18 (mana lanet)", "Büyü — lanet + korku", 17, "Kutsal emanet, ışık büyüsü",
"Hayalet ektoplazması ×1-2, lanetli tomar", "Yüksek",
"Tapınağı ihanetle lekeleyen rahibin huzursuz ruhu. Sunak arındırılmadan yok edilemez.")),
("Taş Bekçi Heykeli", "Muhafız Golem", "T-2",
edesc(80, "15-22 (balyoz kol)", "Yakın dövüş — ağır", 18, "Kazma, çekirdeğini kırmak",
"Kutsal taş ×2, golem çekirdeği ×1", "Yüksek",
"Tapınağı bekleyen kadim heykel. Yabancıyı kutsal alanda görünce canlanır.")),
# Çorak (B-1 wasteland)
("Çorak Hortlağı", "Zombi", "B-1",
edesc(30, "7-11 (pençe)", "Yakın dövüş — hastalık", 14, "Ateş, kafa darbesi",
"Çürük et, kayıp madalyon (nadir)", "Orta",
"Çorak toprakta sürünen, hastalık saçan cesetler. Yavaş ama kalabalık gelir.")),
("Kum Solucanı", "Dev Solucan", "B-1",
edesc(75, "14-20 (yutma)", "Pusu — yer altından", 17, "Titreşimden kaçınmak, ateş",
"Kum solucanı dişi ×1-2, kristalize tuz", "Çok Yüksek",
"Çorak kumun altında bekleyen kör dev solucan. Ayak sesini duyunca patlarcasına fırlar.")),
("Leş Akbabası", "Uçan Yırtıcı", "B-1",
edesc(22, "6-9 (gaga)", "Havadan dalış", 13, "Ok/yay, yüksek zemin",
"Akbaba gagası, tüy demeti", "Orta",
"Çorakta leş kokusunu kilometrelerce öteden alan iri akbaba. Zayıfı seçer.")),
("Gölge İblisi", "İblis (Elit)", "B-1",
edesc(95, "18-26 (kara alev)", "Büyü — kara alev + korku", 19, "Kutsal emanet, ışık",
"İblis boynuzu ×1, kükürt, kara kristal", "Ölümcül",
"Yarıktan sızan alt-düzey iblis. Gölgeden gölgeye atlar, dokunduğunu yakar.")),
# Çayır / genel
("Yaban Domuzu Reisi", "Canavar (Elit)", "Solstice Mire",
edesc(50, "11-16 (azı dişi)", "Hücum — boynuzlama", 15, "Tuzak, mızrak",
"Domuz dişi ×1-2, kalın deri, yaban eti", "Orta-Yüksek",
"Sürünün yaşlı, yaralı ama azgın reisi. Hücumu ağaç deviren güçtedir.")),
("Dağ Ayısı", "Canavar (Elit)", "Emberfall",
edesc(65, "13-19 (pençe)", "Yakın dövüş — pençe+ısırık", 16, "Ateş, mesafe",
"Ayı pençesi ×1-2, kalın post, bal", "Yüksek",
"İninden erken uyanmış huysuz bir bozayı. Toprağını savunurken acımasızdır.")),
("Bataklık Harpisi", "Harpi", "Solstice Mire",
edesc(34, "8-13 (pençe dalış)", "Havadan taciz + çığlık", 15, "Ok/yay, ağ",
"Harpi tüyü ×1-2, çalınmış mücevher", "Orta-Yüksek",
"Bataklık üstünde çığlıklarla uçan yarı kuş yaratık. Parlak eşya için saldırır.")),
("Vahşi Şaman Goblin", "Goblin Büyücü", "Rivenwood",
edesc(40, "9-14 (şok totemi)", "Büyü — totem + şimşek", 15, "Totemini kırmak, hızlı baskın",
"Goblin kulağı ×2, totem parçası, garip iksir", "Orta-Yüksek",
"Goblin çetesini totemleriyle güçlendiren sinsi şaman. Önce totemi düşmeli.")),
("Gulyabani", "Yamyam Yaratık", "Xylos Prime",
edesc(45, "10-15 (tırnak)", "Pusu — karanlıktan", 16, "Ateş meşalesi, ışık",
"Solgun et, mezar tozu, kayıp yüzük", "Yüksek",
"Karanlık ormanda mezarları eşen soluk benizli yamyam. Işıktan tiksinir.")),
("Orman Şeytanı (Leshy)", "Bitki Ruhu (Elit)", "Ironbark Reach",
edesc(85, "15-22 (kök kırbaç)", "Yakın dövüş + köklerle bağlama", 18, "Ateş, gümüş balta",
"Kadim meşe özü ×2, canlı kök, uğur boncuğu", "Çok Yüksek",
"Ormanın öfkeli koruyucu ruhu. Witcher masalındaki gibi kurbanlarını köklerle sarar.")),
("Su Cini (Vodyanoy)", "Su Canavarı", "Aetherbloom",
edesc(48, "10-15 (pençe)", "Sudan pusu", 16, "Kuru zemine çekmek, ateş",
"Pullu deri, inci, sülük salgısı", "Orta-Yüksek",
"Gölet ve derelerde pusuya yatan pullu su cini. Balııları suya çeker.")),
]
# ─────────────────────────── GÖREV İTEMLERİ ───────────────────────────
# type: material (çoğu). gather_loc: oyun lokasyonu code'u (varsa location_resources'a eklenir), None=sadece drop.
# (code, ad, rarity, gather_loc, skill, step, xp, açıklama)
QITEMS = [
# Toplanabilir reagent'lar
("herb_healroot", "Şifalı Kök", "common", "fisilti_ormani", "foraging", 5, 9, "Rahibe Lysa'nın merhemleri için kök."),
("herb_moonflower", "Ay Çiçeği", "uncommon", "cayirlar", "foraging", 6, 12, "Sadece dolunayda açan soluk mavi çiçek."),
("herb_bluecap", "Mavi Mantar", "common", "kadim_orman", "foraging", 5, 9, "Nemli kütüklerde biten mavi şapkalı mantar."),
("mat_wildhoney", "Yaban Balı", "uncommon", "akca_koru", "foraging", 7, 13, "Vahşi arı kovanından çıkarılan koyu bal."),
("mat_crystal", "Kristal Parçası", "uncommon", "derin_maden", "mining", 7, 13, "Maden derinliğinde parıldayan ham kristal."),
("mat_ironslag", "Demir Cürufu", "common", "tas_ocagi", "mining", 5, 9, "Eritme artığı; nalbantın işine yarar."),
("mat_pearlshell", "İnci Kabuğu", "uncommon", "aynali_gol", "fishing", 6, 12, "İçinde bazen inci taşıyan sedefli kabuk."),
("mat_holywater", "Kutsal Su", "rare", "firtina_kiyisi", "fishing", 8, 16, "Kutsanmış kaynaktan alınan berrak su."),
("mat_ancientoak", "Kadim Meşe Özü", "rare", "kadim_orman", "woodcutting", 8, 16, "Yüzyıllık meşenin sızan özü; büyüde güçlüdür."),
("mat_salt", "Kristalize Tuz", "common", "firtina_kiyisi", "foraging", 5, 9, "Kıyı kayalarında biriken tuz kristali."),
# Sadece drop / bulunacak eşyalar
("q_wolf_fang", "Kurt Dişi", "common", None, None, 0, 0, "Kurt sürülerinden toplanan sivri diş."),
("q_spider_venom", "Örümcek Zehir Kesesi", "uncommon", None, None, 0, 0, "Dev örümceklerden alınan zehir kesesi."),
("q_kobold_ear", "Kobold Kulağı", "common", None, None, 0, 0, "Kobold avının kanıtı."),
("q_skeleton_bone", "İskelet Kemiği", "common", None, None, 0, 0, "Huzursuz ölülerden kalan kemik."),
("q_golem_core", "Golem Çekirdeği", "rare", None, None, 0, 0, "Golemi çalıştıran manalı taş çekirdek."),
("q_ectoplasm", "Hayalet Ektoplazması", "rare", None, None, 0, 0, "Hayaletten sızan titreşen madde."),
("q_harpy_feather", "Harpi Tüyü", "uncommon", None, None, 0, 0, "Bataklık harpisinin keskin tüyü."),
("q_bear_claw", "Ayı Pençesi", "uncommon", None, None, 0, 0, "Dağ ayısının kavisli pençesi."),
("q_goblin_ear", "Goblin Kulağı", "common", None, None, 0, 0, "Goblin çetesi avının kanıtı."),
("q_troll_hide", "Trol Derisi", "rare", None, None, 0, 0, "Kalın, ok geçirmez trol derisi."),
("q_demon_horn", "İblis Boynuzu", "epic", None, None, 0, 0, "Gölge iblisinden koparılan kara boynuz."),
("q_worm_tooth", "Kum Solucanı Dişi", "rare", None, None, 0, 0, "Kum solucanının testere gibi dişi."),
("q_boar_tusk", "Domuz Dişi", "common", None, None, 0, 0, "Yaban domuzu reisinin azı dişi."),
("q_leech_slime", "Sülük Salgısı", "common", None, None, 0, 0, "Bataklık sülüğünden alınan yapışkan salgı."),
("q_medallion", "Kayıp Madalyon", "rare", None, None, 0, 0, "Bir soylunun kayıp aile madalyonu."),
("q_ancient_tablet","Kadim Tablet", "rare", None, None, 0, 0, "Eski bir dille yazılı taş tablet."),
("q_cursed_scroll", "Lanetli Tomar", "rare", None, None, 0, 0, "Karanlık büyü içeren mühürlü tomar."),
("q_holy_relic", "Kutsal Emanet", "epic", None, None, 0, 0, "ık tarikatının kayıp kutsal emaneti."),
("q_lost_ring", "Kayıp Yüzük", "uncommon", None, None, 0, 0, "Bir maceracının mezardan çıkan yüzüğü."),
("q_totem_shard", "Totem Parçası", "common", None, None, 0, 0, "Goblin şamanın kırık totemi."),
]
# ─────────────────────────── YAN GÖREVLER ───────────────────────────
# (başlık, giver_key, bölge_adı[map_tags.name] veya None, özet, [hedef notları])
QUESTS = [
# ── Kaptan Doran — bounty / güvenlik ──
("Kurt Belası", "doran", "Veridian Glade",
"Aç kurtlar kale yollarına inmeye başladı. Doran onları seyrekleştirmeni istiyor.",
["Veridian Glade'de 6 Aç Kurt öldür.", "Kanıt olarak 4 Kurt Dişi topla.", "Kaptan Doran'a dön."]),
("Örümcek Yuvası", "doran", "Luminara Grove",
"Zehirli örümcekler bir tüccarı zehirledi. Yuvayı temizle.",
["Luminara Grove'da 5 Zehirli Orman Örümceği öldür.", "3 Örümcek Zehir Kesesi getir."]),
("Haydut Tehdidi", "doran", "Solstice Mire",
"Orman haydutları kervan yolunu kesiyor. Reisleri bir ders almalı.",
["Solstice Mire'da 5 Orman Haydutu öldür.", "Çaldıkları keseyi geri al.", "Doran'a bildir."]),
("Gölge Kurdu Avı", "doran", "Emberfall",
"Bir alfa kurt sürüsü kontrolden çıkardı. Elit hedefi indir.",
["Emberfall'da Gölge Kurdu (Alfa)'yı öldür.", "Postunu kanıt getir."]),
("Kanatlı Bela", "doran", "Xylos Prime",
"Kanlı yarasalar geceleri nöbetçilere saldırıyor.",
["Xylos Prime'da 8 Kanlı Yarasa öldür.", "5 Yarasa Kanadı topla."]),
("Karga Laneti", "doran", "Veridian Glade",
"Lanetli bir karga sürüsü tarlaları talan ediyor; uğursuzluk saçıyorlar.",
["Lanetli Karga Sürüsü'nü dağıt.", "Lanetli boncuğu Rahibe Lysa'ya götür."]),
# ── Rahibe Lysa — şifa / topla ──
("Merhem İçin Kök", "lysa", "Fısıltı Ormanı",
"Revirdeki yaralılar için şifalı kök tükendi.",
["Fısıltı Ormanı'nda 8 Şifalı Kök topla.", "Rahibe Lysa'ya teslim et."]),
("Dolunay Çiçeği", "lysa", None,
"Ateşi düşmeyen bir çocuk için nadir ay çiçeği gerek.",
["Uçsuz Çayırlar'da 6 Ay Çiçeği topla.", "Lysa'ya getir."]),
("Kutsal Su Seferi", "lysa", None,
"Vaftiz ayini için kutsanmış kaynak suyu lazım.",
["Fırtına Kıyısı'ndan 4 Kutsal Su al.", "Mabede götür."]),
("Salgın Durdurulmalı", "lysa", "B-1",
"Çorak topraktan gelen hortlaklar hastalık yayıyor; kaynağı azalt.",
["Çorak Hortlağı'ndan 6 öldür.", "5 Mavi Mantar topla (panzehir için).", "Lysa'ya dön."]),
("Arı Kovanı Riski", "lysa", None,
"Yaralı yanıkları için yaban balı gerekiyor ama kovanlar tehlikeli.",
["Akça Koru'dan 5 Yaban Balı topla.", "Lysa'ya teslim et."]),
# ── Tüccar Aldwin — teslimat / eskort ──
("Kervan Koruması", "aldwin", "Solstice Mire",
"Aldwin'in kervanı bataklıktan geçerken haydut korkusu içinde.",
["Kervan yolundaki 4 Orman Haydutu'nu temizle.", "Aldwin'e güvenli yolu bildir."]),
("Kayıp Sevkiyat", "aldwin", "Rivenwood",
"Bir yük arabası devrildi, mallar ormana saçıldı.",
["Rivenwood'da 5 kayıp sandığı bul (araştır).", "Goblinlerin çaldığını Aldwin'e söyle."]),
("Değerli Teslimat", "aldwin", "Riftwood Crossing",
"Kırılgan bir sipariş Riftwood'a ulaştırılmalı.",
["Paketi Hancı Bramble'a götür.", "İmzalı makbuzu Aldwin'e geri getir."]),
("Borçlu Kaçtı", "aldwin", "Karaağaç Karanlığı",
"Aldwin'e borçlu bir adam karanlık ormana kaçmış.",
["Karaağaç Karanlığı'nda borçluyu bul.", "Ya altını ya sözünü Aldwin'e getir."]),
("Rakip Casusu", "aldwin", "Aethelguard Citadel",
"Aldwin rakip bir tüccarın kendisini gözetlediğinden şüpheleniyor.",
["Kalede casusu takip et (araştır).", "Bulguları Aldwin'e ilet."]),
# ── Simyacı Verena — reagent ──
("Zehir Deneyi", "verena", "Luminara Grove",
"Verena panzehir formülü için taze örümcek zehri istiyor.",
["4 Örümcek Zehir Kesesi topla.", "Verena'ya getir."]),
("Mantar Koleksiyonu", "verena", "Kadim Orman",
"Nadir mavi mantarlar bir iksirin anahtarı.",
["Kadim Orman'da 10 Mavi Mantar topla.", "Verena'nın laboratuvarına bırak."]),
("Kristalin Sırrı", "verena", "Rivenhaven",
"Verena maden kristalinin manasını incelemek istiyor.",
["Derin Maden'den 6 Kristal Parçası çıkar.", "Verena'ya teslim et."]),
("Ektoplazma Örneği", "verena", "T-2",
"Hayalet maddesi Verena'nın en riskli deneyi için gerekli.",
["Lanetli Rahip Hayaleti'nden 2 Hayalet Ektoplazması al.", "Kırılmadan Verena'ya götür."]),
("Sülük Salgısı", "verena", "Slyvandor",
"Yapışkan sülük salgısı yeni bir yara bandı denemesi için.",
["Bataklık Sülüğü'nden 5 Sülük Salgısı topla.", "Verena'ya ver."]),
("İblis Kükürdü", "verena", "B-1",
"Verena tehlikeli bir simya için iblis boynuzu tozu istiyor — delilik mi cesaret mi?",
["Gölge İblisi'nden 1 İblis Boynuzu al.", "Verena'ya teslim et (dikkatli!)."]),
# ── Kütüphaneci Orin — araştır / kadim ──
("Kayıp Tabletler", "orin", "M-1",
"Orin mağara duvarlarındaki kadim yazıtları istiyor.",
["M-1 mağarasında 3 Kadim Tablet bul.", "Orin'e getir."]),
("Yasak Tomar", "orin", "T-1",
"Tapınak arşivinde lanetli bir tomar saklı; Orin onu güvenle incelemek istiyor.",
["Huzursuz İskeletleri geçip 1 Lanetli Tomar al.", "Orin'e teslim et."]),
("Harabelerin Sırrı", "orin", "Rivenhaven",
"Madende eski bir uygarlığın izleri var.",
["Rivenhaven'da 4 oyma taş incele (araştır).", "Çizimleri Orin'e ver."]),
("Kadim Meşenin Bilgeliği", "orin", "Kadim Orman",
"Orin kadim meşe özünün büyülü kaydını çözmek istiyor.",
["3 Kadim Meşe Özü topla.", "Orin'in masasına bırak."]),
("Kayıp Günlük", "orin", "Whisperwood",
"Kaybolan bir kaşifin günlüğü ormanda bir yerlerde.",
["Whisperwood'da kaşifin kampını bul (araştır).", "Günlüğü Orin'e getir."]),
# ── Hancı Bramble — yemek / dedikodu ──
("Han Mutfağı: Et", "bramble", "Av Toprakları",
"Han için taze yaban eti lazım.",
["Av Toprakları'nda avlan, 8 yaban eti getir.", "Bramble'a teslim et."]),
("Han Mutfağı: Balık", "bramble", None,
"Cuma ziyafeti için taze balık gerek.",
["Aynalı Göl'de 10 balık tut.", "Bramble'a getir."]),
("Bal Likörü", "bramble", "Akça Koru",
"Bramble'ın meşhur bal likörü için yaban balı bitti.",
["6 Yaban Balı topla.", "Bramble'a ver."]),
("Fıçı Baskını", "bramble", "Rivenwood",
"Goblinler han fıçılarını çaldı!",
["Rivenwood'da 4 Orman Haydutu/Goblin öldür.", "Çalınan fıçıyı geri getir."]),
("Dedikodu Toplayıcı", "bramble", "Riftwood Crossing",
"Bramble kasabadaki söylentileri merak ediyor.",
["3 kasabalıyla konuş (araştır).", "Duyduklarını Bramble'a anlat."]),
# ── Avcı Ustası Kael — av / deri ──
("Çırak Sınavı: Tavşan", "kael", "Av Toprakları",
"Kael'in çırağı olmak için önce mütevazı bir avla başla.",
["Av Toprakları'nda 6 tavşan avla.", "6 tavşan derisi getir."]),
("Reis Domuz", "kael", "Solstice Mire",
"Yaban domuzu reisi bir avcıyı yaraladı; onu indir.",
["Yaban Domuzu Reisi'ni öldür.", "2 Domuz Dişi getir."]),
("Ayı Sorunu", "kael", "Emberfall",
"Erken uyanan bir dağ ayısı avlanma alanını basıyor.",
["Dağ Ayısı'nı öldür.", "Ayı Pençesi ve postu Kael'e getir."]),
("Kaliteli Deri Siparişi", "kael", "Av Toprakları",
"Bir zırhçı için kalın deri gerekiyor.",
["Geyik avla, 8 geyik derisi topla.", "Kael'e teslim et."]),
("Harpi Tehdidi", "kael", "Solstice Mire",
"Bataklık harpileri avcıların tuzaklarını yağmalıyor.",
["3 Bataklık Harpisi öldür.", "4 Harpi Tüyü getir."]),
("Kaçak Yavru", "kael", "Whisperwood",
"Nadir bir geyik yavrusu sürüden ayrı düşmüş; kurtlardan önce bul.",
["Whisperwood'da yavruyu bul (araştır).", "Güvenle Kael'e getir."]),
# ── Kervan Lideri Sefa — yol / teslimat ──
("Yol Temizliği", "sefa", "Ironbark Reach",
"Sefa'nın rotasını sarmaşık cinleri kapatmış.",
["Ironbark Reach'te 4 Sarmaşık Cini öldür.", "Yolu Sefa'ya rapor et."]),
("Kıtalar Arası Paket", "sefa", "Riftwood Crossing",
"Meridia'ya gidecek hassas bir paket koruma istiyor.",
["Paketi limana kadar koru (eskort).", "Sefa'ya teslim onayını getir."]),
("Kayıp Kervancı", "sefa", "Karaağaç Karanlığı",
"Sefa'nın bir adamı karanlık ormanda kayıp.",
["Karaağaç Karanlığı'nda kervancıyı ara (araştır).", "Gulyabani tehdidini temizle.", "Adamı geri getir."]),
("Nal Sevkiyatı", "sefa", "Riftwood Crossing",
"Nalbant Garrick'e demir cürufu ulaştırılmalı.",
["Taş Ocağı'ndan 8 Demir Cürufu topla.", "Garrick'e teslim et."]),
("Korsan Uyarısı", "sefa", None,
"Kıyıda korsan görüldü; Sefa deniz rotasını merak ediyor.",
["Fırtına Kıyısı'nı gözle (araştır).", "Seline ile konuş.", "Sefa'ya bildir."]),
# ── Ustabaşı Grungni — maden ──
("Kobold İstilası", "grungni", "Rivenhaven",
"Kobold sürüsü madenin alt katını ele geçirdi.",
["Rivenhaven'da 8 Kobold Madenci öldür.", "6 Kobold Kulağı getir."]),
("Taştan Bela", "grungni", "Rivenhaven",
"Bir taş golemi maden geçidini tıkadı.",
["Taş Golemi'ni parçala.", "Golem Çekirdeği'ni Grungni'ye getir."]),
("Göçük Kurtarması", "grungni", "Rivenhaven",
"Göçük altında kalan madenciler var.",
["3 madenciyi kazıp çıkar (araştır).", "Güvenli çıkışı Grungni'ye bildir."]),
("Kristal Kotası", "grungni", "Derin Maden",
"Kalenin siparişi için kristal çıkarılmalı.",
["Derin Maden'den 10 Kristal Parçası çıkar.", "Grungni'ye teslim et."]),
("Derindeki Örümcek", "grungni", "M-2",
"En alt galeride dev bir örümcek yuva kurmuş.",
["Derin Örümcek'i öldür.", "Yuvayı yak.", "Grungni'ye haber ver."]),
("Kör Trol", "grungni", "M-1",
"Kör bir mağara trolü madencileri dehşete düşürüyor.",
["Kör Mağara Trolü'nü indir.", "Trol Derisi'ni kanıt getir."]),
# ── Kâhin Nadia — tapınak/undead ──
("Huzursuz Ölüler", "nadia", "T-1",
"Tapınak mührü zayıfladı, iskeletler uyandı.",
["T-1'de 8 Huzursuz İskelet dağıt.", "6 İskelet Kemiği topla."]),
("Mührü Onar", "nadia", "T-1",
"Kırık mühür yeniden çizilmeli.",
["3 Kutsal Su getir.", "Sunakta ritüeli tamamla.", "Nadia'ya bildir."]),
("Kayıp Kâhin", "nadia", "M-1",
"Nadia'nın çırağı kehanet ararken mağarada kaybolmuş.",
["M-1'de çırağı bul (araştır).", "Sağ salim Nadia'ya getir."]),
("Kehanet Tableti", "nadia", "M-2",
"Bir kehanet kadim bir tablete kazınmış.",
["M-2'de 2 Kadim Tablet bul.", "Nadia'ya oku."]),
# ── Baş Rahip Thariel — kutsal ──
("Lanetli Sunak", "thariel", "T-2",
"İhanetle lekelenen sunak arındırılmalı.",
["Lanetli Rahip Hayaleti'ni yok et.", "Sunağı kutsal su ile arındır.", "Thariel'e dön."]),
("Kutsal Emanet", "thariel", "T-2",
"Tarikatın kutsal emaneti yağmalanmış, izini sür.",
["Taş Bekçi Heykeli'ni geç.", "1 Kutsal Emanet'i geri al.", "Thariel'e teslim et."]),
("Karanlığı Kov", "thariel", "B-1",
"Çorak topraktan yayılan karanlık tapınağı tehdit ediyor.",
["B-1'de 1 Gölge İblisi'ni kov.", "İblis Boynuzu'nu mühürle.", "Thariel'e getir."]),
("Kutsal Su Kuyusu", "thariel", None,
"Mabet kuyusu kurudu; kutsanacak temiz su gerek.",
["Fırtına Kıyısı'ndan 6 Kutsal Su getir.", "Thariel kutsayacak."]),
("Hac Yolu Güvenliği", "thariel", "T-1",
"Hacılar huzursuz ölülerden korkuyor.",
["T-1 yolundaki 6 Huzursuz İskelet'i temizle.", "Thariel'e güvenli yolu bildir."]),
# ── Orman Bekçisi Bram — orman dengesi ──
("Ormanın Dengesi", "bram", "Whisperwood",
"Yaşlı Ağaç Adam öfkelenip yolcuları eziyor; sakinleştir ya da indir.",
["Whisperwood'da Yaşlı Ağaç Adam'ı yatıştır ya da öldür.", "Bram'a bildir."]),
("Spor Salgını", "bram", "Rivenwood",
"Mantar spor yaratıkları ormanı zehirliyor.",
["Rivenwood'da 6 Mantar Spor Yaratığı öldür.", "Zehirli sporları yak."]),
("Kaçak Ateş", "bram", "Emberfall",
"Emberfall'da kontrolsüz bir yangın çıkabilir; kaynağı bul.",
["Emberfall'da yangın izini araştır.", "Bram'a raporla."]),
("Leshy Uyanışı", "bram", "Ironbark Reach",
"Bir orman şeytanı (Leshy) uyanmış; Witcher misali onu alt et.",
["Ironbark Reach'te Orman Şeytanı (Leshy)'yi öldür.", "Kadim Meşe Özü'nü Bram'a getir."]),
("Fare İstilası", "bram", "Aetherbloom",
"Dev orman fareleri ağaç köklerini kemiriyor.",
["Aetherbloom'da 8 Dev Orman Faresi öldür.", "Bram'a bildir."]),
# ── Elf İzci Elindra — nadir doğa ──
("Ay Çiçeği Kutsaması", "elindra", None,
"Elindra'nın ritüeli için el değmemiş ay çiçekleri gerek.",
["Uçsuz Çayırlar'da 8 Ay Çiçeği topla.", "Elindra'ya getir."]),
("Kutsal Ağaç Bekçisi", "elindra", "Luminara Grove",
"Örümcekler kutsal ağaca ağ örüyor.",
["Luminara Grove'da 6 Zehirli Orman Örümceği öldür.", "Ağacı temizle."]),
("İnci Gözyaşı", "elindra", None,
"Elindra'nın kolyesi için berrak bir göl incisi gerek.",
["Aynalı Göl'de 5 İnci Kabuğu topla.", "Elindra'ya ver."]),
("Su Cini Tehdidi", "elindra", "Aetherbloom",
"Bir su cini derenin balıklarını ve balııları yutuyor.",
["Aetherbloom'da Su Cini (Vodyanoy)'u öldür.", "Elindra'ya haber ver."]),
# ── Münzevi Morvath — gizemli reagent ──
("Reagent Toplayıcı", "morvath", "Xylos Prime",
"Morvath büyüsü için üç farklı reagent istiyor.",
["4 Kadim Meşe Özü, 4 Mavi Mantar, 4 Kristal Parçası getir.", "Morvath'a ver."]),
("Kara Tomarın Peşinde", "morvath", "T-1",
"Morvath yasak bilgi için lanetli bir tomar istiyor.",
["T-1'den 1 Lanetli Tomar al.", "Morvath'a gizlice getir."]),
("Gölge Özü", "morvath", "B-1",
"Morvath karanlık bir deney için iblis maddesi arıyor.",
["Gölge İblisi'nden 1 İblis Boynuzu al.", "Morvath'a teslim et."]),
("Totem Kırıcı", "morvath", "Rivenwood",
"Vahşi şaman goblin Morvath'ın çizdiği sınırı bozuyor.",
["Vahşi Şaman Goblin'i öldür.", "2 Totem Parçası getir."]),
# ── Thessa Mor — gizemli / karışık ──
("Kayıp Madalyon", "thessa", "B-1",
"Thessa bir soylunun kayıp madalyonunu istiyor; nedenini söylemiyor.",
["Çorak Hortlağı'ndan 1 Kayıp Madalyon düşür.", "Thessa'ya getir."]),
("Mezar Yüzüğü", "thessa", "Karaağaç Karanlığı",
"Bir maceracının yüzüğü gulyabaninin ininde.",
["Gulyabani'yi öldür.", "1 Kayıp Yüzük al.", "Thessa'ya teslim et."]),
("İsimsiz İş", "thessa", "M-2",
"Thessa mağaranın dibinden bir şey getirmeni istiyor — soru sorma.",
["M-2'de gizli sandığı bul (araştır).", "Açmadan Thessa'ya getir."]),
("Sessiz Tanık", "thessa", "Rivenhaven",
"Bir olaya tanık olan madenci ortadan kaybolmuş.",
["Rivenhaven'da tanığı ara (araştır).", "Bulguları Thessa'ya fısılda."]),
("Kum Altındaki Sır", "thessa", "B-1",
"Çorakta bir şey gömülü; kum solucanı üstünde bekliyor.",
["Kum Solucanı'nı öldür.", "1 Kum Solucanı Dişi ve gömülü kutuyu getir."]),
# ── Nalbant Garrick — demir/kömür ──
("Demir Cürufu Lazım", "garrick", "Taş Ocağı",
"Garrick'in nal ocağı için cüruf tükendi.",
["Taş Ocağı'ndan 10 Demir Cürufu topla.", "Garrick'e getir."]),
("At Nalı Siparişi", "garrick", "Riftwood Crossing",
"Kervan atları için toplu nal gerek; malzeme topla.",
["6 Demir Cürufu ve 3 bakır külçe getir.", "Garrick'e teslim et."]),
("Kayıp Örs", "garrick", "Rivenwood",
"Garrick'in kıymetli örsü çalınmış — goblin işi.",
["Rivenwood'da goblin kampını bul (araştır).", "Örsü geri getir."]),
# ── Yaşlı Wystan — nostalji / bounty ──
("Eski Kılıcım", "wystan", "M-1",
"Wystan gençliğinin kılıcını bir mağarada bırakmış.",
["M-1'de eski kılıcı bul (araştır).", "Wystan'a getir."]),
("Son Bir Av", "wystan", "Emberfall",
"Wystan bir kez daha büyük bir av görmek istiyor; onun adına indir.",
["Dağ Ayısı'nı öldür.", "Postu Wystan'a göster."]),
("Şövalyenin Onuru", "wystan", "B-1",
"Wystan'ı yenen iblis hâlâ çorakta; intikamını al.",
["Gölge İblisi'ni öldür.", "İblis Boynuzu'nu Wystan'a getir."]),
("Zırhımı Onar", "wystan", "Taş Ocağı",
"Wystan'ın eski zırhı için metal gerek.",
["Taş Ocağı'ndan 8 Demir Cürufu topla.", "Bir demirciye onartıp Wystan'a getir."]),
# ── Balıı Seline — deniz/göl ──
("Fırtına Avı", "seline", None,
"Fırtına öncesi balık sürüsü kıyıya yaklaştı.",
["Fırtına Kıyısı'nda 12 balık tut.", "Seline'ye getir."]),
("İnci Dalışı", "seline", None,
"Seline'nin borcu için değerli inci gerek.",
["Aynalı Göl'de 8 İnci Kabuğu topla.", "Seline'ye ver."]),
("Dev Balık Efsanesi", "seline", None,
"Gölde efsanevi bir balık görülmüş; kanıt getir.",
["Aynalı Göl'ü araştır.", "Efsane balığı yakala.", "Seline'ye göster."]),
("Korsan Kampı", "seline", "B-1",
"Kıyı korsanları balıı ağlarını yağmalıyor.",
["Kıyıdaki 4 haydudu/korsanı temizle.", "Seline'ye bildir."]),
("Sülük Bela", "seline", "Slyvandor",
"Bataklık sülükleri balık ağlarına doluşuyor.",
["Slyvandor'da 6 Bataklık Sülüğü öldür.", "5 Sülük Salgısı Verena için topla."]),
# ── Ek karışık (dünya doldurma) ──
("Tuz Toplayıcı", "bramble", None,
"Han'ın eti kürlemek için tuza ihtiyacı var.",
["Fırtına Kıyısı'ndan 8 Kristalize Tuz topla.", "Bramble'a getir."]),
("Akbaba Sürüsü", "doran", "B-1",
"Leş akbabaları çorak yolcularına saldırıyor.",
["B-1'de 6 Leş Akbabası öldür.", "Doran'a bildir."]),
("Kutsal Taş", "thariel", "T-2",
"Sunak onarımı için bekçi heykelinden kutsal taş gerek.",
["Taş Bekçi Heykeli'ni yık.", "2 Kutsal Taş getir."]),
("Bal ve Mum", "grungni", "Akça Koru",
"Madenciler karanlıkta çalışmak için mum, ustabaşı da tatlı istiyor.",
["Akça Koru'dan 5 Yaban Balı topla.", "Grungni'ye getir."]),
("Goblin Reisi", "kael", "Rivenwood",
"Goblin çetesinin şamanı bölgeyi terörize ediyor.",
["Vahşi Şaman Goblin'i öldür.", "4 Goblin Kulağı getir."]),
("Kristalize Tuz Siparişi", "verena", None,
"Verena koruyucu tuz çemberi için saf tuz istiyor.",
["Fırtına Kıyısı'ndan 10 Kristalize Tuz topla.", "Verena'ya ver."]),
("Yolcu Kurtarma", "sefa", "Solstice Mire",
"Bataklıkta mahsur kalan bir yolcu yardım bekliyor.",
["Solstice Mire'da yolcuyu bul (araştır).", "Harpilerden koru.", "Sefa'ya getir."]),
("Kadim Kapı", "orin", "M-2",
"Orin mağaranın dibindeki mühürlü kapıyı çözmek istiyor.",
["M-2'de kapının 3 sembolünü incele (araştır).", "Çizimi Orin'e getir."]),
("Şifalı Çorba", "lysa", "Av Toprakları",
"Hasta çocuklar için besleyici et suyu gerek.",
["Av Toprakları'nda 6 yaban eti getir.", "Lysa'ya teslim et."]),
("Kayıp Çoban", "bram", "Uçsuz Çayırlar mı?",
"Çayırlarda bir çoban ve sürüsü kaybolmuş.",
["Uçsuz Çayırlar'ı araştır.", "Çobanı bul.", "Bram'a bildir."]),
("Örümcek İpeği", "verena", "M-2",
"Verena dayanıklı örümcek ipeği ile deney yapacak.",
["Derin Örümcek'ten 4 ipek topla.", "Verena'ya getir."]),
("Kayıp Madenci Ailesi", "grungni", "Rivenhaven",
"Bir madencinin ailesi haber bekliyor; adamı bul.",
["Rivenhaven'da kayıp madenciyi ara (araştır).", "Haberi Grungni'ye ilet."]),
("Tapınak Çanı", "nadia", "T-2",
"Tapınak çanı düşmüş; ruhları uzak tutan sesi geri getir.",
["T-2'de düşen çanı bul (araştır).", "Bekçiyi geçip çanı as.", "Nadia'ya bildir."]),
]
async def resolve_map_tag(c, name):
if not name or name.endswith("mı?"):
return None
return await c.fetchval("SELECT id FROM map_tags WHERE name=$1", name)
async def main():
dsn = settings.DATABASE_URL.replace("+asyncpg", "")
c = await asyncpg.connect(dsn)
# 1) Görev verenler (upsert by name)
giver_ids = {}
for key, name, role, place, about in GIVERS:
tag = await c.fetchval("SELECT id FROM map_tags WHERE name=$1", place)
desc = f"{about}\n\n{GIVER_MARK}"
gid = await c.fetchval("SELECT id FROM npcs WHERE name=$1", name)
if gid:
await c.execute("UPDATE npcs SET role=$2,stance='friendly',description=$3,"
"map_tag_id=$4,updated_at=now() WHERE id=$1", gid, role, desc, tag)
else:
gid = await c.fetchval("INSERT INTO npcs(name,role,stance,description,map_tag_id) "
"VALUES($1,$2,'friendly',$3,$4) RETURNING id", name, role, desc, tag)
giver_ids[key] = gid
# 2) Düşmanlar (upsert by name)
for name, role, region, desc in ENEMIES:
tag = await c.fetchval("SELECT id FROM map_tags WHERE name=$1", region)
eid = await c.fetchval("SELECT id FROM npcs WHERE name=$1", name)
if eid:
await c.execute("UPDATE npcs SET role=$2,stance='hostile',description=$3,"
"map_tag_id=$4,updated_at=now() WHERE id=$1", eid, role, desc, tag)
else:
await c.execute("INSERT INTO npcs(name,role,stance,description,map_tag_id) "
"VALUES($1,$2,'hostile',$3,$4)", name, role, desc, tag)
# 3) Görev itemleri (upsert by code) + toplanabilir olanları location_resources'a
for code, name, rarity, loc, skill, step, xp, about in QITEMS:
iid = await c.fetchval("""
INSERT INTO items(code,name,type,rarity,base_value,description)
VALUES($1,$2,'material',$3,$4,$5)
ON CONFLICT(code) DO UPDATE SET name=EXCLUDED.name,rarity=EXCLUDED.rarity,
description=EXCLUDED.description RETURNING id
""", code, name, rarity, {"common":8,"uncommon":20,"rare":60,"epic":180}.get(rarity,8), about)
if loc:
lid = await c.fetchval("SELECT id FROM locations WHERE code=$1", loc)
if lid:
await c.execute("""
INSERT INTO location_resources(location_id,item_code,skill,step_cost,xp,cooldown_s)
VALUES($1,$2,$3,$4,$5,300)
ON CONFLICT(location_id,item_code) DO NOTHING
""", lid, code, skill, step, xp)
# 4) Quest'ler — önce eski seed'i temizle, sonra ekle
await c.execute("DELETE FROM quests WHERE author=$1", SEED_TAG)
qn = 0
for i, (title, gkey, region, summary, objs) in enumerate(QUESTS):
gid = giver_ids.get(gkey)
rtag = await resolve_map_tag(c, region)
qid = await c.fetchval("""
INSERT INTO quests(title,quest_type,summary,giver_npc_id,region_tag_id,author,sort_order)
VALUES($1,'side',$2,$3,$4,$5,$6) RETURNING id
""", title, summary, gid, rtag, SEED_TAG, i)
for j, note in enumerate(objs):
await c.execute("INSERT INTO quest_objectives(quest_id,note,ordering,map_tag_id) "
"VALUES($1,$2,$3,$4)", qid, note, j, rtag)
qn += 1
tot_q = await c.fetchval("SELECT count(*) FROM quests")
tot_npc = await c.fetchval("SELECT count(*) FROM npcs")
tot_hostile = await c.fetchval("SELECT count(*) FROM npcs WHERE stance='hostile'")
tot_items = await c.fetchval("SELECT count(*) FROM items")
await c.close()
print(f"Görev veren: {len(GIVERS)}, yeni düşman: {len(ENEMIES)}, görev itemi: {len(QITEMS)}")
print(f"Yan görev eklendi: {qn}. DB toplam quest: {tot_q}, NPC: {tot_npc} "
f"(hostile {tot_hostile}), item: {tot_items}")
asyncio.run(main())

249
db/seed_world_graph.py Normal file
View file

@ -0,0 +1,249 @@
"""
Örnek dünya: 15 lokasyon + 21 yol + lokasyon kaynakları + 12 D&D olayı.
Idempotent (code üzerinden upsert). Harita koordinatları 0-1000 (statik görsel için).
Yeni ham maddeler (13 odun, cevherler, otlar, balıklar, av) lokasyonlara tier'a göre dağıtılır.
Çalıştırma: cd /opt/stepstead && ./venv/bin/python3 db/seed_world_graph.py
"""
import asyncio
import json
import sys
import asyncpg
sys.path.insert(0, "/opt/stepstead")
from config import settings
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
# (code, ad, tür, açıklama, x, y)
LOCATIONS = [
("koy", "Stepstead Köyü", "village", "Yolculuğun başladığı huzurlu köy. Etrafta taş, sopa ve sarmaşık bulunur.", 163, 186),
("pazar", "Pazarköy", "city", "Tüccarların uğrak yeri, canlı bir kasaba.", 232, 392),
("liman", "Liman Kenti", "city", "Büyük gemilerin yanaştığı ticaret limanı. Derin deniz balıılığı buradan yapılır.", 140, 630),
("fisilti_ormani", "Fısıltı Ormanı", "forest", "Köye en yakın orman. Meşe ve çam ağaçları.", 205, 110),
("gumus_camlik", "Gümüş Çamlık", "forest", "Sisli tepelerde huş ve kayın ağaçları.", 110, 300),
("akca_koru", "Akça Koru", "forest", "Akçaağaç ve söğütleriyle ünlü sakin koru.", 282, 255),
("kadim_orman", "Kadim Orman", "forest", "Yüzyıllık dişbudak, sedir ve ceviz ağaçları.", 320, 468),
("karanlik_orman", "Karaağaç Karanlığı", "forest", "En değerli ağaçların büyüdüğü tekinsiz orman: porsuk, maun, abanoz...", 262, 600),
("tas_ocagi", "Taş Ocağı", "mine", "Bakır ve kalay damarları, bol kömür.", 610, 290),
("derin_maden", "Derin Maden", "mine", "Demir ve gümüş çıkarılan eski maden.", 750, 320),
("ejder_dagi", "Ejder Dağı", "mine", "Altın damarlarının saklandığı efsanevi zirve.", 790, 115),
("aynali_gol", "Aynalı Göl", "lake", "Sazan ve alabalığın bol olduğu berrak göl.", 723, 655),
("firtina_kiyisi", "Fırtına Kıyısı", "lake", "Dalgalı denizde uskumru, palamut ve lüfer.", 685, 905),
("cayirlar", "Uçsuz Çayırlar", "meadow", "Şifalı otların yetiştiği rüzgârlı çayırlar.", 590, 760),
("av_topraklari", "Av Toprakları", "hunting", "Tavşan ve geyik avlanır. Daha derinde kurt vadisi.", 855, 712),
]
# (a, b, adım, olay_şansı)
ROADS = [
("koy", "fisilti_ormani", 320, 0.30),
("koy", "tas_ocagi", 350, 0.30),
("koy", "aynali_gol", 380, 0.25),
("koy", "cayirlar", 400, 0.30),
("koy", "pazar", 450, 0.20),
("fisilti_ormani", "gumus_camlik", 400, 0.35),
("gumus_camlik", "akca_koru", 450, 0.35),
("akca_koru", "kadim_orman", 500, 0.40),
("kadim_orman", "karanlik_orman", 600, 0.50),
("gumus_camlik", "av_topraklari", 500, 0.40),
("av_topraklari", "karanlik_orman", 650, 0.50),
("tas_ocagi", "derin_maden", 450, 0.35),
("derin_maden", "ejder_dagi", 600, 0.45),
("aynali_gol", "pazar", 300, 0.20),
("pazar", "liman", 500, 0.25),
("liman", "firtina_kiyisi", 250, 0.25),
("aynali_gol", "firtina_kiyisi", 550, 0.35),
("cayirlar", "av_topraklari", 450, 0.35),
("cayirlar", "gumus_camlik", 350, 0.30),
("derin_maden", "liman", 550, 0.30),
("cayirlar", "tas_ocagi", 300, 0.25),
]
# lokasyon → [(item_code, skill, adım, xp, cooldown_s)]
RESOURCES = {
"koy": [
("stick", "foraging", 4, 3, 120), ("stone_small", "foraging", 5, 3, 120),
("vine", "foraging", 6, 4, 120), ("herb_mint", "foraging", 8, 6, 180),
],
"fisilti_ormani": [
("log_oak", "woodcutting", 12, 8, 240), ("log_pine", "woodcutting", 14, 10, 240),
("stick", "foraging", 4, 3, 120), ("vine", "foraging", 6, 4, 120),
("herb_sage", "foraging", 9, 7, 180),
],
"gumus_camlik": [
("log_birch", "woodcutting", 16, 12, 240), ("log_beech", "woodcutting", 18, 14, 240),
("herb_thyme", "foraging", 10, 8, 180), ("stick", "foraging", 4, 3, 120),
],
"akca_koru": [
("log_maple", "woodcutting", 20, 16, 300), ("log_willow", "woodcutting", 22, 18, 300),
("herb_chamomile", "foraging", 11, 9, 180), ("vine", "foraging", 6, 4, 120),
],
"kadim_orman": [
("log_ash", "woodcutting", 24, 20, 300), ("log_cedar", "woodcutting", 26, 22, 300),
("log_walnut", "woodcutting", 28, 24, 300), ("herb_nettle", "foraging", 12, 10, 180),
],
"karanlik_orman": [
("log_cherry", "woodcutting", 30, 26, 360), ("log_yew", "woodcutting", 34, 30, 360),
("log_mahogany", "woodcutting", 38, 34, 360), ("log_ebony", "woodcutting", 42, 38, 360),
("herb_ginseng", "foraging", 16, 14, 240), ("herb_saffron", "foraging", 18, 16, 240),
],
"tas_ocagi": [
("ore_copper", "mining", 15, 10, 240), ("ore_tin", "mining", 15, 10, 240),
("coal", "mining", 12, 8, 240), ("stone_small", "foraging", 5, 3, 120),
],
"derin_maden": [
("ore_iron", "mining", 20, 16, 300), ("coal", "mining", 12, 8, 240),
("ore_silver", "mining", 26, 22, 300),
],
"ejder_dagi": [
("ore_gold", "mining", 34, 30, 360), ("ore_silver", "mining", 26, 22, 300),
("ore_iron", "mining", 20, 16, 300),
],
"aynali_gol": [
("fish_carp", "fishing", 14, 9, 240), ("fish_perch", "fishing", 15, 10, 240),
("fish_trout", "fishing", 18, 14, 240), ("vine", "foraging", 6, 4, 120),
],
"firtina_kiyisi": [
("fish_anchovy", "fishing", 14, 9, 240), ("fish_mackerel", "fishing", 16, 12, 240),
("fish_bonito", "fishing", 18, 14, 240), ("fish_bluefish", "fishing", 20, 16, 240),
("fish_bream", "fishing", 20, 16, 240),
],
"liman": [
("fish_pike", "fishing", 24, 20, 300), ("fish_catfish", "fishing", 26, 22, 300),
("fish_salmon", "fishing", 28, 24, 300), ("fish_turbot", "fishing", 30, 26, 300),
("fish_tuna", "fishing", 34, 30, 360), ("fish_sturgeon", "fishing", 38, 34, 360),
],
"cayirlar": [
("herb_rosemary", "foraging", 10, 8, 180), ("herb_basil", "foraging", 10, 8, 180),
("herb_lemonbalm", "foraging", 12, 10, 180), ("herb_fennel", "foraging", 12, 10, 180),
("herb_lavender", "foraging", 14, 12, 180), ("stick", "foraging", 4, 3, 120),
],
"av_topraklari": [
("meat_rabbit", "hunting", 16, 12, 300), ("leather_rabbit", "hunting", 16, 12, 300),
("meat_deer", "hunting", 20, 16, 300), ("leather_deer", "hunting", 20, 16, 300),
("meat_boar", "hunting", 24, 20, 360), ("leather_boar", "hunting", 24, 20, 360),
("meat_wolf", "hunting", 28, 24, 360), ("leather_wolf", "hunting", 28, 24, 360),
("meat_bear", "hunting", 34, 30, 420), ("leather_bear", "hunting", 34, 30, 420),
],
"pazar": [
("herb_mint", "foraging", 8, 6, 180), ("stick", "foraging", 4, 3, 120),
],
}
# D&D olay kartları. choices: skill check'li {text, skill, dc, success{}, fail{}} ya da düz {text, outcome{}}
EVENTS = [
("gezgin_tacir", "Gezgin Tacir", "Yolda rengarenk arabasıyla bir tacirle karşılaşıyorsun. Sana el sallıyor.", [
{"text": "Selam ver ve sohbet et", "outcome": {"text": "Tacir sohbetten memnun, sana bir nane çayı ikram ediyor.", "items": [{"code": "tea_mint", "qty": 1}]}},
{"text": "Başını öne eğip yoluna devam et", "outcome": {"text": "Tacir omuz silkip uzaklaşıyor."}},
]),
("haydut_pususu", "Haydut Pususu", "Çalıların arasından iki haydut fırlıyor! 'Adımlarını ya da canını!'", [
{"text": "Savaş!", "skill": "combat", "dc": 12,
"success": {"text": "Haydutları püskürttün! Kaçarken düşürdükleri keseyi aldın.", "items": [{"code": "stone_small", "qty": 3}], "xp": {"skill": "combat", "amount": 25}},
"fail": {"text": "Dayak yedin ve kaçtın. Yorgunluktan adımların eridi.", "steps": -150}},
{"text": "Kaçmayı dene", "skill": "hunting", "dc": 10,
"success": {"text": "Çevik adımlarla izini kaybettirdin.", "xp": {"skill": "hunting", "amount": 15}},
"fail": {"text": "Yakalandın, keseni silkelediler.", "steps": -100}},
{"text": "Adımlarından 100 ver", "outcome": {"text": "Haydutlar parayı alıp gözden kayboldu.", "steps": -100}},
]),
("yarali_gezgin", "Yaralı Gezgin", "Yol kenarında ayağı burkulmuş bir gezgin oturuyor, yardım istiyor.", [
{"text": "Yarasını sar (Simya)", "skill": "alchemy", "dc": 8,
"success": {"text": "Gezgin minnetle teşekkür edip çantasından ot uzatıyor.", "items": [{"code": "herb_mint", "qty": 2}], "xp": {"skill": "alchemy", "amount": 20}},
"fail": {"text": "Elinden geleni yaptın ama pek işe yaramadı. Zaman kaybettin.", "steps": -30}},
{"text": "Görmezden gel", "outcome": {"text": "Vicdanın sızlasa da yoluna devam ettin."}},
]),
("kayip_sandik", "Kayıp Sandık", "Patikanın kenarında yarı gömülü eski bir sandık görüyorsun.", [
{"text": "Kazıp aç", "outcome": {"text": "İçinden işe yarar malzemeler çıktı!", "items": [{"code": "stone_small", "qty": 2}, {"code": "vine", "qty": 2}]}},
{"text": "Tuzak olabilir, dokunma", "outcome": {"text": "Temkinli davranıp uzaklaştın."}},
]),
("dev_ayi", "Dev Ayı", "Önünde iki ayağı üzerinde doğrulmuş kocaman bir ayı! Homurtusu yeri titretiyor.", [
{"text": "Savaş!", "skill": "combat", "dc": 15,
"success": {"text": "Zorlu bir mücadele sonunda ayıyı alt ettin!", "items": [{"code": "meat_bear", "qty": 1}, {"code": "leather_bear", "qty": 1}], "xp": {"skill": "combat", "amount": 40}},
"fail": {"text": "Ayı seni fena hırpaladı. Canını zor kurtardın.", "steps": -200}},
{"text": "Yavaşça geri çekil", "skill": "hunting", "dc": 8,
"success": {"text": "Ses çıkarmadan uzaklaştın.", "xp": {"skill": "hunting", "amount": 10}},
"fail": {"text": "Bir dala bastın! Ayı kovaladı, koşmaktan tükendin.", "steps": -120}},
]),
("mantar_halkasi", "Mantar Halkası", "Bir açıklıkta mükemmel daire çizen mantarlar... Ortasında bir şey parlıyor.", [
{"text": "Dikkatlice topla (Toplama)", "skill": "foraging", "dc": 10,
"success": {"text": "Halkanın ortasından nadir otlar topladın!", "items": [{"code": "herb_chamomile", "qty": 2}], "xp": {"skill": "foraging", "amount": 20}},
"fail": {"text": "Mantarlar zehirliymiş, başın döndü.", "steps": -60}},
{"text": "Uğursuzluktur, dokunma", "outcome": {"text": "Halkaya saygıyla selam verip geçtin."}},
]),
("eski_harabe", "Eski Harabe", "Sarmaşıklarla kaplı yıkık bir taş yapı. İçinden serin bir esinti geliyor.", [
{"text": "İçini araştır (İnşaat)", "skill": "construction", "dc": 12,
"success": {"text": "Sağlam taşlar ve eski aletler buldun!", "items": [{"code": "stone_small", "qty": 4}], "xp": {"skill": "construction", "amount": 25}},
"fail": {"text": "Bir taş kaydı, az daha altında kalıyordun. Hızla çıktın.", "steps": -80}},
{"text": "Dışarıdan bak, devam et", "outcome": {"text": "Harabeyi haritana not edip ilerledin."}},
]),
("nehir_gecidi", "Nehir Geçidi", "Yol taşkın bir dereyle kesilmiş. Sığ bir geçit var ama akıntı hızlı.", [
{"text": "Geçerken balık da yakala (Balıılık)", "skill": "fishing", "dc": 8,
"success": {"text": "Hem karşıya geçtin hem eline bir sazan takıldı!", "items": [{"code": "fish_carp", "qty": 1}], "xp": {"skill": "fishing", "amount": 15}},
"fail": {"text": "Ayağın kaydı, sırılsıklam oldun.", "steps": -40}},
{"text": "Uzun ama güvenli yoldan dolaş", "outcome": {"text": "Ekstra yürüdün ama kuru kaldın.", "steps": -50}},
]),
("gizemli_yasli", "Gizemli Yaşlı", "Bastonlu bir ihtiyar taşın üstünde oturmuş, sana bakıp gülümsüyor: 'Otların dilinden anlar mısın evlat?'", [
{"text": "Yanına otur ve dinle", "outcome": {"text": "Sana otların sırlarını anlattı. Bilgeleştin.", "xp": {"skill": "alchemy", "amount": 20}}},
{"text": "Kibarca reddet", "outcome": {"text": "İhtiyar gülümseyip gözlerini kapadı."}},
]),
("kirik_araba", "Kırık Araba", "Bir çiftçinin arabasının tekeri kırılmış, yükü yola saçılmış.", [
{"text": "Tamir et (İnşaat)", "skill": "construction", "dc": 10,
"success": {"text": "Tekeri taktın! Çiftçi teşekkür olarak sarmaşık ipi verdi.", "items": [{"code": "vine", "qty": 3}], "xp": {"skill": "construction", "amount": 20}},
"fail": {"text": "Uğraştın ama beceremedin. Çiftçi yine de teşekkür etti.", "steps": -30}},
{"text": "Yardım edemem, acelem var", "outcome": {"text": "Çiftçi arkandan söylendi."}},
]),
("saganak", "Sağanak", "Gökyüzü kararıyor, bardaktan boşanırcasına yağmur başlıyor!", [
{"text": "Hızlıca sığınak yap (İnşaat)", "skill": "construction", "dc": 8,
"success": {"text": "Dallardan derme çatma ama işlevsel bir sığınak! Yağmuru kuru geçirdin.", "xp": {"skill": "construction", "amount": 15}},
"fail": {"text": "Sığınak çöktü, iliklerine kadar ıslandın.", "steps": -60}},
{"text": "Yağmurda yürümeye devam et", "outcome": {"text": "Islandın, üşüdün, yoruldun ama yol aldın.", "steps": -30}},
]),
("sansli_bulus", "Şanslı Buluş", "Patikanın hemen kenarında birinin unuttuğu küçük bir malzeme yığını duruyor.", [
{"text": "Topla", "outcome": {"text": "Sopa ve taşları sırt çantana koydun.", "items": [{"code": "stick", "qty": 2}, {"code": "stone_small", "qty": 2}]}},
{"text": "Sahibi gelir, bırak", "outcome": {"text": "Dürüstlüğünle gurur duyarak ilerledin."}},
]),
]
async def main():
conn = await asyncpg.connect(DSN)
for code, name, typ, desc, x, y in LOCATIONS:
await conn.execute("""
INSERT INTO locations (code, name, type, description, map_x, map_y)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (code) DO UPDATE SET name=$2, type=$3, description=$4, map_x=$5, map_y=$6
""", code, name, typ, desc, x, y)
ids = {r["code"]: r["id"] for r in await conn.fetch("SELECT id, code FROM locations")}
for a, b, cost, chance in ROADS:
await conn.execute("""
INSERT INTO roads (loc_a, loc_b, step_cost, event_chance)
VALUES ($1,$2,$3,$4)
ON CONFLICT (loc_a, loc_b) DO UPDATE SET step_cost=$3, event_chance=$4
""", ids[a], ids[b], cost, chance)
n_res = 0
for loc, res in RESOURCES.items():
for item, skill, steps, xp, cd in res:
await conn.execute("""
INSERT INTO location_resources (location_id, item_code, skill, step_cost, xp, cooldown_s)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (location_id, item_code) DO UPDATE SET skill=$3, step_cost=$4, xp=$5, cooldown_s=$6
""", ids[loc], item, skill, steps, xp, cd)
n_res += 1
for code, name, txt, choices in EVENTS:
await conn.execute("""
INSERT INTO events (code, name, text, choices) VALUES ($1,$2,$3,$4::jsonb)
ON CONFLICT (code) DO UPDATE SET name=$2, text=$3, choices=$4::jsonb
""", code, name, txt, json.dumps(choices))
# Konumu olmayan karakterleri köye koy
moved = await conn.execute(
"UPDATE characters SET location_id=$1 WHERE location_id IS NULL", ids["koy"])
print(f"Lokasyon: {len(LOCATIONS)}, yol: {len(ROADS)}, kaynak: {n_res}, olay: {len(EVENTS)}. Karakter taşındı: {moved}")
await conn.close()
asyncio.run(main())

76
deps.py Normal file
View file

@ -0,0 +1,76 @@
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models import Role, User, get_db
from security import decode_token
# Eski davranışla uyumluluk: bu hesaplar rol atanmasa da tam yetkili.
LEGACY_ADMIN_EMAILS = {"jts@jts.rocks", "afm@jts.rocks"}
# Panel izin kataloğu (kullanıcı yönetimi UI'ı buradan okur)
PERMISSIONS = {
"upload": "Dosya yükleme + Asset Takip",
"lore": "Lore notları (görme + ekleme + düzenleme)",
"map_view": "Harita editörünü görüntüleme (salt okunur)",
"npc": "NPC listesi (görme + ekleme + düzenleme)",
"map_edit": "Harita etiketi ekleme / düzenleme / silme",
"chars": "Karakterler (sınıf + skill tanımları)",
"users": "Kullanıcı ve rol yönetimi",
}
async def get_user_perms(user: User, db: AsyncSession) -> set[str]:
if user.email in LEGACY_ADMIN_EMAILS:
return {"*"}
if not user.role_id:
return set()
role = (await db.execute(select(Role).where(Role.id == user.role_id))).scalar_one_or_none()
return set(role.permissions or []) if role else set()
def has_perm(perms: set[str], perm: str) -> bool:
return "*" in perms or perm in perms
def require_perm(perm: str):
async def dep(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
perms = await get_user_perms(user, db)
if not has_perm(perms, perm):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Yetkin yok")
return user
return dep
async def get_current_user(
authorization: Annotated[str | None, Header()] = None,
db: AsyncSession = Depends(get_db),
) -> User:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
token = authorization.split(None, 1)[1].strip()
try:
payload = decode_token(token)
except Exception as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}")
user_id = int(payload["sub"])
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or inactive")
return user
async def require_panel(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> User:
"""Herhangi bir panel yetkisi olan (bir rolü/izni bulunan) her yetkili."""
perms = await get_user_perms(user, db)
if not perms:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Panel yetkin yok")
return user

65
email_utils.py Normal file
View file

@ -0,0 +1,65 @@
import asyncio
import smtplib
import ssl
from email.message import EmailMessage
from config import settings
def _send_sync(to: str, subject: str, body: str, html: str | None = None) -> None:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.SMTP_FROM
msg["To"] = to
msg.set_content(body)
if html:
msg.add_alternative(html, subtype="html")
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as s:
s.ehlo()
if settings.SMTP_TLS:
ctx = ssl.create_default_context()
s.starttls(context=ctx)
s.ehlo()
if settings.SMTP_USER:
s.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
s.send_message(msg)
async def send_email(to: str, subject: str, body: str, html: str | None = None) -> None:
await asyncio.to_thread(_send_sync, to, subject, body, html)
def verify_email_template(verify_url: str) -> tuple[str, str]:
body = (
f"Stepstead'e hoş geldin!\n\n"
f"E-postanı doğrulamak için aşağıdaki bağlantıya tıkla:\n{verify_url}\n\n"
f"Bağlantı 24 saat geçerlidir.\n"
)
html = f"""
<html><body style="font-family:sans-serif;max-width:600px;margin:auto;background:#f5f3ee;padding:24px">
<h2 style="color:#3a5a40">Stepstead E-posta Doğrulama</h2>
<p>Hoş geldin! Yolculuğa başlamak için e-postanı doğrula.</p>
<p><a href="{verify_url}" style="background:#3a5a40;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px">E-postamı Doğrula</a></p>
<p style="color:#666;font-size:12px">Buton çalışmazsa: <code>{verify_url}</code></p>
<p style="color:#666;font-size:12px">Bu bağlantı 24 saat geçerlidir.</p>
</body></html>
"""
return body, html
def password_reset_template(reset_url: str) -> tuple[str, str]:
body = (
f"Stepstead — Şifre sıfırlama talebin alındı.\n\n"
f"Yeni şifre belirlemek için:\n{reset_url}\n\n"
f"Bağlantı 1 saat geçerlidir. Talep sana ait değilse bu e-postayı yok say.\n"
)
html = f"""
<html><body style="font-family:sans-serif;max-width:600px;margin:auto;background:#f5f3ee;padding:24px">
<h2 style="color:#3a5a40">Stepstead Şifre Sıfırlama</h2>
<p>Şifre sıfırlama talebin alındı.</p>
<p><a href="{reset_url}" style="background:#3a5a40;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px">Şifremi Sıfırla</a></p>
<p style="color:#666;font-size:12px">Bağlantı 1 saat geçerlidir.</p>
</body></html>
"""
return body, html

30
game_logic.py Normal file
View file

@ -0,0 +1,30 @@
"""
Oyun mantığı yardımcıları: XP eğrisi, level hesabı.
"""
from config import settings
# Her skill için XP eğrisi: level N → toplam XP eşiği
# Basit kare: 100 * N^2 (level 50 = 250000 XP)
def xp_threshold_for_level(level: int) -> int:
return 100 * level * level
def skill_level_from_xp(xp: int) -> int:
lvl = 1
while lvl < 50 and xp >= xp_threshold_for_level(lvl + 1):
lvl += 1
return lvl
def character_level_from_total_xp(total_xp: int) -> int:
# Karakter level: tüm skill XP toplamı eşiği — daha hızlı eğri
# Level 1 → 0 XP, Level 50 → 10*250000 = 2.5M XP (zor)
# Karakter eğrisi: 500 * N^2
lvl = 1
while lvl < 50 and total_xp >= 500 * (lvl + 1) * (lvl + 1):
lvl += 1
return lvl
def is_valid_skill(name: str) -> bool:
return name in settings.SKILL_NAMES

72
main.py Normal file
View file

@ -0,0 +1,72 @@
"""
Stepstead MMO toplayıcı oyun backend
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from config import settings
from routers import auth as auth_router
from routers import character as character_router
from routers import world as world_router
from routers import harvest as harvest_router
from routers import craft as craft_router
from routers import travel as travel_router
from routers import market as market_router
from routers import leaderboard as leaderboard_router
from routers import admin as admin_router
from routers import map_tags as map_tags_router
from routers import lore as lore_router
from routers import npcs as npcs_router
from routers import quests as quests_router
from routers import chars as chars_router
from routers import ws as ws_router
app = FastAPI(title="Stepstead API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://stepstead.com", "https://www.stepstead.com", "*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(character_router.router)
app.include_router(world_router.router)
app.include_router(harvest_router.router)
app.include_router(craft_router.router)
app.include_router(travel_router.router)
app.include_router(market_router.router)
app.include_router(leaderboard_router.router)
app.include_router(admin_router.router)
app.include_router(map_tags_router.router)
app.include_router(lore_router.router)
app.include_router(npcs_router.router)
app.include_router(quests_router.router)
app.include_router(chars_router.router)
app.include_router(ws_router.router)
@app.get("/health")
async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "0.1.0"}
@app.get("/db-health")
async def db_health():
from sqlalchemy import text
from models import AsyncSessionLocal
import cache as redis_cache
db_ok = False
db_tables = 0
try:
async with AsyncSessionLocal() as s:
r = await s.execute(text("SELECT count(*) FROM information_schema.tables WHERE table_schema='public'"))
db_tables = r.scalar_one()
db_ok = True
except Exception as e:
return {"db": False, "db_error": str(e), "redis": False}
redis_ok = await redis_cache.ping()
return {"db": db_ok, "db_tables": db_tables, "redis": redis_ok}

9
models/__init__.py Normal file
View file

@ -0,0 +1,9 @@
from .database import AsyncSessionLocal, Base, engine, get_db
from .character import Character, Skill
from .auth import PasswordResetToken, Role, User
__all__ = [
"Base", "engine", "AsyncSessionLocal", "get_db",
"User", "PasswordResetToken", "Role",
"Character", "Skill",
]

58
models/auth.py Normal file
View file

@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
name: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
permissions: Mapped[list] = mapped_column(JSONB, default=list, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(64))
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
email_verify_token: Mapped[Optional[str]] = mapped_column(String(128))
email_verify_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
role_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("roles.id", ondelete="SET NULL"))
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
reset_tokens: Mapped[list["PasswordResetToken"]] = relationship(
back_populates="user", cascade="all, delete-orphan"
)
characters: Mapped[list["Character"]] = relationship(back_populates="user", cascade="all, delete-orphan")
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped[User] = relationship(back_populates="reset_tokens")
# forward import to avoid circular
from .character import Character # noqa: E402

51
models/character.py Normal file
View file

@ -0,0 +1,51 @@
from datetime import date, datetime
from typing import Optional
from sqlalchemy import BigInteger, Date, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class Character(Base):
__tablename__ = "characters"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
level: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
total_xp: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
gold: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
step_balance: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
step_lifetime: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
pos_chunk_x: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # eski tile sistemi, kullanılmıyor
pos_chunk_y: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
pos_x: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
pos_y: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# FK DB'de var; locations tablosunun ORM modeli yok, o yüzden burada düz kolon
location_id: Mapped[Optional[int]] = mapped_column(BigInteger)
pending_event: Mapped[Optional[dict]] = mapped_column(JSONB)
# Günlük hedef + streak
daily_steps: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
daily_date: Mapped[Optional[date]] = mapped_column(Date)
streak_days: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_claim_date: Mapped[Optional[date]] = mapped_column(Date)
last_sync_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
user: Mapped["User"] = relationship(back_populates="characters") # noqa: F821
skills: Mapped[list["Skill"]] = relationship(back_populates="character", cascade="all, delete-orphan")
class Skill(Base):
__tablename__ = "skills"
character_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("characters.id", ondelete="CASCADE"), primary_key=True)
skill_name: Mapped[str] = mapped_column(String(32), primary_key=True)
xp: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
level: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
character: Mapped[Character] = relationship(back_populates="skills")

22
models/database.py Normal file
View file

@ -0,0 +1,22 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from config import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=settings.DEBUG,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session

14
requirements.txt Normal file
View file

@ -0,0 +1,14 @@
fastapi==0.136.1
uvicorn[standard]==0.46.0
pydantic==2.13.3
pydantic-settings==2.14.0
SQLAlchemy==2.0.49
asyncpg==0.31.0
psycopg2-binary==2.9.10
redis==7.4.0
bcrypt==5.0.0
PyJWT==2.10.1
email-validator==2.3.0
python-dotenv==1.2.2
httpx==0.28.1
websockets==15.0.1

0
routers/__init__.py Normal file
View file

267
routers/admin.py Normal file
View file

@ -0,0 +1,267 @@
"""
Admin asset upload (harita görselleri vb.).
Sadece ADMIN_EMAILS listesindeki hesaplar kullanabilir (oyun JWT'si ile).
Dosyalar /var/www/stepstead.com/assets/uploads/ altına yazılır ve
https://stepstead.com/assets/uploads/<ad> ile servis edilir.
"""
import re
import unicodedata
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from deps import LEGACY_ADMIN_EMAILS as ADMIN_EMAILS
from deps import PERMISSIONS, require_perm
from models import Role, User, get_db
from security import hash_password
router = APIRouter(prefix="/admin", tags=["admin"])
UPLOAD_DIR = Path("/var/www/stepstead.com/assets/uploads")
MAX_SIZE = 30 * 1024 * 1024 # 30 MB
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".svg", ".json", ".zip"}
def _clean_name(s: str) -> str:
"""Dosya adı temizliği: Türkçe dahil Unicode harf/rakam korunur (NFC'ye
normalize edilir iOS NFD gönderir), sadece tehlikeli karakterler atılır."""
s = unicodedata.normalize("NFC", s)
s = re.sub(r"[/\\\x00-\x1f]", "", s)
return s.strip().strip(".")[:80]
def _url(name: str) -> str:
return "https://stepstead.com/assets/uploads/" + quote(name)
# Geriye dönük: eski kod yolu; artık rol tabanlı require_perm kullanılıyor.
def _require_admin(user: User) -> None:
if user.email not in ADMIN_EMAILS:
raise HTTPException(403, "Yetkin yok")
class UploadOut(BaseModel):
filename: str
url: str
size: int
@router.post("/upload", response_model=UploadOut)
async def upload_asset(
file: UploadFile,
target_name: str | None = Form(None),
user: User = Depends(require_perm("upload")),
):
orig = Path(file.filename or "dosya").name
ext = Path(orig).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, f"İzinli uzantılar: {', '.join(sorted(ALLOWED_EXT))}")
if target_name:
# Asset listesinden yükleme: "up_<asset adı>.<ext>" — up_ öneki
# "buradan geldi, boyut/uygunluk kontrolü bekliyor" demek.
clean = _clean_name(target_name)
if not clean:
raise HTTPException(400, "Geçersiz hedef ad")
name = f"up_{clean}{ext}"
else:
stem = _clean_name(Path(orig).stem) or "dosya"
name = f"{stem}{ext}"
data = await file.read()
if len(data) > MAX_SIZE:
raise HTTPException(400, "Dosya çok büyük (max 30 MB)")
if not data:
raise HTTPException(400, "Boş dosya")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
(UPLOAD_DIR / name).write_bytes(data)
return UploadOut(filename=name, url=_url(name), size=len(data))
class FileRow(BaseModel):
filename: str
url: str
size: int
@router.get("/uploads", response_model=list[FileRow])
async def list_uploads(user: User = Depends(require_perm("upload"))):
out: list[FileRow] = []
if UPLOAD_DIR.exists():
for p in sorted(UPLOAD_DIR.iterdir()):
if p.is_file():
out.append(FileRow(filename=p.name, url=_url(p.name),
size=p.stat().st_size))
return out
# ---------------------------------------------------------------------------
# Kullanıcı & rol yönetimi — sadece "users" izni (admin)
# ---------------------------------------------------------------------------
class RoleOut(BaseModel):
id: int
name: str
permissions: list[str]
is_system: bool
user_count: int = 0
class RoleIn(BaseModel):
name: str = Field(min_length=2, max_length=40)
permissions: list[str] = []
class AdminUserOut(BaseModel):
id: int
email: str
display_name: str | None
role_id: int | None
role_name: str | None
is_active: bool
created_at: str
class UserCreateIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=64)
role_id: int | None = None
class UserUpdateIn(BaseModel):
role_id: int | None = None
is_active: bool | None = None
def _valid_perms(perms: list[str]) -> list[str]:
bad = [p for p in perms if p not in PERMISSIONS and p != "*"]
if bad:
raise HTTPException(400, f"Bilinmeyen izin: {', '.join(bad)}")
return sorted(set(perms))
@router.get("/permissions")
async def list_permissions(user: User = Depends(require_perm("users"))):
return PERMISSIONS
@router.get("/roles", response_model=list[RoleOut])
async def list_roles(user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
counts = dict((await db.execute(
select(User.role_id, func.count()).where(User.role_id.isnot(None)).group_by(User.role_id)
)).all())
roles = (await db.execute(select(Role).order_by(Role.id))).scalars().all()
return [RoleOut(id=r.id, name=r.name, permissions=list(r.permissions or []),
is_system=r.is_system, user_count=counts.get(r.id, 0)) for r in roles]
@router.post("/roles", response_model=RoleOut)
async def create_role(data: RoleIn, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
exists = (await db.execute(select(Role).where(Role.name == data.name))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Bu isimde rol zaten var")
role = Role(name=data.name, permissions=_valid_perms(data.permissions))
db.add(role)
await db.commit()
await db.refresh(role)
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
is_system=role.is_system)
@router.post("/roles/{role_id}", response_model=RoleOut)
async def update_role(role_id: int, data: RoleIn,
user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
if not role:
raise HTTPException(404, "Rol yok")
if role.is_system:
raise HTTPException(400, "Sistem rolü (admin) değiştirilemez")
role.name = data.name
role.permissions = _valid_perms(data.permissions)
await db.commit()
return RoleOut(id=role.id, name=role.name, permissions=list(role.permissions),
is_system=role.is_system)
@router.post("/roles/{role_id}/delete")
async def delete_role(role_id: int, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none()
if not role:
raise HTTPException(404, "Rol yok")
if role.is_system:
raise HTTPException(400, "Sistem rolü silinemez")
in_use = (await db.execute(select(func.count()).where(User.role_id == role_id))).scalar()
if in_use:
raise HTTPException(400, f"Rol {in_use} kullanıcıda atanmış — önce onları değiştir")
await db.delete(role)
await db.commit()
return {"deleted": role_id}
def _user_out(u: User, role_name: str | None) -> AdminUserOut:
return AdminUserOut(id=u.id, email=u.email, display_name=u.display_name,
role_id=u.role_id, role_name=role_name,
is_active=u.is_active, created_at=u.created_at.isoformat())
@router.get("/users", response_model=list[AdminUserOut])
async def list_users(user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
rows = (await db.execute(
select(User, Role.name).outerjoin(Role, Role.id == User.role_id).order_by(User.id)
)).all()
return [_user_out(u, rn) for u, rn in rows]
@router.post("/users", response_model=AdminUserOut)
async def create_user(data: UserCreateIn, user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
email = str(data.email).lower()
exists = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Bu e-posta zaten kayıtlı")
role_name = None
if data.role_id is not None:
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
if not role:
raise HTTPException(400, "Rol yok")
role_name = role.name
u = User(email=email, password_hash=hash_password(data.password),
display_name=data.display_name, email_verified=True, role_id=data.role_id)
db.add(u)
await db.commit()
await db.refresh(u)
return _user_out(u, role_name)
@router.post("/users/{user_id}", response_model=AdminUserOut)
async def update_user(user_id: int, data: UserUpdateIn,
user: User = Depends(require_perm("users")),
db: AsyncSession = Depends(get_db)):
u = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if not u:
raise HTTPException(404, "Kullanıcı yok")
if "role_id" in data.model_fields_set: # role_id:null gönderilirse rol kaldırılır
if data.role_id is not None:
role = (await db.execute(select(Role).where(Role.id == data.role_id))).scalar_one_or_none()
if not role:
raise HTTPException(400, "Rol yok")
u.role_id = data.role_id
if data.is_active is not None:
if u.id == user.id and not data.is_active:
raise HTTPException(400, "Kendini pasifleştiremezsin")
u.is_active = data.is_active
await db.commit()
role_name = None
if u.role_id:
role_name = (await db.execute(select(Role.name).where(Role.id == u.role_id))).scalar_one_or_none()
return _user_out(u, role_name)

180
routers/auth.py Normal file
View file

@ -0,0 +1,180 @@
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from deps import get_current_user, get_user_perms
from email_utils import password_reset_template, send_email, verify_email_template
from models import PasswordResetToken, User, get_db
from security import create_access_token, hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(None, max_length=64)
class LoginIn(BaseModel):
email: EmailStr
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in_minutes: int = settings.ACCESS_TOKEN_EXPIRE_MINUTES
class UserOut(BaseModel):
id: int
email: str
display_name: str | None
email_verified: bool
is_admin: bool
role: str | None = None
permissions: list[str] = []
class Config:
from_attributes = True
class RequestResetIn(BaseModel):
email: EmailStr
class ConfirmResetIn(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(data: RegisterIn, db: AsyncSession = Depends(get_db)):
exists = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Bu e-posta adresi zaten kayıtlı")
token = secrets.token_urlsafe(32)
user = User(
email=str(data.email).lower(),
password_hash=hash_password(data.password),
display_name=data.display_name,
email_verified=False,
email_verify_token=token,
email_verify_sent_at=datetime.now(timezone.utc),
)
db.add(user)
await db.commit()
await db.refresh(user)
verify_url = f"{settings.APP_URL}/verify-email?token={token}"
body, html = verify_email_template(verify_url)
try:
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
except Exception as e:
print(f"[email] verify mail failed: {e}")
return user
@router.post("/login", response_model=TokenOut)
async def login(data: LoginIn, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(401, "Geçersiz e-posta veya şifre")
if not user.is_active:
raise HTTPException(403, "Hesap askıya alınmış")
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
return TokenOut(access_token=create_access_token(user.id, {"email": user.email}))
@router.get("/verify-email")
async def verify_email(token: str, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email_verify_token == token))).scalar_one_or_none()
if not user:
raise HTTPException(400, "Geçersiz veya kullanılmış token")
sent_at = user.email_verify_sent_at
if sent_at and (datetime.now(timezone.utc) - sent_at) > timedelta(hours=24):
raise HTTPException(400, "Token süresi dolmuş")
user.email_verified = True
user.email_verify_token = None
await db.commit()
return {"status": "ok", "message": "E-posta doğrulandı"}
@router.post("/resend-verification")
async def resend_verification(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
if not user:
return {"status": "ok"}
if user.email_verified:
return {"status": "already_verified"}
token = secrets.token_urlsafe(32)
user.email_verify_token = token
user.email_verify_sent_at = datetime.now(timezone.utc)
await db.commit()
body, html = verify_email_template(f"{settings.APP_URL}/verify-email?token={token}")
await send_email(user.email, "Stepstead — E-posta Doğrulama", body, html)
return {"status": "ok"}
@router.post("/request-password-reset")
async def request_password_reset(data: RequestResetIn, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email == str(data.email).lower()))).scalar_one_or_none()
if not user:
return {"status": "ok"}
token = secrets.token_urlsafe(32)
db.add(PasswordResetToken(
user_id=user.id,
token=token,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
))
await db.commit()
body, html = password_reset_template(f"{settings.APP_URL}/reset-password?token={token}")
try:
await send_email(user.email, "Stepstead — Şifre Sıfırlama", body, html)
except Exception as e:
print(f"[email] reset mail failed: {e}")
return {"status": "ok"}
@router.post("/reset-password")
async def reset_password(data: ConfirmResetIn, db: AsyncSession = Depends(get_db)):
rt = (await db.execute(select(PasswordResetToken).where(PasswordResetToken.token == data.token))).scalar_one_or_none()
if not rt or rt.used_at is not None:
raise HTTPException(400, "Geçersiz veya kullanılmış token")
if rt.expires_at < datetime.now(timezone.utc):
raise HTTPException(400, "Token süresi dolmuş")
user = (await db.execute(select(User).where(User.id == rt.user_id))).scalar_one_or_none()
if not user:
raise HTTPException(400, "Kullanıcı bulunamadı")
user.password_hash = hash_password(data.new_password)
rt.used_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "ok", "message": "Şifre değiştirildi"}
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
out = UserOut.model_validate(user, from_attributes=True)
perms = await get_user_perms(user, db)
out.permissions = sorted(perms)
if user.role_id:
from models import Role
out.role = (await db.execute(select(Role.name).where(Role.id == user.role_id))).scalar_one_or_none()
elif "*" in perms:
out.role = "admin"
return out

90
routers/character.py Normal file
View file

@ -0,0 +1,90 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from deps import get_current_user
from models import Character, Skill, User, get_db
router = APIRouter(prefix="/character", tags=["character"])
class CharacterCreateIn(BaseModel):
name: str = Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_]+$")
class SkillOut(BaseModel):
skill_name: str
xp: int
level: int
class CharacterOut(BaseModel):
id: int
name: str
level: int
total_xp: int
gold: int
step_balance: int
step_lifetime: int
location_code: Optional[str] = None
location_name: Optional[str] = None
has_pending_event: bool = False
skills: list[SkillOut]
class Config:
from_attributes = True
async def _with_location(db: AsyncSession, char: Character) -> CharacterOut:
out = CharacterOut.model_validate(char, from_attributes=True)
out.has_pending_event = char.pending_event is not None
if char.location_id is not None:
row = (await db.execute(text(
"SELECT code, name FROM locations WHERE id = :i"
), {"i": char.location_id})).first()
if row:
out.location_code, out.location_name = row[0], row[1]
return out
@router.post("", response_model=CharacterOut, status_code=status.HTTP_201_CREATED)
async def create_character(data: CharacterCreateIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
exists = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if exists:
raise HTTPException(409, "Zaten bir karakterin var")
name_taken = (await db.execute(select(Character).where(Character.name == data.name))).scalar_one_or_none()
if name_taken:
raise HTTPException(409, "Bu isim alınmış")
char = Character(user_id=user.id, name=data.name)
db.add(char)
await db.flush()
for skill_name in settings.SKILL_NAMES:
db.add(Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1))
# Başlangıç lokasyonu: köy. Not: başlangıç tool'u YOK —
# ilk tool'lar köyde elle toplanan taş/sopa/sarmaşıktan craft edilir.
start = (await db.execute(text(
"SELECT id FROM locations WHERE type = 'village' ORDER BY id LIMIT 1"
))).first()
if start:
char.location_id = start[0]
await db.commit()
await db.refresh(char, ["skills"])
return await _with_location(db, char)
@router.get("/me", response_model=CharacterOut)
async def my_character(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok, önce oluştur")
await db.refresh(char, ["skills"])
return await _with_location(db, char)

296
routers/chars.py Normal file
View file

@ -0,0 +1,296 @@
"""
Karakterler sınıf (class) + skill tanımları ("chars" izni).
Sınıf: ne yapar, rolü. Skill: max level, ne yaptığı, bağlı sınıf.
WAF nedeniyle sadece GET/POST.
GET /chars/classes sınıf listesi
POST /chars/classes yeni sınıf
POST /chars/classes/{id} güncelle
POST /chars/classes/{id}/delete sil
GET /chars/skills skill listesi (sınıf adıyla)
POST /chars/skills yeni skill
POST /chars/skills/{id} güncelle
POST /chars/skills/{id}/delete sil
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm, require_panel, get_user_perms, has_perm
from models import User, get_db
router = APIRouter(prefix="/chars", tags=["chars"])
def _author(user: User) -> str:
return (user.display_name or "").strip() or user.email
async def _assert_owner_or_admin(db: AsyncSession, user: User, table: str, row_id: int):
perms = await get_user_perms(user, db)
if has_perm(perms, "*"):
return
owner = (await db.execute(text(
f"SELECT author_id FROM {table} WHERE id=:id"
), {"id": row_id})).scalar_one_or_none()
if owner is None:
raise HTTPException(404, "Kayıt yok")
if owner != user.id:
raise HTTPException(403, "Sadece kendi kaydını düzenleyebilir/silebilirsin")
# ---------------- Sınıflar ----------------
class ClassIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
summary: str | None = Field(default=None, max_length=300)
description: str | None = Field(default=None, max_length=20000)
notes: str | None = Field(default=None, max_length=20000)
sort_order: int = 0
class ClassOut(BaseModel):
id: int
name: str
summary: str | None
description: str | None
notes: str | None
sort_order: int
author: str | None
author_id: int | None
updated_at: str
def _crow(r) -> ClassOut:
d = dict(r)
d["updated_at"] = d["updated_at"].isoformat()
return ClassOut(**d)
@router.get("/classes", response_model=list[ClassOut])
async def list_classes(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT id,name,summary,description,notes,sort_order,author,author_id,updated_at "
"FROM game_classes ORDER BY sort_order,name"
))).mappings().all()
return [_crow(r) for r in rows]
@router.post("/classes", response_model=ClassOut)
async def add_class(c: ClassIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
row = (await db.execute(text(
"INSERT INTO game_classes(name,summary,description,notes,sort_order,author,author_id) "
"VALUES(:name,:summary,:description,:notes,:so,:author,:aid) "
"RETURNING id,name,summary,description,notes,sort_order,author,author_id,updated_at"
), {"name": c.name, "summary": c.summary, "description": c.description, "notes": c.notes,
"so": c.sort_order, "author": _author(user), "aid": user.id})).mappings().one()
await db.commit()
return _crow(row)
@router.post("/classes/{class_id}", response_model=ClassOut)
async def update_class(class_id: int, c: ClassIn, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_classes", class_id)
row = (await db.execute(text(
"UPDATE game_classes SET name=:name,summary=:summary,description=:description,notes=:notes,"
"sort_order=:so,updated_at=now() WHERE id=:id "
"RETURNING id,name,summary,description,notes,sort_order,author,author_id,updated_at"
), {"id": class_id, "name": c.name, "summary": c.summary, "description": c.description,
"notes": c.notes, "so": c.sort_order})).mappings().one_or_none()
if not row:
raise HTTPException(404, "Sınıf yok")
await db.commit()
return _crow(row)
@router.post("/classes/{class_id}/delete")
async def delete_class(class_id: int, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_classes", class_id)
res = await db.execute(text("DELETE FROM game_classes WHERE id=:id"), {"id": class_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Sınıf yok")
return {"deleted": class_id}
# ---------------- Skiller ----------------
class SkillIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
max_level: int | None = Field(default=None, ge=0, le=1000)
effect: str | None = Field(default=None, max_length=20000)
description: str | None = Field(default=None, max_length=20000)
class_id: int | None = None
notes: str | None = Field(default=None, max_length=20000)
sort_order: int = 0
class SkillOut(BaseModel):
id: int
name: str
max_level: int | None
effect: str | None
description: str | None
class_id: int | None
class_name: str | None
notes: str | None
sort_order: int
author: str | None
author_id: int | None
updated_at: str
def _srow(r) -> SkillOut:
d = dict(r)
d["updated_at"] = d["updated_at"].isoformat()
return SkillOut(**d)
async def _check_class(db: AsyncSession, class_id: int | None) -> None:
if class_id is None:
return
ok = (await db.execute(text("SELECT 1 FROM game_classes WHERE id=:id"), {"id": class_id})).scalar()
if not ok:
raise HTTPException(400, "Sınıf bulunamadı")
@router.get("/skills", response_model=list[SkillOut])
async def list_skills(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT s.id,s.name,s.max_level,s.effect,s.description,s.class_id,c.name AS class_name,"
"s.notes,s.sort_order,s.author,s.author_id,s.updated_at "
"FROM game_skills s LEFT JOIN game_classes c ON c.id=s.class_id "
"ORDER BY s.sort_order,s.name"
))).mappings().all()
return [_srow(r) for r in rows]
@router.post("/skills", response_model=SkillOut)
async def add_skill(s: SkillIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
await _check_class(db, s.class_id)
sid = (await db.execute(text(
"INSERT INTO game_skills(name,max_level,effect,description,class_id,notes,sort_order,author,author_id) "
"VALUES(:name,:mx,:effect,:description,:cid,:notes,:so,:author,:aid) RETURNING id"
), {"name": s.name, "mx": s.max_level, "effect": s.effect, "description": s.description,
"cid": s.class_id, "notes": s.notes, "so": s.sort_order,
"author": _author(user), "aid": user.id})).scalar_one()
await db.commit()
return await _get_skill(db, sid)
@router.post("/skills/{skill_id}", response_model=SkillOut)
async def update_skill(skill_id: int, s: SkillIn, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_skills", skill_id)
await _check_class(db, s.class_id)
res = await db.execute(text(
"UPDATE game_skills SET name=:name,max_level=:mx,effect=:effect,description=:description,"
"class_id=:cid,notes=:notes,sort_order=:so,updated_at=now() WHERE id=:id"
), {"id": skill_id, "name": s.name, "mx": s.max_level, "effect": s.effect,
"description": s.description, "cid": s.class_id, "notes": s.notes, "so": s.sort_order})
if res.rowcount == 0:
raise HTTPException(404, "Skill yok")
await db.commit()
return await _get_skill(db, skill_id)
@router.post("/skills/{skill_id}/delete")
async def delete_skill(skill_id: int, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_skills", skill_id)
res = await db.execute(text("DELETE FROM game_skills WHERE id=:id"), {"id": skill_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Skill yok")
return {"deleted": skill_id}
async def _get_skill(db: AsyncSession, skill_id: int) -> SkillOut:
row = (await db.execute(text(
"SELECT s.id,s.name,s.max_level,s.effect,s.description,s.class_id,c.name AS class_name,"
"s.notes,s.sort_order,s.author,s.author_id,s.updated_at "
"FROM game_skills s LEFT JOIN game_classes c ON c.id=s.class_id WHERE s.id=:id"
), {"id": skill_id})).mappings().one()
return _srow(row)
# ---------------- Irklar ----------------
class RaceIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
summary: str | None = Field(default=None, max_length=300)
description: str | None = Field(default=None, max_length=20000)
traits: str | None = Field(default=None, max_length=20000)
notes: str | None = Field(default=None, max_length=20000)
sort_order: int = 0
class RaceOut(BaseModel):
id: int
name: str
summary: str | None
description: str | None
traits: str | None
notes: str | None
sort_order: int
author: str | None
author_id: int | None
updated_at: str
def _rrow(r) -> RaceOut:
d = dict(r)
d["updated_at"] = d["updated_at"].isoformat()
return RaceOut(**d)
_RACE_COLS = "id,name,summary,description,traits,notes,sort_order,author,author_id,updated_at"
@router.get("/races", response_model=list[RaceOut])
async def list_races(user: User = Depends(require_panel), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
f"SELECT {_RACE_COLS} FROM game_races ORDER BY sort_order,name"
))).mappings().all()
return [_rrow(r) for r in rows]
@router.post("/races", response_model=RaceOut)
async def add_race(r: RaceIn, user: User = Depends(require_perm("chars")), db: AsyncSession = Depends(get_db)):
row = (await db.execute(text(
"INSERT INTO game_races(name,summary,description,traits,notes,sort_order,author,author_id) "
"VALUES(:name,:summary,:description,:traits,:notes,:so,:author,:aid) "
f"RETURNING {_RACE_COLS}"
), {"name": r.name, "summary": r.summary, "description": r.description, "traits": r.traits,
"notes": r.notes, "so": r.sort_order, "author": _author(user), "aid": user.id})).mappings().one()
await db.commit()
return _rrow(row)
@router.post("/races/{race_id}", response_model=RaceOut)
async def update_race(race_id: int, r: RaceIn, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_races", race_id)
row = (await db.execute(text(
"UPDATE game_races SET name=:name,summary=:summary,description=:description,traits=:traits,"
"notes=:notes,sort_order=:so,updated_at=now() WHERE id=:id "
f"RETURNING {_RACE_COLS}"
), {"id": race_id, "name": r.name, "summary": r.summary, "description": r.description,
"traits": r.traits, "notes": r.notes, "so": r.sort_order})).mappings().one_or_none()
if not row:
raise HTTPException(404, "Irk yok")
await db.commit()
return _rrow(row)
@router.post("/races/{race_id}/delete")
async def delete_race(race_id: int, user: User = Depends(require_perm("chars")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "game_races", race_id)
res = await db.execute(text("DELETE FROM game_races WHERE id=:id"), {"id": race_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Irk yok")
return {"deleted": race_id}

251
routers/craft.py Normal file
View file

@ -0,0 +1,251 @@
"""
Crafting: tarifleri listele, üret.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from game_logic import character_level_from_total_xp, skill_level_from_xp
from models import Character, Skill, User, get_db
router = APIRouter(prefix="/craft", tags=["craft"])
class IngredientOut(BaseModel):
item_code: str
item_name: str
qty: int
have: int
class RecipeOut(BaseModel):
id: int
code: str
name: str
output_item_code: str
output_item_name: str
output_qty: int
skill: str
required_level: int
xp_reward: int
step_cost: int
ingredients: list[IngredientOut]
unlocked: bool
craftable: bool
async def _char(db: AsyncSession, user: User) -> Character:
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
return char
async def _inventory_map(db: AsyncSession, char_id: int) -> dict[str, int]:
rows = (await db.execute(text("""
SELECT i.code, SUM(inv.qty)::int AS qty
FROM inventory inv JOIN items i ON i.id = inv.item_id
WHERE inv.character_id = :cid GROUP BY i.code
"""), {"cid": char_id})).all()
return {r[0]: r[1] for r in rows}
async def _skill_levels(db: AsyncSession, char_id: int) -> dict[str, int]:
rows = (await db.execute(text(
"SELECT skill_name, level FROM skills WHERE character_id = :cid"
), {"cid": char_id})).all()
return {r[0]: r[1] for r in rows}
@router.get("/recipes", response_model=list[RecipeOut])
async def list_recipes(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
inv = await _inventory_map(db, char.id)
skl = await _skill_levels(db, char.id)
bal = char.step_balance
rows = (await db.execute(text("""
SELECT r.id, r.code, r.name, r.output_item_id, r.output_qty,
r.skill, r.required_level, r.xp_reward, r.step_cost, r.ingredients,
oi.code, oi.name
FROM recipes r JOIN items oi ON oi.id = r.output_item_id
ORDER BY r.skill, r.required_level
"""))).all()
out: list[RecipeOut] = []
for r in rows:
ings: list[IngredientOut] = []
all_ok = True
for ing in r[9]: # JSONB
code = ing["item_code"]
qty = int(ing["qty"])
item_row = (await db.execute(text("SELECT name FROM items WHERE code=:c"), {"c": code})).first()
have = inv.get(code, 0)
if have < qty:
all_ok = False
ings.append(IngredientOut(item_code=code, item_name=item_row[0] if item_row else code, qty=qty, have=have))
unlocked = skl.get(r[5], 1) >= r[6]
affordable_steps = bal >= r[8]
craftable = unlocked and all_ok and affordable_steps
out.append(RecipeOut(
id=r[0], code=r[1], name=r[2],
output_item_code=r[10], output_item_name=r[11], output_qty=r[4],
skill=r[5], required_level=r[6], xp_reward=r[7], step_cost=r[8],
ingredients=ings, unlocked=unlocked, craftable=craftable,
))
return out
class CraftIn(BaseModel):
recipe_code: str
qty: int = Field(1, ge=1, le=50)
class CraftOut(BaseModel):
output_item_code: str
output_item_name: str
output_qty: int
skill: str
xp_gained: int
skill_level: int
skill_level_up: bool
character_level: int
character_level_up: bool
step_balance: int
@router.post("", response_model=CraftOut)
async def craft(c: CraftIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
rcp = (await db.execute(text("""
SELECT id, output_item_id, output_qty, skill, required_level, xp_reward, step_cost, ingredients
FROM recipes WHERE code = :c
"""), {"c": c.recipe_code})).first()
if not rcp:
raise HTTPException(404, "Tarif yok")
skill_name = rcp[3]
required_level = rcp[4]
step_cost_per = rcp[6]
xp_per = rcp[5]
ingredients = rcp[7]
# Skill seviye
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == skill_name)
)).scalar_one_or_none()
if skill is None:
skill = Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1)
db.add(skill)
await db.flush()
if skill.level < required_level:
raise HTTPException(400, f"{skill_name} {required_level} gerekli (mevcut {skill.level})")
# Toplam adım
total_step = step_cost_per * c.qty
if char.step_balance < total_step:
raise HTTPException(400, f"Yetersiz adım: {total_step} gerekli, {char.step_balance} var")
# Malzeme yeterli mi
inv = await _inventory_map(db, char.id)
for ing in ingredients:
need = int(ing["qty"]) * c.qty
have = inv.get(ing["item_code"], 0)
if have < need:
raise HTTPException(400, f"{ing['item_code']} yetersiz: {need} gerekli, {have} var")
# Malzemeleri tüket (FIFO slotlardan)
for ing in ingredients:
need = int(ing["qty"]) * c.qty
item_id_row = (await db.execute(text("SELECT id FROM items WHERE code=:c"), {"c": ing["item_code"]})).first()
if not item_id_row:
raise HTTPException(500, f"Item bulunamadı: {ing['item_code']}")
item_id = item_id_row[0]
stacks = (await db.execute(text("""
SELECT id, qty FROM inventory
WHERE character_id = :cid AND item_id = :iid
ORDER BY slot
"""), {"cid": char.id, "iid": item_id})).all()
remaining = need
for st in stacks:
if remaining <= 0:
break
take = min(remaining, st[1])
if take == st[1]:
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
else:
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
remaining -= take
if remaining > 0:
raise HTTPException(500, "İç tutarsızlık: malzeme eksildi ama bulunamadı")
# Output ekle (stack ya da yeni slot)
out_qty = int(rcp[2]) * c.qty
out_item_id = rcp[1]
out_row = (await db.execute(text("SELECT code, name, stack_max FROM items WHERE id = :i"), {"i": out_item_id})).first()
out_code = out_row[0]
out_name = out_row[1]
stack_max = int(out_row[2])
remaining = out_qty
# Önce mevcut stack'lere ekle (stack_max'a kadar)
existing = (await db.execute(text("""
SELECT id, qty FROM inventory WHERE character_id = :cid AND item_id = :iid ORDER BY slot
"""), {"cid": char.id, "iid": out_item_id})).all()
for ex in existing:
if remaining <= 0:
break
space = stack_max - ex[1]
if space <= 0:
continue
add = min(space, remaining)
await db.execute(text("UPDATE inventory SET qty = qty + :a WHERE id = :id"), {"a": add, "id": ex[0]})
remaining -= add
# Yeni slot(lar)
while remaining > 0:
add = min(stack_max, remaining)
slot_row = (await db.execute(text(
"SELECT COALESCE(MAX(slot), -1) + 1 FROM inventory WHERE character_id = :cid"
), {"cid": char.id})).first()
next_slot = slot_row[0]
await db.execute(text("""
INSERT INTO inventory (character_id, item_id, qty, slot)
VALUES (:cid, :iid, :q, :s)
"""), {"cid": char.id, "iid": out_item_id, "q": add, "s": next_slot})
remaining -= add
# XP + adım
xp_gain = xp_per * c.qty
old_sl = skill.level
skill.xp += xp_gain
skill.level = skill_level_from_xp(skill.xp)
skill_up = skill.level > old_sl
old_cl = char.level
char.total_xp += xp_gain
char.level = character_level_from_total_xp(char.total_xp)
char_up = char.level > old_cl
char.step_balance -= total_step
char.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return CraftOut(
output_item_code=out_code,
output_item_name=out_name,
output_qty=out_qty,
skill=skill_name,
xp_gained=xp_gain,
skill_level=skill.level,
skill_level_up=skill_up,
character_level=char.level,
character_level_up=char_up,
step_balance=char.step_balance,
)

160
routers/harvest.py Normal file
View file

@ -0,0 +1,160 @@
"""
Envanter + consumable kullanımı + tool tanımları.
NOT (2026-07-04): Node hasat sistemi kaldırıldı kaynak toplama artık
lokasyon bazlı (routers/travel.py: POST /world/gather). SKILL_TOOLS burada
tanımlı kalır, travel.py buradan import eder.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import bindparam, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from models import Character, User, get_db
router = APIRouter(prefix="/world", tags=["inventory"])
# Tool şartı: bu skill'lerle toplama için envanterde listedeki tool'lardan biri olmalı.
# Foraging (elle toplama) serbest. İlk tool'lar elle toplanan taş/sopa/sarmaşıktan craft edilir.
SKILL_TOOLS = {
"woodcutting": ["axe_stone", "axe_copper", "axe_iron"],
"mining": ["pickaxe_stone", "pickaxe_copper", "pickaxe_iron"],
"fishing": ["fishing_rod", "fishing_rod_fine", "fishing_rod_master"],
}
TOOL_LABEL = {"woodcutting": "Balta", "mining": "Kazma", "fishing": "Olta"}
async def _has_any_tool(db: AsyncSession, char_id: int, codes: list[str]) -> bool:
stmt = text("""
SELECT 1 FROM inventory inv JOIN items i ON i.id = inv.item_id
WHERE inv.character_id = :cid AND i.code IN :codes LIMIT 1
""").bindparams(bindparam("codes", expanding=True))
row = (await db.execute(stmt, {"cid": char_id, "codes": codes})).first()
return row is not None
async def consume_items(db: AsyncSession, char_id: int, item_id: int, qty: int, item_name: str) -> None:
"""Envanterden FIFO tüket — market/camp/use-item ortak paterni.
Yetersizse HTTPException(400) fırlatır."""
stacks = (await db.execute(text(
"SELECT id, qty FROM inventory WHERE character_id=:cid AND item_id=:iid ORDER BY slot"
), {"cid": char_id, "iid": item_id})).all()
have = sum(s[1] for s in stacks)
if have < qty:
raise HTTPException(400, f"{item_name} yetersiz: {qty} gerekli, {have} var")
remaining = qty
for st in stacks:
if remaining <= 0:
break
take = min(remaining, st[1])
if take == st[1]:
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
else:
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
remaining -= take
class InventoryItem(BaseModel):
slot: int
item_code: str
item_name: str
rarity: str
qty: int
icon: str | None = None
item_type: str = "material"
effect: dict | None = None
@router.get("/inventory", response_model=list[InventoryItem])
async def inventory(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
return []
rows = (await db.execute(text("""
SELECT inv.slot, i.code, i.name, i.rarity, inv.qty, i.icon, i.type, i.effect
FROM inventory inv JOIN items i ON i.id = inv.item_id
WHERE inv.character_id = :cid
ORDER BY inv.slot
"""), {"cid": char.id})).all()
return [
InventoryItem(slot=r[0], item_code=r[1], item_name=r[2], rarity=r[3], qty=r[4],
icon=r[5], item_type=r[6], effect=r[7])
for r in rows
]
class UseItemIn(BaseModel):
item_code: str
qty: int = Field(1, ge=1, le=10)
class UseItemOut(BaseModel):
item_code: str
item_name: str
qty_used: int
effect_type: str
effect_amount: int
step_balance: int
@router.post("/use-item", response_model=UseItemOut)
async def use_item(u: UseItemIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
item = (await db.execute(
text("SELECT id, name, type, effect FROM items WHERE code = :c"), {"c": u.item_code}
)).first()
if not item:
raise HTTPException(404, "Item yok")
item_id, item_name, item_type, effect = item[0], item[1], item[2], item[3]
if item_type != "consumable" or not effect:
raise HTTPException(400, f"{item_name} kullanılabilir bir item değil")
if effect.get("type") != "steps":
raise HTTPException(400, f"Bilinmeyen etki türü: {effect.get('type')}")
# Envanterde yeterli mi (FIFO tüket — craft ile aynı patern)
stacks = (await db.execute(text("""
SELECT id, qty FROM inventory
WHERE character_id = :cid AND item_id = :iid
ORDER BY slot
"""), {"cid": char.id, "iid": item_id})).all()
have = sum(s[1] for s in stacks)
if have < u.qty:
raise HTTPException(400, f"{item_name} yetersiz: {u.qty} gerekli, {have} var")
remaining = u.qty
for st in stacks:
if remaining <= 0:
break
take = min(remaining, st[1])
if take == st[1]:
await db.execute(text("DELETE FROM inventory WHERE id = :id"), {"id": st[0]})
else:
await db.execute(text("UPDATE inventory SET qty = qty - :t WHERE id = :id"), {"t": take, "id": st[0]})
remaining -= take
# Etki: adım bakiyesi yenile (lifetime'a sayılmaz — gerçek yürüyüş değil)
gained = int(effect["amount"]) * u.qty
char.step_balance += gained
await db.execute(
text("INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
"VALUES (:cid, :d, 'consumable', true, :r)"),
{"cid": char.id, "d": gained, "r": f"{u.item_code} x{u.qty}"},
)
char.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return UseItemOut(
item_code=u.item_code,
item_name=item_name,
qty_used=u.qty,
effect_type="steps",
effect_amount=gained,
step_balance=char.step_balance,
)

59
routers/leaderboard.py Normal file
View file

@ -0,0 +1,59 @@
"""
Liderlik tablosu adım (lifetime), level ve gold sıralamaları.
GET /leaderboard?by=steps|level|gold top 20 + kendi sıran.
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from models import Character, User, get_db
router = APIRouter(prefix="/leaderboard", tags=["leaderboard"])
ORDER_COLS = {"steps": "step_lifetime", "level": "total_xp", "gold": "gold"}
class LeaderRow(BaseModel):
rank: int
name: str
level: int
value: int # sıralama kriterinin değeri
me: bool
class LeaderboardOut(BaseModel):
by: str
rows: list[LeaderRow]
my_rank: int
@router.get("", response_model=LeaderboardOut)
async def leaderboard(by: str = "steps",
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
if by not in ORDER_COLS:
raise HTTPException(400, "by: steps | level | gold")
col = ORDER_COLS[by]
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
rows = (await db.execute(text(f"""
SELECT rank, name, level, val, id FROM (
SELECT id, name, level, {col} AS val,
RANK() OVER (ORDER BY {col} DESC, id) AS rank
FROM characters
) t WHERE rank <= 20 OR id = :me
ORDER BY rank
"""), {"me": char.id})).all()
my_rank = 0
out: list[LeaderRow] = []
for r in rows:
me = r[4] == char.id
if me:
my_rank = int(r[0])
if int(r[0]) <= 20:
out.append(LeaderRow(rank=int(r[0]), name=r[1], level=int(r[2]), value=int(r[3]), me=me))
return LeaderboardOut(by=by, rows=out, my_rank=my_rank)

203
routers/lore.py Normal file
View file

@ -0,0 +1,203 @@
"""
Lore notları "lore" izni (admin + loremaster). Hikaye için küçük notlar; sonra derlenir.
Hepsi admin-only (GET dahil özel içerik). WAF nedeniyle sadece GET/POST.
GET /lore notlar (yeniden eskiye)
POST /lore yeni not
POST /lore/{id} güncelle
POST /lore/{id}/delete sil
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm, get_user_perms, has_perm
from models import User, get_db
router = APIRouter(prefix="/lore", tags=["lore"])
async def _assert_owner_or_admin(db: AsyncSession, user: User, table: str, row_id: int):
"""Admin (perm '*') her şeyi düzenler/siler; diğerleri sadece kendi kaydını."""
perms = await get_user_perms(user, db)
if has_perm(perms, "*"):
return
owner = (await db.execute(text(
f"SELECT author_id FROM {table} WHERE id=:id"
), {"id": row_id})).scalar_one_or_none()
if owner is None:
raise HTTPException(404, "Kayıt yok")
if owner != user.id:
raise HTTPException(403, "Sadece kendi kaydını düzenleyebilir/silebilirsin")
class NoteIn(BaseModel):
body: str = Field(min_length=1, max_length=20000)
body_en: str | None = Field(default=None, max_length=20000)
tag: str | None = Field(default=None, max_length=60)
subject_kind: str | None = Field(default=None, max_length=16) # 'npc' | 'place' | None
subject_id: int | None = None
subject_label: str | None = Field(default=None, max_length=120)
class NoteOut(BaseModel):
id: int
body: str
body_en: str | None
tag: str | None
subject_kind: str | None
subject_id: int | None
subject_label: str | None
author: str | None
author_id: int | None
created_at: str
updated_at: str
_NOTE_COLS = ("id,body,body_en,tag,subject_kind,subject_id,subject_label,"
"author,author_id,created_at,updated_at")
def _author(user: User) -> str:
return (user.display_name or "").strip() or user.email
def _row(r) -> NoteOut:
d = dict(r)
d["created_at"] = d["created_at"].isoformat()
d["updated_at"] = d["updated_at"].isoformat()
return NoteOut(**d)
@router.get("", response_model=list[NoteOut])
async def list_notes(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
f"SELECT {_NOTE_COLS} FROM lore_notes ORDER BY id DESC"
))).mappings().all()
return [_row(r) for r in rows]
@router.get("/subjects")
async def list_subjects(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
"""Not bağlanabilecek konular: kişiler (npcs) + yerler (map_tags)."""
npcs = (await db.execute(text(
"SELECT id, name, role FROM npcs ORDER BY name"
))).mappings().all()
places = (await db.execute(text(
"SELECT id, name, category FROM map_tags ORDER BY name"
))).mappings().all()
return {
"npc": [{"id": r["id"], "label": r["name"], "sub": r["role"] or ""} for r in npcs],
"place": [{"id": r["id"], "label": r["name"], "sub": r["category"] or ""} for r in places],
}
@router.post("", response_model=NoteOut)
async def add_note(n: NoteIn, user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
row = (await db.execute(text(
"INSERT INTO lore_notes(body,body_en,tag,subject_kind,subject_id,subject_label,author,author_id) "
"VALUES(:body,:body_en,:tag,:sk,:sid,:slabel,:author,:aid) "
f"RETURNING {_NOTE_COLS}"
), {"body": n.body, "body_en": n.body_en, "tag": n.tag, "sk": n.subject_kind,
"sid": n.subject_id, "slabel": n.subject_label,
"author": _author(user), "aid": user.id})).mappings().one()
await db.commit()
return _row(row)
# ---- Temel Hikaye — uzun metin bölümleri (lore_story) ----
# ÖNEMLİ: /lore/story yolları /lore/{note_id}'den ÖNCE tanımlı olmalı (route sırası).
class StoryIn(BaseModel):
title: str | None = Field(default=None, max_length=120)
body: str = Field(min_length=1, max_length=200000)
body_en: str | None = Field(default=None, max_length=200000)
class StoryOut(BaseModel):
id: int
title: str | None
body: str
body_en: str | None
author: str | None
author_id: int | None
created_at: str
updated_at: str
def _srow(r) -> StoryOut:
d = dict(r)
d["created_at"] = d["created_at"].isoformat()
d["updated_at"] = d["updated_at"].isoformat()
return StoryOut(**d)
@router.get("/story", response_model=list[StoryOut])
async def list_story(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT id,title,body,body_en,author,author_id,created_at,updated_at FROM lore_story ORDER BY sort_order,id"
))).mappings().all()
return [_srow(r) for r in rows]
@router.post("/story", response_model=StoryOut)
async def add_story(s: StoryIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
row = (await db.execute(text(
"INSERT INTO lore_story(title,body,body_en,author,author_id) VALUES(:title,:body,:body_en,:author,:aid) "
"RETURNING id,title,body,body_en,author,author_id,created_at,updated_at"
), {"title": s.title, "body": s.body, "body_en": s.body_en, "author": _author(user), "aid": user.id})).mappings().one()
await db.commit()
return _srow(row)
@router.post("/story/{story_id}", response_model=StoryOut)
async def update_story(story_id: int, s: StoryIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "lore_story", story_id)
row = (await db.execute(text(
"UPDATE lore_story SET title=:title,body=:body,body_en=:body_en,updated_at=now() WHERE id=:id "
"RETURNING id,title,body,body_en,author,author_id,created_at,updated_at"
), {"id": story_id, "title": s.title, "body": s.body, "body_en": s.body_en})).mappings().one_or_none()
if not row:
raise HTTPException(404, "Bölüm yok")
await db.commit()
return _srow(row)
@router.post("/story/{story_id}/delete")
async def delete_story(story_id: int, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "lore_story", story_id)
res = await db.execute(text("DELETE FROM lore_story WHERE id=:id"), {"id": story_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Bölüm yok")
return {"deleted": story_id}
@router.post("/{note_id}", response_model=NoteOut)
async def update_note(note_id: int, n: NoteIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "lore_notes", note_id)
row = (await db.execute(text(
"UPDATE lore_notes SET body=:body,body_en=:body_en,tag=:tag,"
"subject_kind=:sk,subject_id=:sid,subject_label=:slabel,updated_at=now() WHERE id=:id "
f"RETURNING {_NOTE_COLS}"
), {"id": note_id, "body": n.body, "body_en": n.body_en, "tag": n.tag,
"sk": n.subject_kind, "sid": n.subject_id, "slabel": n.subject_label})).mappings().one_or_none()
if not row:
raise HTTPException(404, "Not yok")
await db.commit()
return _row(row)
@router.post("/{note_id}/delete")
async def delete_note(note_id: int, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, "lore_notes", note_id)
res = await db.execute(text("DELETE FROM lore_notes WHERE id=:id"), {"id": note_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Not yok")
return {"deleted": note_id}

266
routers/map_tags.py Normal file
View file

@ -0,0 +1,266 @@
"""
Harita etiketleri kıta/bölge/POI alanlarını harita üstünde işaretle.
points: [[x,y], ...] normalize (0-1) koordinatlar. poi = tek nokta, bölge = poligon.
GET /map-tags herkese ık liste (editör + oyun okur)
POST /map-tags "map_edit" izni (yeni etiket)
PUT /map-tags/{id} admin (güncelle)
DELETE /map-tags/{id} admin (sil)
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm
from models import User, get_db
router = APIRouter(prefix="/map-tags", tags=["map-tags"])
KINDS = {"continent", "region", "poi"}
# Kategori kataloğu (tip artık isim prefix'inde değil, yapısal). kind ile eşleşmeli.
CATEGORIES: dict[str, dict] = {
# region (zone) kategorileri
"forest": {"kind": "region", "label": "Orman", "icon": "🌲", "color": "#2e7d32"},
"city": {"kind": "region", "label": "Şehir", "icon": "🏙", "color": "#c9a227"},
"mountain": {"kind": "region", "label": "Dağ", "icon": "", "color": "#8d6e63"},
"lake": {"kind": "region", "label": "Göl / Kıyı", "icon": "💧", "color": "#1e88e5"},
"plains": {"kind": "region", "label": "Çayır", "icon": "🌾", "color": "#9ccc65"},
"wasteland": {"kind": "region", "label": "Kavruk (magma)","icon": "🌋", "color": "#b71c1c"},
"zone": {"kind": "region", "label": "Bölge (genel)", "icon": "", "color": "#78909c"},
# poi (landmark) kategorileri
"town": {"kind": "poi", "label": "Kasaba", "icon": "🏘", "color": "#c9a227"},
"temple": {"kind": "poi", "label": "Tapınak", "icon": "", "color": "#ab47bc"},
"cave": {"kind": "poi", "label": "Mağara", "icon": "🕳", "color": "#546e7a"},
"mine": {"kind": "poi", "label": "Maden", "icon": "", "color": "#8d6e63"},
"market": {"kind": "poi", "label": "Pazar", "icon": "🛒", "color": "#26a69a"},
"dungeon": {"kind": "poi", "label": "Zindan", "icon": "🚪", "color": "#c62828"},
"port": {"kind": "poi", "label": "Liman", "icon": "", "color": "#1565c0"},
}
# NPC/quest otomasyonu için: satıcılar ve görev verenler nereye yerleşir
VENDOR_CATEGORIES = {"town", "city", "market"}
QUESTGIVER_CATEGORIES = {"town", "city", "temple", "market"}
class TagIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
kind: str = "region"
category: str | None = None
continent: str | None = None
points: list[list[float]] = Field(min_length=1)
color: str | None = None
note: str | None = None
region_ids: list[int] = Field(default_factory=list) # POI: içinde olduğu bölge(ler)
class TagOut(BaseModel):
id: int
name: str
kind: str
category: str | None
continent: str | None
points: list[list[float]]
color: str | None
note: str | None
region_ids: list[int]
def _color(t: TagIn) -> str | None:
"""Renk verilmemişse kategoriden türet."""
if t.color:
return t.color
if t.category and t.category in CATEGORIES:
return CATEGORIES[t.category]["color"]
return None
def _validate(t: TagIn) -> None:
if t.kind not in KINDS:
raise HTTPException(400, f"kind: {', '.join(sorted(KINDS))}")
if t.category is not None:
cat = CATEGORIES.get(t.category)
if not cat:
raise HTTPException(400, f"geçersiz category: {t.category}")
if cat["kind"] != t.kind:
raise HTTPException(400, f"'{t.category}' kategorisi {cat['kind']} içindir, {t.kind} değil")
if t.kind == "poi" and len(t.points) != 1:
raise HTTPException(400, "poi tek nokta olmalı")
if t.kind != "poi" and len(t.points) < 3:
raise HTTPException(400, "bölge en az 3 köşe olmalı")
for p in t.points:
if len(p) != 2 or not all(0 <= c <= 1 for c in p):
raise HTTPException(400, "koordinatlar [x,y] ve 0-1 arası olmalı")
_COLS = "id,name,kind,category,continent,points,color,note,region_ids"
@router.get("/categories")
async def list_categories():
"""Kategori kataloğu (frontend ikon/renk/etiket + otomasyon kuralları)."""
return {
"categories": CATEGORIES,
"vendor": sorted(VENDOR_CATEGORIES),
"questgiver": sorted(QUESTGIVER_CATEGORIES),
}
@router.get("", response_model=list[TagOut])
async def list_tags(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
f"SELECT {_COLS} FROM map_tags ORDER BY id"
))).mappings().all()
return [TagOut(**dict(r)) for r in rows]
@router.post("", response_model=TagOut)
async def create_tag(t: TagIn, user: User = Depends(require_perm("map_edit")),
db: AsyncSession = Depends(get_db)):
_validate(t)
import json
row = (await db.execute(text(
"INSERT INTO map_tags(name,kind,category,continent,points,color,note,region_ids) "
"VALUES(:name,:kind,:category,:continent,CAST(:points AS JSONB),:color,:note,CAST(:region_ids AS JSONB)) "
f"RETURNING {_COLS}"
), {"name": t.name, "kind": t.kind, "category": t.category, "continent": t.continent,
"points": json.dumps(t.points), "color": _color(t), "note": t.note,
"region_ids": json.dumps(t.region_ids)})).mappings().one()
await db.commit()
return TagOut(**dict(row))
# --- Lokasyonlar + yollar (editörün 3. işlemi: yol çizme) ---
# Lokasyonlar 0-1000 grid'te (map_x/map_y); editör/oyun 0-1 normalize kullanır.
# roads.waypoints = normalize (0-1) [[x,y],...] ara noktalar (uçlar hariç).
# NOT: bu route'lar POST /{tag_id} catch-all'ından ÖNCE tanımlanmalı (aksi halde
# "roads" tag_id:int olarak parse edilip 422 döner).
class LocationOut(BaseModel):
id: int
code: str
name: str
type: str
x: float # normalize 0-1
y: float
class RoadOut(BaseModel):
id: int
a: str # loc code
b: str
step_cost: int
event_chance: float
waypoints: list[list[float]]
class RoadIn(BaseModel):
a: str
b: str
step_cost: int = Field(ge=1, le=100000)
event_chance: float = Field(default=0.3, ge=0.0, le=1.0)
waypoints: list[list[float]] = Field(default_factory=list)
@router.get("/locations", response_model=list[LocationOut])
async def list_locations(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT id, code, name, type, map_x, map_y FROM locations ORDER BY name"
))).mappings().all()
return [LocationOut(id=r["id"], code=r["code"], name=r["name"], type=r["type"],
x=r["map_x"] / 1000.0, y=r["map_y"] / 1000.0) for r in rows]
@router.get("/roads", response_model=list[RoadOut])
async def list_roads(db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT r.id, a.code AS a, b.code AS b, r.step_cost, r.event_chance, r.waypoints "
"FROM roads r JOIN locations a ON a.id=r.loc_a JOIN locations b ON b.id=r.loc_b "
"ORDER BY r.id"
))).mappings().all()
return [RoadOut(**dict(r)) for r in rows]
def _validate_wp(wps: list[list[float]]) -> None:
for p in wps:
if len(p) != 2 or not all(0 <= c <= 1 for c in p):
raise HTTPException(400, "waypoint [x,y] ve 0-1 arası olmalı")
@router.post("/roads", response_model=RoadOut)
async def upsert_road(r: RoadIn, user: User = Depends(require_perm("map_edit")),
db: AsyncSession = Depends(get_db)):
import json
if r.a == r.b:
raise HTTPException(400, "Yol aynı lokasyona bağlanamaz")
_validate_wp(r.waypoints)
ids = (await db.execute(text(
"SELECT code, id FROM locations WHERE code IN (:a, :b)"
), {"a": r.a, "b": r.b})).all()
id_by_code = {c: i for c, i in ids}
if r.a not in id_by_code or r.b not in id_by_code:
raise HTTPException(400, "Lokasyon bulunamadı")
ia, ib = id_by_code[r.a], id_by_code[r.b]
# roads UNIQUE(loc_a, loc_b) yönlü — her iki yönü de kontrol et, varsa güncelle
existing = (await db.execute(text(
"SELECT id, loc_a, loc_b FROM roads WHERE (loc_a=:a AND loc_b=:b) OR (loc_a=:b AND loc_b=:a)"
), {"a": ia, "b": ib})).first()
wp = r.waypoints
if existing:
# Kayıtlı yön a→b değilse waypoint sırasını ters çevir (uçlarla tutarlı kalsın)
if existing[1] != ia:
wp = list(reversed(wp))
row = (await db.execute(text(
"UPDATE roads SET step_cost=:sc, event_chance=:ec, waypoints=CAST(:wp AS JSONB) "
"WHERE id=:id "
"RETURNING id, (SELECT code FROM locations WHERE id=loc_a) AS a, "
"(SELECT code FROM locations WHERE id=loc_b) AS b, step_cost, event_chance, waypoints"
), {"id": existing[0], "sc": r.step_cost, "ec": r.event_chance, "wp": json.dumps(wp)})).mappings().one()
else:
row = (await db.execute(text(
"INSERT INTO roads(loc_a, loc_b, step_cost, event_chance, waypoints) "
"VALUES(:a, :b, :sc, :ec, CAST(:wp AS JSONB)) "
"RETURNING id, (SELECT code FROM locations WHERE id=loc_a) AS a, "
"(SELECT code FROM locations WHERE id=loc_b) AS b, step_cost, event_chance, waypoints"
), {"a": ia, "b": ib, "sc": r.step_cost, "ec": r.event_chance, "wp": json.dumps(wp)})).mappings().one()
await db.commit()
return RoadOut(**dict(row))
@router.post("/roads/{road_id}/delete")
async def delete_road(road_id: int, user: User = Depends(require_perm("map_edit")),
db: AsyncSession = Depends(get_db)):
res = await db.execute(text("DELETE FROM roads WHERE id=:id"), {"id": road_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Yol yok")
return {"deleted": road_id}
@router.post("/{tag_id}", response_model=TagOut)
async def update_tag(tag_id: int, t: TagIn, user: User = Depends(require_perm("map_edit")),
db: AsyncSession = Depends(get_db)):
_validate(t)
import json
row = (await db.execute(text(
"UPDATE map_tags SET name=:name,kind=:kind,category=:category,continent=:continent,"
"points=CAST(:points AS JSONB),color=:color,note=:note,"
"region_ids=CAST(:region_ids AS JSONB),updated_at=now() "
f"WHERE id=:id RETURNING {_COLS}"
), {"id": tag_id, "name": t.name, "kind": t.kind, "category": t.category, "continent": t.continent,
"points": json.dumps(t.points), "color": _color(t), "note": t.note,
"region_ids": json.dumps(t.region_ids)})).mappings().one_or_none()
if not row:
raise HTTPException(404, "Etiket yok")
await db.commit()
return TagOut(**dict(row))
@router.post("/{tag_id}/delete")
async def delete_tag(tag_id: int, user: User = Depends(require_perm("map_edit")),
db: AsyncSession = Depends(get_db)):
res = await db.execute(text("DELETE FROM map_tags WHERE id=:id"), {"id": tag_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Etiket yok")
return {"deleted": tag_id}

505
routers/market.py Normal file
View file

@ -0,0 +1,505 @@
"""
Pazar oyuncu arası ticaret (gold ile) + NPC vendor.
Kurallar:
- İlan verme / satın alma / vendor'a satma SADECE 'pazar' lokasyonundayken.
- İlanları görüntüleme her yerden serbest (vitrin).
- Para birimi: gold (characters.gold). Vendor her şeyi items.base_value'dan alır.
- İlan iptali her yerden yapılabilir (item envantere döner).
Endpoint'ler:
- GET /market/listings ık ilanlar (q= arama, mine= sadece benimkiler)
- POST /market/list {item_code, qty, unit_price} ilan ver
- POST /market/buy {listing_id, qty?} satın al (kısmi alım destekli)
- POST /market/cancel {listing_id} ilanı iptal et
- GET /market/vendor envanterim + vendor'un ödeyeceği birim fiyatlar
- POST /market/vendor/sell {item_code, qty} vendor'a sat → gold
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from models import Character, User, get_db
from routers.harvest import consume_items
from routers.travel import _add_items
router = APIRouter(prefix="/market", tags=["market"])
MARKET_LOCATION = "pazar"
MAX_OPEN_LISTINGS = 10
async def _char(db: AsyncSession, user: User) -> Character:
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
return char
async def _require_at_market(db: AsyncSession, char: Character) -> None:
row = (await db.execute(text(
"SELECT code FROM locations WHERE id = :i"
), {"i": char.location_id})).first()
if not row or row[0] != MARKET_LOCATION:
raise HTTPException(400, "Bunun için Pazar'da olmalısın")
# ---------- İlanlar ----------
class ListingOut(BaseModel):
id: int
item_code: str
item_name: str
rarity: str
icon: Optional[str] = None
qty: int
unit_price: int
seller_name: str
mine: bool
@router.get("/listings", response_model=list[ListingOut])
async def listings(q: str = "", mine: bool = False,
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
sql = """
SELECT ml.id, i.code, i.name, i.rarity, i.icon, ml.qty, ml.unit_price, c.name, ml.seller_id
FROM market_listings ml
JOIN items i ON i.id = ml.item_id
JOIN characters c ON c.id = ml.seller_id
WHERE ml.status = 'open'
"""
params: dict = {}
if q:
sql += " AND i.name ILIKE :q"
params["q"] = f"%{q}%"
if mine:
sql += " AND ml.seller_id = :me"
params["me"] = char.id
sql += " ORDER BY i.name, ml.unit_price LIMIT 100"
rows = (await db.execute(text(sql), params)).all()
return [ListingOut(id=r[0], item_code=r[1], item_name=r[2], rarity=r[3], icon=r[4],
qty=r[5], unit_price=r[6], seller_name=r[7], mine=(r[8] == char.id))
for r in rows]
class ListIn(BaseModel):
item_code: str
qty: int = Field(ge=1, le=999)
unit_price: int = Field(ge=1, le=1_000_000)
class ListingActionOut(BaseModel):
listing_id: int
gold: int
@router.post("/list", response_model=ListingActionOut)
async def create_listing(l: ListIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _require_at_market(db, char)
open_count = (await db.execute(text(
"SELECT count(*) FROM market_listings WHERE seller_id=:s AND status='open'"
), {"s": char.id})).scalar_one()
if open_count >= MAX_OPEN_LISTINGS:
raise HTTPException(400, f"En fazla {MAX_OPEN_LISTINGS}ık ilanın olabilir")
item = (await db.execute(text(
"SELECT id, name FROM items WHERE code = :c"), {"c": l.item_code})).first()
if not item:
raise HTTPException(404, "Item yok")
await consume_items(db, char.id, item[0], l.qty, item[1])
lid = (await db.execute(text("""
INSERT INTO market_listings (seller_id, item_id, qty, unit_price)
VALUES (:s, :i, :q, :p) RETURNING id
"""), {"s": char.id, "i": item[0], "q": l.qty, "p": l.unit_price})).scalar_one()
await db.commit()
return ListingActionOut(listing_id=lid, gold=char.gold)
class BuyIn(BaseModel):
listing_id: int
qty: Optional[int] = Field(None, ge=1, le=999) # None = tamamı
class BuyOut(BaseModel):
item_code: str
item_name: str
qty: int
total_price: int
gold: int
@router.post("/buy", response_model=BuyOut)
async def buy(b: BuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _require_at_market(db, char)
row = (await db.execute(text("""
SELECT ml.id, ml.seller_id, ml.item_id, ml.qty, ml.unit_price, i.code, i.name
FROM market_listings ml JOIN items i ON i.id = ml.item_id
WHERE ml.id = :id AND ml.status = 'open' FOR UPDATE OF ml
"""), {"id": b.listing_id})).first()
if not row:
raise HTTPException(404, "İlan yok ya da kapanmış")
if row[1] == char.id:
raise HTTPException(400, "Kendi ilanını satın alamazsın")
qty = b.qty if b.qty is not None else int(row[3])
if qty > int(row[3]):
raise HTTPException(400, f"İlanda sadece {row[3]} adet var")
total = qty * int(row[4])
if char.gold < total:
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
char.gold -= total
await db.execute(text("UPDATE characters SET gold = gold + :g WHERE id = :s"),
{"g": total, "s": row[1]})
await _add_items(db, char.id, row[5], qty)
if qty == int(row[3]):
await db.execute(text(
"UPDATE market_listings SET status='sold', closed_at=:t WHERE id=:id"
), {"t": datetime.now(timezone.utc), "id": row[0]})
else:
await db.execute(text("UPDATE market_listings SET qty = qty - :q WHERE id=:id"),
{"q": qty, "id": row[0]})
await db.execute(text("""
INSERT INTO trade_logs (seller_id, buyer_id, item_id, qty, unit_price)
VALUES (:s, :b, :i, :q, :p)
"""), {"s": row[1], "b": char.id, "i": row[2], "q": qty, "p": row[4]})
await db.commit()
return BuyOut(item_code=row[5], item_name=row[6], qty=qty, total_price=total, gold=char.gold)
class CancelIn(BaseModel):
listing_id: int
@router.post("/cancel", response_model=ListingActionOut)
async def cancel(cn: CancelIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
row = (await db.execute(text("""
SELECT ml.id, ml.item_id, ml.qty, i.code
FROM market_listings ml JOIN items i ON i.id = ml.item_id
WHERE ml.id = :id AND ml.status = 'open' AND ml.seller_id = :s FOR UPDATE OF ml
"""), {"id": cn.listing_id, "s": char.id})).first()
if not row:
raise HTTPException(404, "ık ilanın yok")
await db.execute(text(
"UPDATE market_listings SET status='cancelled', closed_at=:t WHERE id=:id"
), {"t": datetime.now(timezone.utc), "id": row[0]})
await _add_items(db, char.id, row[3], int(row[2]))
await db.commit()
return ListingActionOut(listing_id=row[0], gold=char.gold)
# ---------- NPC Vendor ----------
class VendorRow(BaseModel):
item_code: str
item_name: str
rarity: str
qty: int
unit_price: int # vendor'un ödeyeceği gold
@router.get("/vendor", response_model=list[VendorRow])
async def vendor_prices(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
rows = (await db.execute(text("""
SELECT i.code, i.name, i.rarity, SUM(inv.qty), i.base_value
FROM inventory inv JOIN items i ON i.id = inv.item_id
WHERE inv.character_id = :cid
GROUP BY i.code, i.name, i.rarity, i.base_value
ORDER BY i.name
"""), {"cid": char.id})).all()
return [VendorRow(item_code=r[0], item_name=r[1], rarity=r[2], qty=int(r[3]), unit_price=int(r[4]))
for r in rows]
class VendorSellIn(BaseModel):
item_code: str
qty: int = Field(ge=1, le=999)
class VendorSellOut(BaseModel):
item_code: str
item_name: str
qty: int
gold_gained: int
gold: int
@router.post("/vendor/sell", response_model=VendorSellOut)
async def vendor_sell(v: VendorSellIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _require_at_market(db, char)
item = (await db.execute(text(
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": v.item_code})).first()
if not item:
raise HTTPException(404, "Item yok")
await consume_items(db, char.id, item[0], v.qty, item[1])
gained = int(item[2]) * v.qty
char.gold += gained
await db.commit()
return VendorSellOut(item_code=v.item_code, item_name=item[1], qty=v.qty,
gold_gained=gained, gold=char.gold)
# ---------- Vendor stok (satın alma — gold sink) ----------
# Vendor'un sattığı temel mallar; fiyat = base_value × çarpan
VENDOR_STOCK_MARKUP = 4
VENDOR_STOCK_CODES = ["axe_stone", "pickaxe_stone", "fishing_rod", "tea_mint", "stone_small", "stick", "vine"]
class VendorStockRow(BaseModel):
item_code: str
item_name: str
rarity: str
unit_price: int # gold
@router.get("/vendor/stock", response_model=list[VendorStockRow])
async def vendor_stock(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
await _char(db, user)
from sqlalchemy import bindparam
stmt = text(
"SELECT code, name, rarity, base_value FROM items WHERE code IN :codes ORDER BY base_value"
).bindparams(bindparam("codes", expanding=True))
rows = (await db.execute(stmt, {"codes": VENDOR_STOCK_CODES})).all()
return [VendorStockRow(item_code=r[0], item_name=r[1], rarity=r[2],
unit_price=int(r[3]) * VENDOR_STOCK_MARKUP) for r in rows]
class VendorBuyIn(BaseModel):
item_code: str
qty: int = Field(ge=1, le=10)
class VendorBuyOut(BaseModel):
item_code: str
item_name: str
qty: int
total_price: int
gold: int
@router.post("/vendor/buy", response_model=VendorBuyOut)
async def vendor_buy(v: VendorBuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _require_at_market(db, char)
if v.item_code not in VENDOR_STOCK_CODES:
raise HTTPException(400, "Vendor bunu satmıyor")
item = (await db.execute(text(
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": v.item_code})).first()
if not item:
raise HTTPException(404, "Item yok")
total = int(item[2]) * VENDOR_STOCK_MARKUP * v.qty
if char.gold < total:
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
char.gold -= total
await _add_items(db, char.id, v.item_code, v.qty)
await db.commit()
return VendorBuyOut(item_code=v.item_code, item_name=item[1], qty=v.qty,
total_price=total, gold=char.gold)
# ---------- Şehir haritası: konum bazlı NPC vendor'lar ----------
# Her şehir location'ının kendi vendor NPC'leri var (npcs.location_id).
# Vendor stoğu = sabit stok (npc_items, sonsuz, base_value×markup)
# + konsinye (vendor_stock: oyuncuların sattığı, 24s ömürlü, kendi fiyatı).
# Oyuncu vendor'a sattığında: gold alır + ürün vendor'ın normal satmadığı bir şeyse
# 24s ömürle konsinyeye eklenir (varsa süresi yenilenir). Normal sattığı üründe
# sadece gold ödenir (sabit stok zaten sonsuz).
VENDOR_CONSIGN_HOURS = 24
async def _clean_expired(db: AsyncSession) -> None:
await db.execute(text("DELETE FROM vendor_stock WHERE expires_at < now()"))
async def _vendor_at_here(db: AsyncSession, npc_id: int, char: Character) -> dict:
"""npc_id gerçekten char'ın bulunduğu şehrin vendor'ı mı? değilse 400."""
row = (await db.execute(text(
"SELECT id, name, role, location_id FROM npcs WHERE id = :i"
), {"i": npc_id})).first()
if not row:
raise HTTPException(404, "Satıcı yok")
if row[3] != char.location_id:
raise HTTPException(400, "Bu satıcı burada değil")
return {"id": row[0], "name": row[1], "role": row[2]}
class CityVendor(BaseModel):
npc_id: int
name: str
role: Optional[str] = None
class CityOut(BaseModel):
location_code: str
location_name: str
vendors: list[CityVendor]
@router.get("/city", response_model=CityOut)
async def city_vendors(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
"""Bulunduğun şehrin vendor NPC'leri (şehir haritası pinleri)."""
char = await _char(db, user)
loc = (await db.execute(text(
"SELECT code, name FROM locations WHERE id = :i"
), {"i": char.location_id})).first()
if not loc:
raise HTTPException(404, "Konum yok")
rows = (await db.execute(text(
"SELECT id, name, role FROM npcs WHERE location_id = :l ORDER BY id"
), {"l": char.location_id})).all()
return CityOut(location_code=loc[0], location_name=loc[1],
vendors=[CityVendor(npc_id=r[0], name=r[1], role=r[2]) for r in rows])
class ShopRow(BaseModel):
item_code: str
item_name: str
rarity: str
icon: Optional[str] = None
unit_price: int
qty: Optional[int] = None # None = sabit stok (sonsuz); sayı = konsinye kalan
source: str # "base" | "player"
expires_in_h: Optional[int] = None
class ShopOut(BaseModel):
npc_id: int
npc_name: str
role: Optional[str] = None
items: list[ShopRow]
@router.get("/vendor/{npc_id}/shop", response_model=ShopOut)
async def vendor_shop(npc_id: int, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
v = await _vendor_at_here(db, npc_id, char)
await _clean_expired(db)
# sabit stok — npc_items
base = (await db.execute(text(
"SELECT i.code, i.name, i.rarity, i.icon, i.base_value "
"FROM npc_items ni JOIN items i ON i.id = ni.item_id "
"WHERE ni.npc_id = :n ORDER BY i.base_value"
), {"n": npc_id})).all()
items = [ShopRow(item_code=r[0], item_name=r[1], rarity=r[2], icon=r[3],
unit_price=int(r[4]) * VENDOR_STOCK_MARKUP, qty=None, source="base")
for r in base]
# konsinye — vendor_stock (aktif)
cons = (await db.execute(text(
"SELECT i.code, i.name, i.rarity, i.icon, vs.unit_price, vs.qty, "
"CEIL(EXTRACT(EPOCH FROM (vs.expires_at - now()))/3600.0) "
"FROM vendor_stock vs JOIN items i ON i.id = vs.item_id "
"WHERE vs.npc_id = :n AND vs.expires_at > now() ORDER BY vs.expires_at"
), {"n": npc_id})).all()
items += [ShopRow(item_code=r[0], item_name=r[1], rarity=r[2], icon=r[3],
unit_price=int(r[4]), qty=int(r[5]), source="player", expires_in_h=int(r[6]))
for r in cons]
await db.commit()
return ShopOut(npc_id=v["id"], npc_name=v["name"], role=v["role"], items=items)
class NpcBuyIn(BaseModel):
item_code: str
qty: int = Field(ge=1, le=99)
@router.post("/vendor/{npc_id}/buy", response_model=VendorBuyOut)
async def npc_buy(npc_id: int, b: NpcBuyIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _vendor_at_here(db, npc_id, char)
await _clean_expired(db)
item = (await db.execute(text(
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": b.item_code})).first()
if not item:
raise HTTPException(404, "Item yok")
# önce konsinye stoğu (sınırlı), yoksa sabit stok (npc_items, sonsuz)
cons = (await db.execute(text(
"SELECT qty, unit_price FROM vendor_stock "
"WHERE npc_id = :n AND item_id = :it AND expires_at > now()"
), {"n": npc_id, "it": item[0]})).first()
if cons is not None:
if int(cons[0]) < b.qty:
raise HTTPException(400, f"Stokta {int(cons[0])} adet var")
unit = int(cons[1])
total = unit * b.qty
if char.gold < total:
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
remaining = int(cons[0]) - b.qty
if remaining > 0:
await db.execute(text("UPDATE vendor_stock SET qty = :q WHERE npc_id = :n AND item_id = :it"),
{"q": remaining, "n": npc_id, "it": item[0]})
else:
await db.execute(text("DELETE FROM vendor_stock WHERE npc_id = :n AND item_id = :it"),
{"n": npc_id, "it": item[0]})
else:
# sabit stok mu?
is_base = (await db.execute(text(
"SELECT 1 FROM npc_items WHERE npc_id = :n AND item_id = :it"
), {"n": npc_id, "it": item[0]})).first()
if not is_base:
raise HTTPException(400, "Bu satıcı bunu satmıyor")
unit = int(item[2]) * VENDOR_STOCK_MARKUP
total = unit * b.qty
if char.gold < total:
raise HTTPException(400, f"Yetersiz gold: {total} gerekli, {char.gold} var")
char.gold -= total
await _add_items(db, char.id, b.item_code, b.qty)
await db.commit()
return VendorBuyOut(item_code=b.item_code, item_name=item[1], qty=b.qty,
total_price=total, gold=char.gold)
class NpcSellIn(BaseModel):
item_code: str
qty: int = Field(ge=1, le=999)
@router.post("/vendor/{npc_id}/sell", response_model=VendorSellOut)
async def npc_sell(npc_id: int, s: NpcSellIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
await _vendor_at_here(db, npc_id, char)
item = (await db.execute(text(
"SELECT id, name, base_value FROM items WHERE code = :c"), {"c": s.item_code})).first()
if not item:
raise HTTPException(404, "Item yok")
await consume_items(db, char.id, item[0], s.qty, item[1])
gained = int(item[2]) * s.qty
char.gold += gained
# Vendor'ın normal satmadığı ürünse konsinyeye ekle (24s, süresi yenilenir).
is_base = (await db.execute(text(
"SELECT 1 FROM npc_items WHERE npc_id = :n AND item_id = :it"
), {"n": npc_id, "it": item[0]})).first()
if not is_base:
unit = int(item[2]) * VENDOR_STOCK_MARKUP
await db.execute(text(
"INSERT INTO vendor_stock(npc_id, item_id, qty, unit_price, expires_at) "
"VALUES (:n, :it, :q, :u, now() + make_interval(hours => :h)) "
"ON CONFLICT (npc_id, item_id) DO UPDATE SET "
"qty = vendor_stock.qty + EXCLUDED.qty, unit_price = EXCLUDED.unit_price, "
"expires_at = now() + make_interval(hours => :h)"
), {"n": npc_id, "it": item[0], "q": s.qty, "u": unit, "h": VENDOR_CONSIGN_HOURS})
await db.commit()
return VendorSellOut(item_code=s.item_code, item_name=item[1], qty=s.qty,
gold_gained=gained, gold=char.gold)

271
routers/npcs.py Normal file
View file

@ -0,0 +1,271 @@
"""
NPC yönetimi oyun için oluşturulan NPC listesi ("npc" izni).
Konum = map editöründe çizilen alan (region) veya pin (poi) map_tags.
Taraf (stance): friendly (dost) / hostile (düşman) / neutral (tarafsız).
Bağlar: quest tek vericiye bağlı (exclusive); item çoktan-çoğa (npc_items).
WAF nedeniyle sadece GET/POST.
GET /npcs liste (yer + taraf + bağlı quest/item)
GET /npcs/locations yer dropdown verisi
GET /npcs/catalog quest + item kataloğu (çoktan seçmeli için)
POST /npcs yeni NPC
POST /npcs/{id} güncelle
POST /npcs/{id}/delete sil
POST /npcs/{id}/quests bu NPC'nin verdiği görevleri ayarla (exclusive)
POST /npcs/{id}/items bu NPC'nin sattığı itemları ayarla (replace)
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm
from models import User, get_db
router = APIRouter(prefix="/npcs", tags=["npcs"])
STANCES = {"friendly", "hostile", "neutral"}
class QuestMini(BaseModel):
id: int
title: str
class ItemMini(BaseModel):
id: int
code: str | None
name: str
type: str | None
rarity: str | None
class NpcIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
role: str | None = Field(default=None, max_length=80)
map_tag_id: int | None = None
stance: str = "neutral"
description: str | None = Field(default=None, max_length=20000)
notes: str | None = Field(default=None, max_length=20000)
class NpcOut(BaseModel):
id: int
name: str
role: str | None
map_tag_id: int | None
location_name: str | None
location_kind: str | None
location_category: str | None
stance: str
description: str | None
notes: str | None
quests: list[QuestMini]
sells: list[ItemMini]
updated_at: str
class LocationRow(BaseModel):
id: int
name: str
kind: str
category: str | None
continent: str | None
class Catalog(BaseModel):
quests: list[dict] # {id,title,giver_npc_id}
items: list[ItemMini]
class IdList(BaseModel):
ids: list[int] = Field(default_factory=list)
async def _check_location(db: AsyncSession, map_tag_id: int | None) -> None:
if map_tag_id is None:
return
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
{"id": map_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Yer yok (map editöründe çizilen alan/pin olmalı)")
def _check_stance(stance: str) -> None:
if stance not in STANCES:
raise HTTPException(400, "Geçersiz taraf (friendly/hostile/neutral)")
_SEL = (
"SELECT n.id,n.name,n.role,n.map_tag_id,t.name AS location_name,t.kind AS location_kind,"
"t.category AS location_category,n.stance,n.description,n.notes,n.updated_at "
"FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id "
)
async def _links(db: AsyncSession, npc_ids: list[int]) -> tuple[dict, dict]:
"""Verilen npc'ler için {npc_id: [quests]} ve {npc_id: [items]} döndür."""
quests: dict[int, list] = {i: [] for i in npc_ids}
items: dict[int, list] = {i: [] for i in npc_ids}
if not npc_ids:
return quests, items
qrows = (await db.execute(text(
"SELECT id,title,giver_npc_id FROM quests WHERE giver_npc_id = ANY(:ids)"
), {"ids": npc_ids})).mappings().all()
for r in qrows:
quests[r["giver_npc_id"]].append(QuestMini(id=r["id"], title=r["title"]))
irows = (await db.execute(text(
"SELECT ni.npc_id, i.id,i.code,i.name,i.type,i.rarity "
"FROM npc_items ni JOIN items i ON i.id=ni.item_id "
"WHERE ni.npc_id = ANY(:ids) ORDER BY i.type,i.name"
), {"ids": npc_ids})).mappings().all()
for r in irows:
items[r["npc_id"]].append(ItemMini(id=r["id"], code=r["code"], name=r["name"],
type=r["type"], rarity=r["rarity"]))
return quests, items
async def _one(db: AsyncSession, npc_id: int) -> NpcOut:
got = (await db.execute(text(_SEL + "WHERE n.id=:id"), {"id": npc_id})).mappings().one()
q, it = await _links(db, [npc_id])
d = dict(got)
d["updated_at"] = d["updated_at"].isoformat()
d["quests"] = q[npc_id]
d["sells"] = it[npc_id]
return NpcOut(**d)
@router.get("", response_model=list[NpcOut])
async def list_npcs(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(_SEL + "ORDER BY t.name NULLS LAST, n.name"))).mappings().all()
ids = [r["id"] for r in rows]
q, it = await _links(db, ids)
out = []
for r in rows:
d = dict(r)
d["updated_at"] = d["updated_at"].isoformat()
d["quests"] = q[r["id"]]
d["sells"] = it[r["id"]]
out.append(NpcOut(**d))
return out
@router.get("/locations", response_model=list[LocationRow])
async def npc_locations(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
rows = (await db.execute(text(
"SELECT id,name,kind,category,continent FROM map_tags WHERE kind IN ('region','poi') "
"ORDER BY kind DESC, continent NULLS LAST, name"
))).mappings().all()
return [LocationRow(**dict(r)) for r in rows]
@router.get("/catalog", response_model=Catalog)
async def catalog(user: User = Depends(require_perm("npc")), db: AsyncSession = Depends(get_db)):
qrows = (await db.execute(text(
"SELECT id,title,giver_npc_id FROM quests ORDER BY title"
))).mappings().all()
irows = (await db.execute(text(
"SELECT id,code,name,type,rarity FROM items ORDER BY type,name"
))).mappings().all()
return Catalog(
quests=[dict(r) for r in qrows],
items=[ItemMini(**dict(r)) for r in irows],
)
@router.post("", response_model=NpcOut)
async def create_npc(n: NpcIn, user: User = Depends(require_perm("npc")),
db: AsyncSession = Depends(get_db)):
await _check_location(db, n.map_tag_id)
_check_stance(n.stance)
row = (await db.execute(text(
"INSERT INTO npcs(name,role,map_tag_id,stance,description,notes) "
"VALUES(:name,:role,:tag,:stance,:descr,:notes) RETURNING id"
), {"name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance,
"descr": n.description, "notes": n.notes})).scalar_one()
await db.commit()
return await _one(db, row)
@router.post("/{npc_id}", response_model=NpcOut)
async def update_npc(npc_id: int, n: NpcIn, user: User = Depends(require_perm("npc")),
db: AsyncSession = Depends(get_db)):
await _check_location(db, n.map_tag_id)
_check_stance(n.stance)
res = await db.execute(text(
"UPDATE npcs SET name=:name,role=:role,map_tag_id=:tag,stance=:stance,"
"description=:descr,notes=:notes,updated_at=now() WHERE id=:id"
), {"id": npc_id, "name": n.name, "role": n.role, "tag": n.map_tag_id, "stance": n.stance,
"descr": n.description, "notes": n.notes})
if res.rowcount == 0:
raise HTTPException(404, "NPC yok")
await db.commit()
return await _one(db, npc_id)
@router.post("/{npc_id}/quests", response_model=NpcOut)
async def set_quests(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")),
db: AsyncSession = Depends(get_db)):
"""Bu NPC'nin verdiği görevleri ayarla. Görev tek vericiye bağlıdır (exclusive)."""
exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar()
if not exists:
raise HTTPException(404, "NPC yok")
ids = list(dict.fromkeys(body.ids))
if ids:
# başka NPC'ye bağlı görev çalınamaz
taken = (await db.execute(text(
"SELECT title FROM quests WHERE id = ANY(:ids) AND giver_npc_id IS NOT NULL "
"AND giver_npc_id <> :nid"
), {"ids": ids, "nid": npc_id})).scalars().all()
if taken:
raise HTTPException(409, f"Bu görev(ler) başka NPC'ye bağlı: {', '.join(taken)}")
bad = (await db.execute(text(
"SELECT :cnt - count(*) FROM quests WHERE id = ANY(:ids)"
), {"ids": ids, "cnt": len(ids)})).scalar()
if bad:
raise HTTPException(400, "Geçersiz görev id")
# bu npc'nin listeden çıkarılan görevlerini serbest bırak
await db.execute(text(
"UPDATE quests SET giver_npc_id=NULL WHERE giver_npc_id=:nid "
"AND NOT (id = ANY(:ids))"
), {"nid": npc_id, "ids": ids})
# seçilenleri bu npc'ye bağla
if ids:
await db.execute(text(
"UPDATE quests SET giver_npc_id=:nid WHERE id = ANY(:ids)"
), {"nid": npc_id, "ids": ids})
await db.commit()
return await _one(db, npc_id)
@router.post("/{npc_id}/items", response_model=NpcOut)
async def set_items(npc_id: int, body: IdList, user: User = Depends(require_perm("npc")),
db: AsyncSession = Depends(get_db)):
"""Bu NPC'nin sattığı itemları ayarla (replace). Bir item birden çok NPC'de olabilir."""
exists = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": npc_id})).scalar()
if not exists:
raise HTTPException(404, "NPC yok")
ids = list(dict.fromkeys(body.ids))
if ids:
bad = (await db.execute(text(
"SELECT :cnt - count(*) FROM items WHERE id = ANY(:ids)"
), {"ids": ids, "cnt": len(ids)})).scalar()
if bad:
raise HTTPException(400, "Geçersiz item id")
await db.execute(text("DELETE FROM npc_items WHERE npc_id=:nid"), {"nid": npc_id})
if ids:
await db.execute(text(
"INSERT INTO npc_items(npc_id,item_id) "
"SELECT :nid, x FROM unnest(cast(:ids AS bigint[])) AS x ON CONFLICT DO NOTHING"
), {"nid": npc_id, "ids": ids})
await db.commit()
return await _one(db, npc_id)
@router.post("/{npc_id}/delete")
async def delete_npc(npc_id: int, user: User = Depends(require_perm("npc")),
db: AsyncSession = Depends(get_db)):
res = await db.execute(text("DELETE FROM npcs WHERE id=:id"), {"id": npc_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "NPC yok")
return {"deleted": npc_id}

258
routers/quests.py Normal file
View file

@ -0,0 +1,258 @@
"""
Görevler ana + yan görevler ("lore" izni; loremaster + admin).
Her görevin: görevi veren NPC, kıta, bölge (map_tags region), veriliş noktası (location/pin)
ve "nerede yapılacağı" (çok seçmeli objective location'lar) bilgisi tutulur.
Sahiplik: admin herkesinkini, diğerleri sadece kendi görevini düzenler/siler.
WAF nedeniyle sadece GET/POST.
GET /quests liste (giver + konum + bölge adları + objective'ler)
GET /quests/meta dropdown verisi (npcs, locations, regions, continents)
POST /quests yeni görev
POST /quests/{id} güncelle
POST /quests/{id}/delete sil
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import require_perm, get_user_perms, has_perm
from models import User, get_db
router = APIRouter(prefix="/quests", tags=["quests"])
TYPES = {"main", "side"}
def _author(user: User) -> str:
return (user.display_name or "").strip() or user.email
async def _assert_owner_or_admin(db: AsyncSession, user: User, row_id: int):
perms = await get_user_perms(user, db)
if has_perm(perms, "*"):
return
owner = (await db.execute(text(
"SELECT author_id FROM quests WHERE id=:id"
), {"id": row_id})).scalar_one_or_none()
if owner is None:
raise HTTPException(404, "Görev yok")
if owner != user.id:
raise HTTPException(403, "Sadece kendi görevini düzenleyebilir/silebilirsin")
# ---- Şemalar ----
class ObjectiveIn(BaseModel):
map_tag_id: int | None = None
note: str | None = Field(default=None, max_length=500)
class QuestIn(BaseModel):
title: str = Field(min_length=1, max_length=200)
quest_type: str = "side"
summary: str | None = Field(default=None, max_length=20000)
giver_npc_id: int | None = None
continent: str | None = Field(default=None, max_length=80)
region_tag_id: int | None = None
location_tag_id: int | None = None
objectives: list[ObjectiveIn] = Field(default_factory=list)
class ObjectiveOut(BaseModel):
id: int
map_tag_id: int | None
location_name: str | None
location_kind: str | None
note: str | None
class QuestOut(BaseModel):
id: int
title: str
quest_type: str
summary: str | None
giver_npc_id: int | None
giver_npc_name: str | None
continent: str | None
region_tag_id: int | None
region_name: str | None
location_tag_id: int | None
location_name: str | None
author: str | None
author_id: int | None
objectives: list[ObjectiveOut]
created_at: str
updated_at: str
# ---- Meta (dropdown) ----
class MetaNpc(BaseModel):
id: int
name: str
map_tag_id: int | None
location_name: str | None
class MetaPlace(BaseModel):
id: int
name: str
kind: str
continent: str | None
class MetaRegion(BaseModel):
id: int
name: str
continent: str | None
class MetaOut(BaseModel):
npcs: list[MetaNpc]
places: list[MetaPlace]
regions: list[MetaRegion]
continents: list[str]
@router.get("/meta", response_model=MetaOut)
async def quest_meta(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
npcs = (await db.execute(text(
"SELECT n.id,n.name,n.map_tag_id,t.name AS location_name "
"FROM npcs n LEFT JOIN map_tags t ON t.id=n.map_tag_id ORDER BY n.name"
))).mappings().all()
places = (await db.execute(text(
"SELECT id,name,kind,continent FROM map_tags WHERE kind IN ('region','poi') "
"ORDER BY kind DESC, continent NULLS LAST, name"
))).mappings().all()
regions = (await db.execute(text(
"SELECT id,name,continent FROM map_tags WHERE kind='region' ORDER BY continent NULLS LAST,name"
))).mappings().all()
conts = (await db.execute(text(
"SELECT name FROM map_tags WHERE kind='continent' ORDER BY name"
))).scalars().all()
return MetaOut(
npcs=[MetaNpc(**dict(r)) for r in npcs],
places=[MetaPlace(**dict(r)) for r in places],
regions=[MetaRegion(**dict(r)) for r in regions],
continents=list(conts),
)
# ---- Yardımcı: tek görevi topla ----
async def _load_quest(db: AsyncSession, quest_id: int) -> QuestOut | None:
r = (await db.execute(text(
"SELECT q.id,q.title,q.quest_type,q.summary,"
"q.giver_npc_id,n.name AS giver_npc_name,"
"q.continent,q.region_tag_id,t.name AS region_name,"
"q.location_tag_id,lt.name AS location_name,"
"q.author,q.author_id,q.created_at,q.updated_at "
"FROM quests q "
"LEFT JOIN npcs n ON n.id=q.giver_npc_id "
"LEFT JOIN map_tags t ON t.id=q.region_tag_id "
"LEFT JOIN map_tags lt ON lt.id=q.location_tag_id "
"WHERE q.id=:id"
), {"id": quest_id})).mappings().one_or_none()
if r is None:
return None
objs = (await db.execute(text(
"SELECT o.id,o.map_tag_id,mt.name AS location_name,mt.kind AS location_kind,o.note "
"FROM quest_objectives o LEFT JOIN map_tags mt ON mt.id=o.map_tag_id "
"WHERE o.quest_id=:id ORDER BY o.ordering,o.id"
), {"id": quest_id})).mappings().all()
d = dict(r)
d["created_at"] = d["created_at"].isoformat()
d["updated_at"] = d["updated_at"].isoformat()
d["objectives"] = [ObjectiveOut(**dict(o)) for o in objs]
return QuestOut(**d)
async def _validate(db: AsyncSession, q: QuestIn):
if q.quest_type not in TYPES:
raise HTTPException(400, "quest_type: main | side")
if q.giver_npc_id is not None:
ok = (await db.execute(text("SELECT 1 FROM npcs WHERE id=:id"), {"id": q.giver_npc_id})).scalar()
if not ok:
raise HTTPException(400, "Görev veren NPC yok")
if q.region_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind='region'"),
{"id": q.region_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Bölge yok")
if q.location_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
{"id": q.location_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Veriliş noktası yok (map editörü alan/pin olmalı)")
for o in q.objectives:
if o.map_tag_id is not None:
ok = (await db.execute(text("SELECT 1 FROM map_tags WHERE id=:id AND kind IN ('region','poi')"),
{"id": o.map_tag_id})).scalar()
if not ok:
raise HTTPException(400, "Görev noktası yok (map editörü alan/pin olmalı)")
async def _save_objectives(db: AsyncSession, quest_id: int, objectives: list[ObjectiveIn]):
await db.execute(text("DELETE FROM quest_objectives WHERE quest_id=:id"), {"id": quest_id})
for i, o in enumerate(objectives):
if o.map_tag_id is None and not (o.note or "").strip():
continue
await db.execute(text(
"INSERT INTO quest_objectives(quest_id,map_tag_id,note,ordering) "
"VALUES(:q,:tag,:note,:ord)"
), {"q": quest_id, "tag": o.map_tag_id, "note": (o.note or None), "ord": i})
# ---- CRUD ----
@router.get("", response_model=list[QuestOut])
async def list_quests(user: User = Depends(require_perm("lore")), db: AsyncSession = Depends(get_db)):
ids = (await db.execute(text(
"SELECT id FROM quests ORDER BY quest_type,sort_order,id"
))).scalars().all()
return [await _load_quest(db, i) for i in ids]
@router.post("", response_model=QuestOut)
async def create_quest(q: QuestIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _validate(db, q)
qid = (await db.execute(text(
"INSERT INTO quests(title,quest_type,summary,giver_npc_id,continent,region_tag_id,"
"location_tag_id,author,author_id) "
"VALUES(:title,:qt,:summary,:giver,:cont,:region,:loc,:author,:aid) RETURNING id"
), {"title": q.title, "qt": q.quest_type, "summary": q.summary, "giver": q.giver_npc_id,
"cont": q.continent, "region": q.region_tag_id, "loc": q.location_tag_id,
"author": _author(user), "aid": user.id})).scalar_one()
await _save_objectives(db, qid, q.objectives)
await db.commit()
return await _load_quest(db, qid)
@router.post("/{quest_id}", response_model=QuestOut)
async def update_quest(quest_id: int, q: QuestIn, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, quest_id)
await _validate(db, q)
res = await db.execute(text(
"UPDATE quests SET title=:title,quest_type=:qt,summary=:summary,giver_npc_id=:giver,"
"continent=:cont,region_tag_id=:region,location_tag_id=:loc,updated_at=now() WHERE id=:id"
), {"id": quest_id, "title": q.title, "qt": q.quest_type, "summary": q.summary,
"giver": q.giver_npc_id, "cont": q.continent, "region": q.region_tag_id,
"loc": q.location_tag_id})
if res.rowcount == 0:
raise HTTPException(404, "Görev yok")
await _save_objectives(db, quest_id, q.objectives)
await db.commit()
return await _load_quest(db, quest_id)
@router.post("/{quest_id}/delete")
async def delete_quest(quest_id: int, user: User = Depends(require_perm("lore")),
db: AsyncSession = Depends(get_db)):
await _assert_owner_or_admin(db, user, quest_id)
res = await db.execute(text("DELETE FROM quests WHERE id=:id"), {"id": quest_id})
await db.commit()
if res.rowcount == 0:
raise HTTPException(404, "Görev yok")
return {"deleted": quest_id}

536
routers/travel.py Normal file
View file

@ -0,0 +1,536 @@
"""
Lokasyon-graf dünya: statik harita üzerinde noktalar (şehir/orman/maden...),
yollar (adım maliyeti), lokasyonda kaynak toplama ve yolda D&D tarzı olaylar.
Eski tile/chunk sistemi kaldırıldı (2026-07-04). Akış:
- GET /world/map tüm lokasyonlar + yollar + benim yerim
- GET /world/location bulunduğum yer: kaynaklar (cooldown/tool durumu), buradaki oyuncular, bekleyen olay
- POST /world/travel yol varsa adım öde, git; şansa bağlı olay kartı döner
- POST /world/gather lokasyon kaynağını topla (tool şartı + cooldown + adım + XP)
- POST /world/event/resolve olay kartında seçim yap (skill check d20 + skill seviyesi)
"""
import random
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from game_logic import character_level_from_total_xp, skill_level_from_xp
from models import Character, Skill, User, get_db
from routers.harvest import SKILL_TOOLS, TOOL_LABEL, _has_any_tool, consume_items
router = APIRouter(prefix="/world", tags=["travel"])
# Kamp: lokasyonda kişisel kamp → o lokasyonda gather adım maliyeti -%25
CAMP_COST_ITEMS = {"log_oak": 5, "stone_small": 2, "vine": 1}
CAMP_STEP_COST = 100
CAMP_XP = 40 # construction
CAMP_DISCOUNT = 0.25
def _camp_cost(step_cost: int, has_camp: bool) -> int:
if not has_camp:
return step_cost
return max(1, int(step_cost * (1.0 - CAMP_DISCOUNT)))
async def _has_camp(db: AsyncSession, char_id: int, location_id: int | None) -> bool:
if location_id is None:
return False
row = (await db.execute(text(
"SELECT 1 FROM camps WHERE character_id=:c AND location_id=:l"
), {"c": char_id, "l": location_id})).first()
return row is not None
async def _char(db: AsyncSession, user: User) -> Character:
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
return char
async def _add_items(db: AsyncSession, char_id: int, item_code: str, qty: int) -> None:
"""Envantere stack mantığıyla item ekle."""
row = (await db.execute(
text("SELECT id, stack_max FROM items WHERE code = :c"), {"c": item_code}
)).first()
if not row:
return
item_id, stack_max = row[0], int(row[1])
remaining = qty
stacks = (await db.execute(text(
"SELECT id, qty FROM inventory WHERE character_id=:cid AND item_id=:iid ORDER BY slot"
), {"cid": char_id, "iid": item_id})).all()
for st in stacks:
if remaining <= 0:
return
space = stack_max - st[1]
if space <= 0:
continue
add = min(space, remaining)
await db.execute(text("UPDATE inventory SET qty = qty + :a WHERE id = :id"), {"a": add, "id": st[0]})
remaining -= add
while remaining > 0:
add = min(stack_max, remaining)
slot = (await db.execute(text(
"SELECT COALESCE(MAX(slot), -1) + 1 FROM inventory WHERE character_id=:cid"
), {"cid": char_id})).first()[0]
await db.execute(text(
"INSERT INTO inventory (character_id, item_id, qty, slot) VALUES (:cid, :iid, :q, :s)"
), {"cid": char_id, "iid": item_id, "q": add, "s": slot})
remaining -= add
def _grant_xp(char: Character, skill: Skill, xp: int) -> dict:
old_sl, old_cl = skill.level, char.level
skill.xp += xp
skill.level = skill_level_from_xp(skill.xp)
char.total_xp += xp
char.level = character_level_from_total_xp(char.total_xp)
return {"skill_level_up": skill.level > old_sl, "character_level_up": char.level > old_cl}
# ---------- Harita ----------
class LocationOut(BaseModel):
code: str
name: str
type: str
description: Optional[str] = None
map_x: int
map_y: int
class RoadOut(BaseModel):
a: str
b: str
steps: int
waypoints: list[list[float]] = [] # normalize 0-1 ara noktalar (editörde çizilir)
class MapOut(BaseModel):
locations: list[LocationOut]
roads: list[RoadOut]
my_location: Optional[str] = None
pending_event: Optional[dict] = None
@router.get("/map", response_model=MapOut)
async def world_map(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
locs = (await db.execute(text(
"SELECT id, code, name, type, description, map_x, map_y FROM locations ORDER BY id"
))).all()
id2code = {r[0]: r[1] for r in locs}
roads = (await db.execute(text("SELECT loc_a, loc_b, step_cost, waypoints FROM roads"))).all()
return MapOut(
locations=[LocationOut(code=r[1], name=r[2], type=r[3], description=r[4], map_x=r[5], map_y=r[6]) for r in locs],
roads=[RoadOut(a=id2code[r[0]], b=id2code[r[1]], steps=r[2], waypoints=r[3] or []) for r in roads],
my_location=id2code.get(char.location_id),
pending_event=char.pending_event,
)
# ---------- Lokasyon detay ----------
class ResourceOut(BaseModel):
item_code: str
item_name: str
skill: str
step_cost: int
xp: int
cooldown_remaining: int # saniye, 0 = hazır
tool_ok: bool
tool_needed: Optional[str] = None
class LocationDetailOut(BaseModel):
code: str
name: str
type: str
description: Optional[str] = None
resources: list[ResourceOut]
players_here: list[str]
pending_event: Optional[dict] = None
has_camp: bool = False
camp_cost_items: dict = {} # kamp yoksa: kurulum malzemeleri {code: qty}
camp_step_cost: int = CAMP_STEP_COST
@router.get("/location", response_model=LocationDetailOut)
async def location_detail(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
if char.location_id is None:
raise HTTPException(400, "Konumun yok — sunucu seed'i eksik olabilir")
loc = (await db.execute(text(
"SELECT id, code, name, type, description FROM locations WHERE id = :i"
), {"i": char.location_id})).first()
now = datetime.now(timezone.utc)
rows = (await db.execute(text("""
SELECT lr.id, lr.item_code, i.name, lr.skill, lr.step_cost, lr.xp, gc.ready_at
FROM location_resources lr
JOIN items i ON i.code = lr.item_code
LEFT JOIN gather_cooldowns gc ON gc.resource_id = lr.id AND gc.character_id = :cid
WHERE lr.location_id = :lid
ORDER BY lr.step_cost
"""), {"cid": char.id, "lid": char.location_id})).all()
has_camp = await _has_camp(db, char.id, char.location_id)
resources = []
for r in rows:
tool_codes = SKILL_TOOLS.get(r[3])
tool_ok = True
if tool_codes:
tool_ok = await _has_any_tool(db, char.id, tool_codes)
cd = 0
if r[6] is not None and r[6] > now:
cd = int((r[6] - now).total_seconds())
resources.append(ResourceOut(
item_code=r[1], item_name=r[2], skill=r[3],
step_cost=_camp_cost(int(r[4]), has_camp), xp=r[5],
cooldown_remaining=cd, tool_ok=tool_ok,
tool_needed=TOOL_LABEL.get(r[3]),
))
names = (await db.execute(text(
"SELECT name FROM characters WHERE location_id = :lid AND id != :cid ORDER BY name LIMIT 50"
), {"lid": char.location_id, "cid": char.id})).all()
return LocationDetailOut(
code=loc[1], name=loc[2], type=loc[3], description=loc[4],
resources=resources,
players_here=[n[0] for n in names],
pending_event=char.pending_event,
has_camp=has_camp,
camp_cost_items={} if has_camp else CAMP_COST_ITEMS,
)
# ---------- Seyahat ----------
class TravelIn(BaseModel):
to_code: str
class TravelOut(BaseModel):
location_code: str
location_name: str
step_cost: int
step_balance: int
event: Optional[dict] = None # {code, name, text, choices:[{text}]} — çözülmesi gerekir
@router.post("/travel", response_model=TravelOut)
async def travel(t: TravelIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
if char.pending_event:
raise HTTPException(400, "Önce bekleyen olayı çöz")
dest = (await db.execute(text(
"SELECT id, code, name FROM locations WHERE code = :c"
), {"c": t.to_code})).first()
if not dest:
raise HTTPException(404, "Böyle bir yer yok")
if dest[0] == char.location_id:
raise HTTPException(400, "Zaten oradasın")
road = (await db.execute(text("""
SELECT step_cost, event_chance FROM roads
WHERE (loc_a = :a AND loc_b = :b) OR (loc_a = :b AND loc_b = :a)
"""), {"a": char.location_id, "b": dest[0]})).first()
if not road:
raise HTTPException(400, "Oraya buradan yol yok")
cost = int(road[0])
if char.step_balance < cost:
raise HTTPException(400, f"Yetersiz adım: {cost} gerekli, {char.step_balance} var")
char.step_balance -= cost
char.location_id = dest[0]
char.last_sync_at = datetime.now(timezone.utc)
# Yolda olay? (d100 < şans). %50 önce varış lokasyonuna özel havuz denenir,
# yoksa/diğer yarıda genel + özel karışık havuzdan gelir.
event_payload = None
if random.random() < float(road[1]):
ev = None
if random.random() < 0.5:
ev = (await db.execute(text(
"SELECT code, name, text, choices FROM events "
"WHERE :dest = ANY(location_codes) ORDER BY random() LIMIT 1"
), {"dest": dest[1]})).first()
if ev is None:
ev = (await db.execute(text(
"SELECT code, name, text, choices FROM events "
"WHERE location_codes IS NULL OR :dest = ANY(location_codes) "
"ORDER BY random() LIMIT 1"
), {"dest": dest[1]})).first()
if ev:
# Oyuncuya sadece seçim metinleri gider; sonuçlar sunucuda kalır
choices_client = [{"text": c["text"]} for c in ev[3]]
event_payload = {"code": ev[0], "name": ev[1], "text": ev[2], "choices": choices_client}
# Tam kart saklanır ki istemci reconnect'te de render edebilsin
char.pending_event = event_payload
await db.commit()
# WS yayını
from routers.ws import publish_event, set_online
payload = {"char_id": char.id, "name": char.name, "level": char.level, "location_code": dest[1]}
await set_online(char.id, payload)
await publish_event({"type": "player_moved", **payload})
return TravelOut(
location_code=dest[1], location_name=dest[2],
step_cost=cost, step_balance=char.step_balance,
event=event_payload,
)
# ---------- Kaynak toplama ----------
class GatherIn(BaseModel):
item_code: str
class GatherOut(BaseModel):
item_code: str
item_name: str
qty_gained: int
skill: str
xp_gained: int
skill_level: int
skill_level_up: bool
total_xp: int
character_level: int
character_level_up: bool
step_balance: int
cooldown_s: int
@router.post("/gather", response_model=GatherOut)
async def gather(g: GatherIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
if char.pending_event:
raise HTTPException(400, "Önce bekleyen olayı çöz")
res = (await db.execute(text("""
SELECT lr.id, lr.item_code, i.name, lr.skill, lr.step_cost, lr.xp, lr.cooldown_s
FROM location_resources lr JOIN items i ON i.code = lr.item_code
WHERE lr.location_id = :lid AND lr.item_code = :c
"""), {"lid": char.location_id, "c": g.item_code})).first()
if not res:
raise HTTPException(404, "Bu kaynak burada yok")
# Tool şartı
tool_codes = SKILL_TOOLS.get(res[3])
if tool_codes and not await _has_any_tool(db, char.id, tool_codes):
raise HTTPException(400, f"{TOOL_LABEL[res[3]]} gerekli — envanterinde yok")
# Cooldown
now = datetime.now(timezone.utc)
cd = (await db.execute(text(
"SELECT ready_at FROM gather_cooldowns WHERE character_id=:cid AND resource_id=:rid"
), {"cid": char.id, "rid": res[0]})).first()
if cd and cd[0] > now:
raise HTTPException(400, f"Bu kaynak yenileniyor ({int((cd[0] - now).total_seconds())}s)")
step_cost = _camp_cost(int(res[4]), await _has_camp(db, char.id, char.location_id))
if char.step_balance < step_cost:
raise HTTPException(400, f"Yetersiz adım: {step_cost} gerekli, {char.step_balance} var")
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == res[3])
)).scalar_one_or_none()
if skill is None:
skill = Skill(character_id=char.id, skill_name=res[3], xp=0, level=1)
db.add(skill)
await db.flush()
char.step_balance -= step_cost
ups = _grant_xp(char, skill, int(res[5]))
await _add_items(db, char.id, res[1], 1)
await db.execute(text("""
INSERT INTO gather_cooldowns (character_id, resource_id, ready_at)
VALUES (:cid, :rid, :ra)
ON CONFLICT (character_id, resource_id) DO UPDATE SET ready_at = EXCLUDED.ready_at
"""), {"cid": char.id, "rid": res[0], "ra": now + timedelta(seconds=int(res[6]))})
char.last_sync_at = now
await db.commit()
return GatherOut(
item_code=res[1], item_name=res[2], qty_gained=1,
skill=res[3], xp_gained=int(res[5]),
skill_level=skill.level, skill_level_up=ups["skill_level_up"],
total_xp=char.total_xp, character_level=char.level,
character_level_up=ups["character_level_up"],
step_balance=char.step_balance, cooldown_s=int(res[6]),
)
# ---------- Kamp kurma ----------
class CampBuildOut(BaseModel):
location_code: str
step_cost: int
xp_gained: int
skill: str = "construction"
skill_level: int
skill_level_up: bool
character_level: int
character_level_up: bool
step_balance: int
@router.post("/camp/build", response_model=CampBuildOut)
async def build_camp(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
if char.pending_event:
raise HTTPException(400, "Önce bekleyen olayı çöz")
if char.location_id is None:
raise HTTPException(400, "Konumun yok")
if await _has_camp(db, char.id, char.location_id):
raise HTTPException(400, "Burada zaten kampın var")
if char.step_balance < CAMP_STEP_COST:
raise HTTPException(400, f"Yetersiz adım: {CAMP_STEP_COST} gerekli, {char.step_balance} var")
# Malzemeler (hepsi birden — yetersizse consume_items 400 fırlatır, rollback olur)
for code, qty in CAMP_COST_ITEMS.items():
item = (await db.execute(text(
"SELECT id, name FROM items WHERE code=:c"), {"c": code})).first()
await consume_items(db, char.id, item[0], qty, item[1])
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == "construction")
)).scalar_one_or_none()
if skill is None:
skill = Skill(character_id=char.id, skill_name="construction", xp=0, level=1)
db.add(skill)
await db.flush()
char.step_balance -= CAMP_STEP_COST
ups = _grant_xp(char, skill, CAMP_XP)
await db.execute(text(
"INSERT INTO camps (character_id, location_id) VALUES (:c, :l)"
), {"c": char.id, "l": char.location_id})
loc_code = (await db.execute(text(
"SELECT code FROM locations WHERE id=:i"), {"i": char.location_id})).first()[0]
char.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return CampBuildOut(
location_code=loc_code, step_cost=CAMP_STEP_COST, xp_gained=CAMP_XP,
skill_level=skill.level, skill_level_up=ups["skill_level_up"],
character_level=char.level, character_level_up=ups["character_level_up"],
step_balance=char.step_balance,
)
# ---------- Olay çözümü ----------
class ResolveIn(BaseModel):
choice: int = Field(ge=0, le=5)
class ResolveOut(BaseModel):
event_code: str
choice_text: str
skill_check: Optional[dict] = None # {skill, dc, roll, skill_level, success}
result_text: str
steps_delta: int
gold_delta: int = 0
items_gained: list[dict]
xp_gained: Optional[dict] = None # {skill, xp}
step_balance: int
gold: int = 0
@router.post("/event/resolve", response_model=ResolveOut)
async def resolve_event(r: ResolveIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = await _char(db, user)
if not char.pending_event:
raise HTTPException(400, "Bekleyen olay yok")
ev = (await db.execute(text(
"SELECT code, name, choices FROM events WHERE code = :c"
), {"c": char.pending_event.get("code")})).first()
if not ev:
char.pending_event = None
await db.commit()
raise HTTPException(500, "Olay bulunamadı, temizlendi")
choices = ev[2]
if r.choice >= len(choices):
raise HTTPException(400, "Geçersiz seçim")
ch = choices[r.choice]
# Skill check'li mi?
skill_check = None
if "skill" in ch:
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == ch["skill"])
)).scalar_one_or_none()
lvl = skill.level if skill else 1
roll = random.randint(1, 20)
success = roll + lvl >= int(ch["dc"])
skill_check = {"skill": ch["skill"], "dc": int(ch["dc"]), "roll": roll,
"skill_level": lvl, "success": success}
outcome = ch["success"] if success else ch["fail"]
else:
outcome = ch["outcome"]
# Sonucu uygula
steps_delta = int(outcome.get("steps", 0))
if steps_delta < 0:
steps_delta = -min(-steps_delta, char.step_balance) # bakiye eksiye düşmez
char.step_balance += steps_delta
gold_delta = int(outcome.get("gold", 0))
if gold_delta < 0:
gold_delta = -min(-gold_delta, char.gold)
char.gold += gold_delta
items_gained = []
for it in outcome.get("items", []):
await _add_items(db, char.id, it["code"], int(it["qty"]))
name = (await db.execute(text("SELECT name FROM items WHERE code=:c"), {"c": it["code"]})).first()
items_gained.append({"code": it["code"], "name": name[0] if name else it["code"], "qty": int(it["qty"])})
xp_gained = None
if "xp" in outcome:
skill_name = outcome["xp"]["skill"]
skill = (await db.execute(
select(Skill).where(Skill.character_id == char.id, Skill.skill_name == skill_name)
)).scalar_one_or_none()
if skill is None:
skill = Skill(character_id=char.id, skill_name=skill_name, xp=0, level=1)
db.add(skill)
await db.flush()
_grant_xp(char, skill, int(outcome["xp"]["amount"]))
xp_gained = {"skill": skill_name, "xp": int(outcome["xp"]["amount"])}
char.pending_event = None
char.last_sync_at = datetime.now(timezone.utc)
await db.commit()
return ResolveOut(
event_code=ev[0],
choice_text=ch["text"],
skill_check=skill_check,
result_text=outcome.get("text", ""),
steps_delta=steps_delta,
gold_delta=gold_delta,
items_gained=items_gained,
xp_gained=xp_gained,
step_balance=char.step_balance,
gold=char.gold,
)

194
routers/world.py Normal file
View file

@ -0,0 +1,194 @@
"""
World: pedometer adım senkronizasyonu + günlük hedef/streak.
NOT (2026-07-04): Tile/chunk sistemi kaldırıldı dünya artık lokasyon-graf
(bkz. routers/travel.py: /world/map, /world/travel, /world/gather, /world/event/resolve).
"""
from datetime import date, datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from deps import get_current_user
from models import Character, User, get_db
router = APIRouter(prefix="/world", tags=["world"])
# Pedometer adım senkronizasyonu (gerçek cihaz)
STEP_CAP_PER_MIN = 250 # dakika başı kabul edilen maks adım (koşu dahil)
STEP_SYNC_GRACE = 500 # her sync'te elapsed-tavanına eklenen sabit pay
STEP_FIRST_SYNC_MAX = 5000 # ilk sync / last_sync_at boşken kabul tavanı
STEP_SYNC_MIN_INTERVAL = 10 # iki sync arası min saniye (spam koruması)
# Günlük hedef + streak
DAILY_GOAL = 3000 # günlük gerçek adım hedefi
DAILY_BONUS_BASE = 200 # hedef ödülü (adım bakiyesi)
DAILY_BONUS_PER_STREAK = 50 # streak günü başına ek (7 günde tavan)
DAILY_STREAK_CAP = 7
DAILY_GOLD_BONUS = 5 # hedef ödülü (gold)
def _roll_daily(char: Character, today: date) -> None:
"""Gün değiştiyse günlük sayacı sıfırla; ödül dünden alınmadıysa streak kırılır."""
if char.daily_date != today:
char.daily_date = today
char.daily_steps = 0
if char.last_claim_date is not None and (today - char.last_claim_date).days > 1:
char.streak_days = 0
class SyncStepsIn(BaseModel):
delta: int = Field(ge=0, le=200000) # son sync'ten beri eklenen adım
source: str = Field("unknown", max_length=32)
class SyncStepsOut(BaseModel):
step_balance: int
step_lifetime: int
accepted: int
rejected: int
reason: Optional[str] = None
@router.post("/sync-steps", response_model=SyncStepsOut)
async def sync_steps(d: SyncStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
now = datetime.now(timezone.utc)
last = char.last_sync_at
reason = None
# tavan hesabı
if last is None:
cap = STEP_FIRST_SYNC_MAX
else:
elapsed = (now - last).total_seconds()
if elapsed < STEP_SYNC_MIN_INTERVAL and d.delta > 0:
# çok sık sync — yine de kabul et ama küçük tavan uygula
elapsed = STEP_SYNC_MIN_INTERVAL
cap = int(elapsed / 60.0 * STEP_CAP_PER_MIN) + STEP_SYNC_GRACE
accepted = min(d.delta, cap)
rejected = d.delta - accepted
if rejected > 0:
reason = "cap_exceeded: delta=%d cap=%d" % (d.delta, cap)
_roll_daily(char, now.date())
if accepted > 0:
char.step_balance += accepted
char.step_lifetime += accepted
char.daily_steps += accepted
char.last_sync_at = now
src = (d.source or "unknown")[:32]
if d.delta > 0:
await db.execute(text(
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
"VALUES (:cid, :sd, :src, :acc, :rsn)"
), {"cid": char.id, "sd": d.delta, "src": src, "acc": rejected == 0, "rsn": reason})
await db.commit()
return SyncStepsOut(
step_balance=char.step_balance,
step_lifetime=char.step_lifetime,
accepted=accepted,
rejected=rejected,
reason=reason,
)
# DEBUG: adım yükleme (pedometer gelene kadar test için)
class DebugAddStepsIn(BaseModel):
steps: int = Field(gt=0, le=10000)
@router.post("/debug/add-steps", response_model=dict)
async def debug_add_steps(d: DebugAddStepsIn, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
_roll_daily(char, datetime.now(timezone.utc).date())
char.step_balance += d.steps
char.step_lifetime += d.steps
char.daily_steps += d.steps
await db.commit()
return {"step_balance": char.step_balance, "step_lifetime": char.step_lifetime}
# ---------- Günlük hedef + streak ----------
class DailyOut(BaseModel):
goal: int
today_steps: int
streak_days: int
claimable: bool
claimed_today: bool
bonus_steps: int # şu anki streak'e göre bugünkü ödül
bonus_gold: int
def _bonus_for(streak_after_claim: int) -> int:
return DAILY_BONUS_BASE + DAILY_BONUS_PER_STREAK * min(streak_after_claim, DAILY_STREAK_CAP)
def _daily_out(char: Character, today: date) -> DailyOut:
claimed_today = char.last_claim_date == today
claimable = (not claimed_today) and char.daily_steps >= DAILY_GOAL
next_streak = char.streak_days if claimed_today else char.streak_days + 1
return DailyOut(
goal=DAILY_GOAL, today_steps=char.daily_steps, streak_days=char.streak_days,
claimable=claimable, claimed_today=claimed_today,
bonus_steps=_bonus_for(next_streak), bonus_gold=DAILY_GOLD_BONUS,
)
@router.get("/daily", response_model=DailyOut)
async def daily_status(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
today = datetime.now(timezone.utc).date()
_roll_daily(char, today)
await db.commit()
return _daily_out(char, today)
class DailyClaimOut(BaseModel):
streak_days: int
bonus_steps: int
bonus_gold: int
step_balance: int
gold: int
@router.post("/daily/claim", response_model=DailyClaimOut)
async def daily_claim(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
char = (await db.execute(select(Character).where(Character.user_id == user.id))).scalar_one_or_none()
if not char:
raise HTTPException(404, "Karakter yok")
today = datetime.now(timezone.utc).date()
_roll_daily(char, today)
if char.last_claim_date == today:
raise HTTPException(400, "Bugünkü ödülü zaten aldın")
if char.daily_steps < DAILY_GOAL:
raise HTTPException(400, f"Hedef dolmadı: {char.daily_steps}/{DAILY_GOAL}")
char.streak_days += 1
char.last_claim_date = today
bonus = _bonus_for(char.streak_days)
char.step_balance += bonus
char.gold += DAILY_GOLD_BONUS
await db.execute(text(
"INSERT INTO step_log (character_id, steps_delta, source, accepted, reason) "
"VALUES (:cid, :sd, 'daily_bonus', true, :rsn)"
), {"cid": char.id, "sd": bonus, "rsn": f"streak={char.streak_days}"})
await db.commit()
return DailyClaimOut(
streak_days=char.streak_days, bonus_steps=bonus, bonus_gold=DAILY_GOLD_BONUS,
step_balance=char.step_balance, gold=char.gold,
)

280
routers/ws.py Normal file
View file

@ -0,0 +1,280 @@
"""
WebSocket multiplayer canlı oyuncu pozisyonları.
Mimari: uvicorn 2 worker ile çalıştığı için bağlantılar süreçlere dağılır;
eventler Redis pub/sub kanalı üzerinden tüm worker'lara yayılır, her worker
kendi yerel bağlantılarına iletir. Online varlık (presence) Redis'te
TTL'li key olarak tutulur (heartbeat ile yenilenir).
Event türleri (JSON, hepsinde "type" alanı):
- online_list sadece yeni bağlanana: {"players": [player, ...]}
- chat_history sadece yeni bağlanana: {"messages": [chat, ...]} (son 30, eskiden yeniye)
- player_joined / player_moved player payload (char_id, name, level, chunk_x, chunk_y, x, y)
- player_left {"char_id": int}
- chat {"char_id", "name", "text", "ts", "channel", "location_code?"} (gönderene de gider)
- pong ping yanıtı
İstemciden gelenler:
- {"type":"ping"}
- {"type":"chat","text":"...","channel":"global"|"local"} (local = sadece aynı lokasyondakilere)
- {"type":"get_local_history"} bulunduğun lokasyonun son mesajları (chat_history, channel=local)
"""
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from sqlalchemy import select, text
from cache import redis_client
from models import AsyncSessionLocal, Character
from security import decode_token
log = logging.getLogger("stepstead.ws")
router = APIRouter(tags=["ws"])
CHANNEL = "ss:world_events"
ONLINE_KEY_PREFIX = "ss:online:"
ONLINE_TTL = 120 # saniye; istemci ping'i (25sn) ile yenilenir
CHAT_CHANNEL = "global"
CHAT_MAX_LEN = 300
CHAT_HISTORY_LIMIT = 30
CHAT_MIN_INTERVAL = 1.0 # saniye — basit flood koruması
# Bu event türleri üreticisine geri gönderilmez (chat gönderene de gider)
NO_ECHO_TYPES = {"player_joined", "player_moved", "player_left"}
def player_payload(char: Character, location_code: str | None) -> dict:
return {
"char_id": char.id,
"name": char.name,
"level": char.level,
"location_code": location_code,
}
async def publish_event(event: dict) -> None:
try:
await redis_client.publish(CHANNEL, json.dumps(event))
except Exception:
log.exception("Redis publish başarısız")
async def set_online(char_id: int, data: dict) -> None:
try:
await redis_client.set(ONLINE_KEY_PREFIX + str(char_id), json.dumps(data), ex=ONLINE_TTL)
except Exception:
log.exception("Online key yazılamadı")
class Hub:
"""Bu worker'a bağlı WebSocket'ler + Redis pub/sub dinleyicisi."""
def __init__(self) -> None:
self.local: dict[int, WebSocket] = {}
# char_id → lokasyon kodu (yerel chat filtrelemesi için; player_moved ile güncellenir)
self.locs: dict[int, str | None] = {}
self._listener: asyncio.Task | None = None
def ensure_listener(self) -> None:
if self._listener is None or self._listener.done():
self._listener = asyncio.create_task(self._listen())
async def _listen(self) -> None:
pubsub = redis_client.pubsub()
await pubsub.subscribe(CHANNEL)
try:
async for msg in pubsub.listen():
if msg["type"] != "message":
continue
await self._broadcast_local(msg["data"])
except Exception:
log.exception("pub/sub dinleyici düştü")
finally:
await pubsub.aclose()
async def _broadcast_local(self, raw: str) -> None:
# Pozisyon eventlerini üreten oyuncuya geri gönderme (echo önleme)
origin: int | None = None
local_chat_loc: str | None = None
try:
evt = json.loads(raw)
etype = evt.get("type")
if etype in NO_ECHO_TYPES:
origin = evt.get("char_id")
# Seyahat eden oyuncunun lokasyonunu takip et (yerel chat için)
if etype == "player_moved" and evt.get("char_id") in self.locs:
self.locs[evt["char_id"]] = evt.get("location_code")
if etype == "chat" and evt.get("channel") == "local":
local_chat_loc = evt.get("location_code")
except (json.JSONDecodeError, AttributeError):
pass
dead: list[int] = []
for cid, ws in list(self.local.items()):
if cid == origin:
continue
# Yerel chat sadece aynı lokasyondakilere gider
if local_chat_loc is not None and self.locs.get(cid) != local_chat_loc:
continue
try:
await ws.send_text(raw)
except Exception:
dead.append(cid)
for cid in dead:
self.local.pop(cid, None)
hub = Hub()
async def _chat_history(channel_key: str = CHAT_CHANNEL) -> list[dict]:
async with AsyncSessionLocal() as db:
rows = (await db.execute(text("""
SELECT m.sender_id, c.name, m.text, m.ts
FROM chat_messages m JOIN characters c ON c.id = m.sender_id
WHERE m.channel = :ch
ORDER BY m.ts DESC LIMIT :lim
"""), {"ch": channel_key, "lim": CHAT_HISTORY_LIMIT})).all()
return [
{"char_id": r[0], "name": r[1], "text": r[2], "ts": r[3].isoformat()}
for r in reversed(rows)
]
async def _save_chat(char_id: int, chat_text: str, channel_key: str = CHAT_CHANNEL) -> None:
async with AsyncSessionLocal() as db:
await db.execute(
text("INSERT INTO chat_messages (channel, sender_id, text) VALUES (:ch, :sid, :t)"),
{"ch": channel_key, "sid": char_id, "t": chat_text},
)
await db.commit()
@router.websocket("/ws")
async def world_ws(ws: WebSocket):
# Auth: token query param (WebSocket'te Authorization header her istemcide desteklenmez)
token = ws.query_params.get("token", "")
try:
payload = decode_token(token)
user_id = int(payload["sub"])
except Exception:
await ws.close(code=4401)
return
async with AsyncSessionLocal() as db:
char = (await db.execute(
select(Character).where(Character.user_id == user_id)
)).scalar_one_or_none()
loc_code = None
if char is not None and char.location_id is not None:
row = (await db.execute(
text("SELECT code FROM locations WHERE id = :i"), {"i": char.location_id}
)).first()
loc_code = row[0] if row else None
if char is None:
await ws.close(code=4404)
return
await ws.accept()
hub.ensure_listener()
hub.local[char.id] = ws
hub.locs[char.id] = loc_code
me = player_payload(char, loc_code)
# Mevcut online oyuncular sadece bu istemciye
online: list[dict] = []
try:
async for key in redis_client.scan_iter(match=ONLINE_KEY_PREFIX + "*"):
raw = await redis_client.get(key)
if not raw:
continue
try:
p = json.loads(raw)
except json.JSONDecodeError:
continue
if p.get("char_id") != char.id:
online.append(p)
except Exception:
log.exception("Online liste okunamadı")
await ws.send_text(json.dumps({"type": "online_list", "players": online}))
try:
history = await _chat_history()
except Exception:
log.exception("Chat history okunamadı")
history = []
await ws.send_text(json.dumps({"type": "chat_history", "channel": "global", "messages": history}))
await set_online(char.id, me)
await publish_event({"type": "player_joined", **me})
last_chat = 0.0
try:
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
msg_type = msg.get("type")
if msg_type == "ping":
# TTL yenile; key düştüyse (worker restart vs.) tekrar yaz
refreshed = await redis_client.expire(ONLINE_KEY_PREFIX + str(char.id), ONLINE_TTL)
if not refreshed:
await set_online(char.id, me)
await ws.send_text('{"type": "pong"}')
elif msg_type == "chat":
now_mono = time.monotonic()
if now_mono - last_chat < CHAT_MIN_INTERVAL:
continue
chat_text = str(msg.get("text", "")).strip()[:CHAT_MAX_LEN]
if not chat_text:
continue
last_chat = now_mono
channel = "local" if msg.get("channel") == "local" else "global"
cur_loc = hub.locs.get(char.id)
if channel == "local" and not cur_loc:
continue
channel_key = f"loc:{cur_loc}" if channel == "local" else CHAT_CHANNEL
try:
await _save_chat(char.id, chat_text, channel_key)
except Exception:
log.exception("Chat kaydedilemedi")
continue
event = {
"type": "chat",
"channel": channel,
"char_id": char.id,
"name": char.name,
"text": chat_text,
"ts": datetime.now(timezone.utc).isoformat(),
}
if channel == "local":
event["location_code"] = cur_loc
await publish_event(event)
elif msg_type == "get_local_history":
cur_loc = hub.locs.get(char.id)
messages: list[dict] = []
if cur_loc:
try:
messages = await _chat_history(f"loc:{cur_loc}")
except Exception:
log.exception("Yerel chat history okunamadı")
await ws.send_text(json.dumps(
{"type": "chat_history", "channel": "local", "messages": messages}))
except WebSocketDisconnect:
pass
except Exception:
log.exception("WS döngü hatası (char_id=%s)", char.id)
finally:
hub.local.pop(char.id, None)
hub.locs.pop(char.id, None)
try:
await redis_client.delete(ONLINE_KEY_PREFIX + str(char.id))
except Exception:
pass
await publish_event({"type": "player_left", "char_id": char.id})

40
security.py Normal file
View file

@ -0,0 +1,40 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from config import settings
BCRYPT_ROUNDS = 12
def _truncate(pw: str) -> bytes:
return pw.encode("utf-8")[:72]
def hash_password(password: str) -> str:
return bcrypt.hashpw(_truncate(password), bcrypt.gensalt(rounds=BCRYPT_ROUNDS)).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(_truncate(plain), hashed.encode())
except (ValueError, TypeError):
return False
def create_access_token(subject: str | int, extra: Optional[dict] = None) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(subject),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)).timestamp()),
}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])

117
tests/camp_e2e.py Normal file
View file

@ -0,0 +1,117 @@
"""Kamp + vendor stok + liderlik E2E — canlı backend'e karşı.
Çalıştırma: cd /opt/stepstead && ./venv/bin/python3 tests/camp_e2e.py
"""
import asyncio
import sys
sys.path.insert(0, "/opt/stepstead")
import asyncpg
import httpx
from config import settings
from security import create_access_token
BASE = "http://127.0.0.1:8002"
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
fails = 0
def check(ok: bool, what: str) -> None:
global fails
print(("" if ok else "") + what)
if not ok:
fails += 1
async def main() -> None:
conn = await asyncpg.connect(DSN)
uid = await conn.fetchval("SELECT id FROM users WHERE email='jts@jts.rocks'")
cid = await conn.fetchval("SELECT id FROM characters WHERE user_id=$1", uid)
# Hazırlık: fisilti_ormani'nda, kamp yok, malzemeler + bakiye + gold garanti
await conn.execute("""
UPDATE characters SET pending_event=NULL,
location_id=(SELECT id FROM locations WHERE code='fisilti_ormani'),
step_balance=GREATEST(step_balance,3000), gold=GREATEST(gold,500)
WHERE id=$1""", cid)
await conn.execute("DELETE FROM camps WHERE character_id=$1", cid)
for code, qty in (("log_oak", 5), ("stone_small", 2), ("vine", 1)):
iid = await conn.fetchval("SELECT id FROM items WHERE code=$1", code)
n = await conn.fetchval(
"SELECT COALESCE(SUM(qty),0) FROM inventory WHERE character_id=$1 AND item_id=$2", cid, iid)
if n < qty:
slot = await conn.fetchval(
"SELECT COALESCE(MAX(slot)+1,0) FROM inventory WHERE character_id=$1", cid)
await conn.execute(
"INSERT INTO inventory (character_id,item_id,qty,slot) VALUES ($1,$2,$3,$4)",
cid, iid, qty - n, slot)
tok = create_access_token(uid)
h = {"Authorization": f"Bearer {tok}"}
async with httpx.AsyncClient(base_url=BASE, timeout=15) as cl:
print("== Kamp ==")
r = await cl.get("/world/location", headers=h)
d = r.json()
check(r.status_code == 200 and d["has_camp"] is False and d["camp_cost_items"], "kampsız lokasyon detayı")
base_costs = {x["item_code"]: x["step_cost"] for x in d["resources"]}
r = await cl.post("/world/camp/build", headers=h)
d = r.json()
check(r.status_code == 200 and d["skill"] == "construction" and d["xp_gained"] == 40,
f"kamp kuruldu ({r.text[:90]})")
r = await cl.post("/world/camp/build", headers=h)
check(r.status_code == 400, "ikinci kamp reddedildi")
r = await cl.get("/world/location", headers=h)
d = r.json()
check(d["has_camp"] is True, "has_camp=true")
ok_discount = all(
x["step_cost"] == max(1, int(base_costs[x["item_code"]] * 0.75))
for x in d["resources"])
check(ok_discount, "kaynak maliyetleri -%25")
# İndirimli gather (foraging kaynağı varsa onunla, yoksa toolu olan biriyle)
target = next((x for x in d["resources"] if x["tool_ok"] and x["cooldown_remaining"] == 0), None)
if target:
bal0 = (await cl.get("/character/me", headers=h)).json()["step_balance"]
r = await cl.post("/world/gather", headers=h, json={"item_code": target["item_code"]})
check(r.status_code == 200 and bal0 - r.json()["step_balance"] == target["step_cost"],
f"indirimli gather ({target['item_code']}: {target['step_cost']}👣)")
else:
check(False, "gather edilebilir kaynak bulunamadı")
print("== Vendor stok ==")
r = await cl.get("/market/vendor/stock", headers=h)
stock = r.json()
check(r.status_code == 200 and len(stock) >= 5, f"stok listesi ({len(stock)} mal)")
# pazar dışında alım reddi
r = await cl.post("/market/vendor/buy", headers=h, json={"item_code": "tea_mint", "qty": 1})
check(r.status_code == 400, "pazar dışında alım reddedildi")
await conn.execute(
"UPDATE characters SET location_id=(SELECT id FROM locations WHERE code='pazar') WHERE id=$1", cid)
gold0 = (await cl.get("/character/me", headers=h)).json()["gold"]
price = next(x["unit_price"] for x in stock if x["item_code"] == "tea_mint")
r = await cl.post("/market/vendor/buy", headers=h, json={"item_code": "tea_mint", "qty": 2})
d = r.json()
check(r.status_code == 200 and d["total_price"] == price * 2 and d["gold"] == gold0 - price * 2,
f"vendor'dan 2 çay alındı ({r.text[:90]})")
r = await cl.post("/market/vendor/buy", headers=h, json={"item_code": "log_ebony", "qty": 1})
check(r.status_code == 400, "stokta olmayan mal reddedildi")
print("== Liderlik ==")
r = await cl.get("/leaderboard?by=steps", headers=h)
d = r.json()
check(r.status_code == 200 and d["rows"] and d["my_rank"] >= 1
and any(x["me"] for x in d["rows"]), f"steps sıralaması (benim sıram {d.get('my_rank')})")
r = await cl.get("/leaderboard?by=gold", headers=h)
check(r.status_code == 200 and r.json()["rows"], "gold sıralaması")
r = await cl.get("/leaderboard?by=level", headers=h)
check(r.status_code == 200 and r.json()["rows"], "level sıralaması")
r = await cl.get("/leaderboard?by=hile", headers=h)
check(r.status_code == 400, "geçersiz kriter reddedildi")
await conn.close()
print(f"== SONUÇ: {'BAŞARILI' if fails == 0 else 'BAŞARISIZ'} ({fails} hata) ==")
sys.exit(1 if fails else 0)
asyncio.run(main())

227
tests/econ_e2e.py Normal file
View file

@ -0,0 +1,227 @@
"""Ekonomi + günlük + yerel chat E2E — canlı backend'e karşı.
Çalıştırma: cd /opt/stepstead && ./venv/bin/python3 <bu dosya>
"""
import asyncio
import json
import sys
sys.path.insert(0, "/opt/stepstead")
import asyncpg
import httpx
import websockets
from config import settings
from security import create_access_token
BASE = "http://127.0.0.1:8002"
WS = "ws://127.0.0.1:8002/ws"
DSN = settings.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
fails = 0
def check(ok: bool, what: str) -> None:
global fails
print(("" if ok else "") + what)
if not ok:
fails += 1
async def main() -> None:
conn = await asyncpg.connect(DSN)
uid_a = await conn.fetchval("SELECT id FROM users WHERE email='jts@jts.rocks'")
uid_b = await conn.fetchval("SELECT id FROM users WHERE email='wstest@stepstead.local'")
cid_a = await conn.fetchval("SELECT id FROM characters WHERE user_id=$1", uid_a)
cid_b = await conn.fetchval("SELECT id FROM characters WHERE user_id=$1", uid_b)
# Hazırlık: ikisi de pazarda, bekleyen olay yok, bakiye/gold garanti, temiz ilanlar
await conn.execute("""
UPDATE characters SET pending_event=NULL,
location_id=(SELECT id FROM locations WHERE code='pazar'),
step_balance=GREATEST(step_balance,3000), gold=GREATEST(gold,500)
WHERE id = ANY($1::bigint[])""", [cid_a, cid_b])
await conn.execute("UPDATE market_listings SET status='cancelled', closed_at=now() WHERE status='open'")
# A'ya satılık 5 nane
mint = await conn.fetchval("SELECT id FROM items WHERE code='herb_mint'")
slot = await conn.fetchval("SELECT COALESCE(MAX(slot)+1,0) FROM inventory WHERE character_id=$1", cid_a)
await conn.execute("""
INSERT INTO inventory (character_id,item_id,qty,slot)
VALUES ($1,$2,10,$3)""", cid_a, mint, slot)
# Günlük test için A'nın günlük durumunu sıfırla
await conn.execute("""
UPDATE characters SET daily_steps=0, daily_date=NULL, streak_days=0, last_claim_date=NULL
WHERE id=$1""", cid_a)
tok_a, tok_b = create_access_token(uid_a), create_access_token(uid_b)
ha = {"Authorization": f"Bearer {tok_a}"}
hb = {"Authorization": f"Bearer {tok_b}"}
async with httpx.AsyncClient(base_url=BASE, timeout=15) as cl:
print("== Market ==")
r = await cl.post("/market/list", headers=ha,
json={"item_code": "herb_mint", "qty": 5, "unit_price": 3})
check(r.status_code == 200, f"A ilan verdi ({r.status_code} {r.text[:80]})")
lid = r.json().get("listing_id")
r = await cl.get("/market/listings", headers=hb)
check(r.status_code == 200 and any(x["id"] == lid for x in r.json()), "B ilanı listede gördü")
gold_b0 = (await cl.get("/character/me", headers=hb)).json()["gold"]
r = await cl.post("/market/buy", headers=hb, json={"listing_id": lid, "qty": 2})
check(r.status_code == 200 and r.json()["total_price"] == 6, f"B kısmi satın aldı ({r.text[:80]})")
check(r.json()["gold"] == gold_b0 - 6, "B gold düştü")
gold_a = (await cl.get("/character/me", headers=ha)).json()["gold"]
r = await cl.post("/market/buy", headers=ha, json={"listing_id": lid})
check(r.status_code == 400, "A kendi ilanını alamadı")
r = await cl.post("/market/cancel", headers=ha, json={"listing_id": lid})
check(r.status_code == 200, "A kalan ilanı iptal etti (item geri)")
r = await cl.post("/market/vendor/sell", headers=ha, json={"item_code": "herb_mint", "qty": 2})
check(r.status_code == 200 and r.json()["gold_gained"] == 10,
f"vendor'a satış 2×5 gold ({r.text[:80]})")
# Pazar dışında market engeli
koy = await conn.fetchval("SELECT id FROM locations WHERE code='koy'")
await conn.execute("UPDATE characters SET location_id=$1 WHERE id=$2", koy, cid_a)
r = await cl.post("/market/vendor/sell", headers=ha, json={"item_code": "herb_mint", "qty": 1})
check(r.status_code == 400, "pazar dışında satış reddedildi")
await conn.execute("UPDATE characters SET location_id=(SELECT id FROM locations WHERE code='pazar') WHERE id=$1", cid_a)
print("== Günlük hedef ==")
r = await cl.get("/world/daily", headers=ha)
d = r.json()
check(r.status_code == 200 and d["today_steps"] == 0 and not d["claimable"], f"daily başlangıç ({r.text[:100]})")
r = await cl.post("/world/daily/claim", headers=ha)
check(r.status_code == 400, "hedef dolmadan claim reddedildi")
await cl.post("/world/debug/add-steps", headers=ha, json={"steps": 3000})
r = await cl.get("/world/daily", headers=ha)
check(r.json()["claimable"], "3000 adım sonrası claimable")
r = await cl.post("/world/daily/claim", headers=ha)
d = r.json()
check(r.status_code == 200 and d["streak_days"] == 1 and d["bonus_steps"] == 250,
f"claim: streak 1, +250 adım +5 gold ({r.text[:100]})")
r = await cl.post("/world/daily/claim", headers=ha)
check(r.status_code == 400, "aynı gün ikinci claim reddedildi")
print("== Lokasyona özel olay ==")
await conn.execute("UPDATE roads SET event_chance=1.0")
await conn.execute("""
UPDATE characters SET location_id=(SELECT id FROM locations WHERE code='cayirlar'),
pending_event=NULL, step_balance=GREATEST(step_balance,3000) WHERE id=$1""", cid_a)
seen = set()
got_targeted = False
for _ in range(12):
r = await cl.post("/world/travel", headers=ha, json={"to_code": "av_topraklari"})
if r.status_code != 200:
break
ev = r.json().get("event")
if ev:
seen.add(ev["code"])
row = await conn.fetchval("SELECT location_codes FROM events WHERE code=$1", ev["code"])
if row and "av_topraklari" in row:
got_targeted = True
rr = await cl.post("/world/event/resolve", headers=ha, json={"choice": 0})
if rr.status_code != 200:
check(False, f"resolve başarısız: {rr.text[:100]}")
break
# geri dön
r = await cl.post("/world/travel", headers=ha, json={"to_code": "cayirlar"})
if r.status_code == 200 and r.json().get("event"):
await cl.post("/world/event/resolve", headers=ha, json={"choice": 0})
if got_targeted:
break
check(got_targeted, f"av_topraklari'na özel olay çıktı (görülen: {seen})")
await conn.execute("UPDATE roads SET event_chance=0.3")
print("== Gold olay ödülü (korsan) ==")
# korsan_yagmasi'ni zorla pending yap, choice 0 çöz — gold alanı dönmeli
ev = await conn.fetchrow("SELECT code, name, text, choices FROM events WHERE code='korsan_yagmasi'")
choices_client = [{"text": c["text"]} for c in json.loads(ev["choices"])]
payload = {"code": ev["code"], "name": ev["name"], "text": ev["text"], "choices": choices_client}
await conn.execute("UPDATE characters SET pending_event=$1::jsonb WHERE id=$2", json.dumps(payload), cid_a)
r = await cl.post("/world/event/resolve", headers=ha, json={"choice": 0})
d = r.json()
check(r.status_code == 200 and "gold" in d, f"resolve gold alanı var (delta={d.get('gold_delta')}, check={d.get('skill_check', {}).get('success')})")
print("== Yerel chat ==")
# A pazar'da, B pazar'da → local mesaj B'ye ulaşmalı; B koy'a gidince ulaşmamalı
await conn.execute("""
UPDATE characters SET location_id=(SELECT id FROM locations WHERE code='pazar'),
pending_event=NULL WHERE id = ANY($1::bigint[])""", [cid_a, cid_b])
async with websockets.connect(f"{WS}?token={tok_a}") as wa, \
websockets.connect(f"{WS}?token={tok_b}") as wb:
# açılış mesajlarını tüket (online_list + chat_history)
for w in (wa, wb):
for _ in range(2):
await asyncio.wait_for(w.recv(), 5)
await wa.send(json.dumps({"type": "chat", "channel": "local", "text": "yerel selam"}))
got = None
try:
while True:
m = json.loads(await asyncio.wait_for(wb.recv(), 5))
if m.get("type") == "chat":
got = m
break
except asyncio.TimeoutError:
pass
check(got is not None and got["channel"] == "local" and got["text"] == "yerel selam",
f"aynı lokasyonda yerel chat ulaştı ({got})")
# yerel history isteği
await wb.send(json.dumps({"type": "get_local_history"}))
hist = None
try:
while True:
m = json.loads(await asyncio.wait_for(wb.recv(), 5))
if m.get("type") == "chat_history" and m.get("channel") == "local":
hist = m
break
except asyncio.TimeoutError:
pass
check(hist is not None and any(x["text"] == "yerel selam" for x in hist["messages"]),
"yerel chat history döndü")
# B başka lokasyona gitsin → A'nın yerel mesajı B'ye GİTMEMELİ
r = await cl.post("/world/travel", headers=hb, json={"to_code": "koy"})
if r.status_code == 200 and r.json().get("event"):
await cl.post("/world/event/resolve", headers=hb, json={"choice": 0})
await asyncio.sleep(0.5)
# B'nin kuyruğunu boşalt (player_moved vs.)
try:
while True:
await asyncio.wait_for(wb.recv(), 0.5)
except asyncio.TimeoutError:
pass
await asyncio.sleep(1.1) # flood koruması
await wa.send(json.dumps({"type": "chat", "channel": "local", "text": "bunu gorme"}))
leak = None
try:
while True:
m = json.loads(await asyncio.wait_for(wb.recv(), 3))
if m.get("type") == "chat" and m.get("channel") == "local":
leak = m
break
except asyncio.TimeoutError:
pass
check(leak is None, "farklı lokasyona yerel chat sızmadı")
# global hâlâ çalışıyor
await asyncio.sleep(1.1)
await wa.send(json.dumps({"type": "chat", "text": "global selam"}))
gg = None
try:
while True:
m = json.loads(await asyncio.wait_for(wb.recv(), 5))
if m.get("type") == "chat" and m.get("channel") == "global":
gg = m
break
except asyncio.TimeoutError:
pass
check(gg is not None and gg["text"] == "global selam", "global chat farklı lokasyona ulaştı")
await conn.close()
print(f"== SONUÇ: {'BAŞARILI' if fails == 0 else 'BAŞARISIZ'} ({fails} hata) ==")
sys.exit(1 if fails else 0)
asyncio.run(main())