İlk commit: Stepstead backend (FastAPI)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
ea049b8ccd
45 changed files with 6751 additions and 0 deletions
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Python
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
|
||||
# Gizli
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Loglar
|
||||
logs/
|
||||
*.log
|
||||
16
cache.py
Normal file
16
cache.py
Normal 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
35
config.py
Normal 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
120
db/export_crafts_json.py
Normal 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ıkçı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ıkçı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
109
db/export_items_json.py
Normal 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ıkçı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
196
db/init_schema.sql
Normal 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;
|
||||
33
db/migrate_2026_07_04_market_daily.sql
Normal file
33
db/migrate_2026_07_04_market_daily.sql
Normal 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)
|
||||
);
|
||||
25
db/migrate_2026_07_07_roles.sql
Normal file
25
db/migrate_2026_07_07_roles.sql
Normal 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;
|
||||
17
db/migrate_2026_07_08_equipment.sql
Normal file
17
db/migrate_2026_07_08_equipment.sql
Normal 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;
|
||||
24
db/migrate_2026_07_09_city_vendors.sql
Normal file
24
db/migrate_2026_07_09_city_vendors.sql
Normal 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ıkçı 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);
|
||||
3
db/migrate_2026_07_09_road_waypoints.sql
Normal file
3
db/migrate_2026_07_09_road_waypoints.sql
Normal 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
244
db/seed_craftables.py
Normal 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ıkçı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ıkçı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
166
db/seed_enemies_forest.py
Normal 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. "
|
||||
"Ağı 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="Işı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="Işı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
134
db/seed_equipment.py
Normal 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
152
db/seed_events_combat.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Lokasyona özel olaylar — combat/hunting ağı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ıkçı ağı 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": "Ağı 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
617
db/seed_quests.py
Normal 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 açı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",
|
||||
"Işı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ıkçı Seline", "Balıkçı Reisi", "Riftwood Crossing",
|
||||
"Kıyı balıkçı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ıkçı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, "Işı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ıkçı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ıkçı 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ıkçı 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
249
db/seed_world_graph.py
Normal 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ıkçı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ıkçı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
76
deps.py
Normal 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
65
email_utils.py
Normal 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
30
game_logic.py
Normal 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
72
main.py
Normal 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
9
models/__init__.py
Normal 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
58
models/auth.py
Normal 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
51
models/character.py
Normal 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
22
models/database.py
Normal 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
14
requirements.txt
Normal 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
0
routers/__init__.py
Normal file
267
routers/admin.py
Normal file
267
routers/admin.py
Normal 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
180
routers/auth.py
Normal 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
90
routers/character.py
Normal 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
296
routers/chars.py
Normal 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
251
routers/craft.py
Normal 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
160
routers/harvest.py
Normal 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
59
routers/leaderboard.py
Normal 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
203
routers/lore.py
Normal 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
266
routers/map_tags.py
Normal 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 açı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
505
routers/market.py
Normal 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 → açı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} açı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, "Açı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
271
routers/npcs.py
Normal 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
258
routers/quests.py
Normal 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
536
routers/travel.py
Normal 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
194
routers/world.py
Normal 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
280
routers/ws.py
Normal 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
40
security.py
Normal 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
117
tests/camp_e2e.py
Normal 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
227
tests/econ_e2e.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue