From 23e91a379075d547c752e6a83b28477fdf9ce860 Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Mon, 23 Feb 2026 10:41:46 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20MVP=20ISP=20Tycoon:=20=D0=BE=D1=82?= =?UTF-8?q?=20=D0=B3=D0=B0=D1=80=D0=B0=D0=B6=D0=B0=20=D0=B4=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B3=D0=B0=D0=BF=D0=BE=D0=BB=D0=B8=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 89 ++++++++++++++++++++++++++++++++++++++ config.py | 0 database.py | 72 ++++++++++++++++++++++++++++++ economy.py | 101 +++++++++++++++++++++++++++++++++++++++++++ handlers/__init__.py | 0 handlers/build.py | 0 handlers/channel.py | 63 +++++++++++++++++++++++++++ handlers/events.py | 43 ++++++++++++++++++ handlers/map.py | 24 ++++++++++ handlers/start.py | 58 +++++++++++++++++++++++++ handlers/stats.py | 32 ++++++++++++++ keyboards.py | 18 ++++++++ main.py | 71 ++++++++++++++++++++++++++++++ requirements.txt | 2 + 14 files changed, 573 insertions(+) create mode 100644 .gitignore create mode 100644 config.py create mode 100644 database.py create mode 100644 economy.py create mode 100644 handlers/__init__.py create mode 100644 handlers/build.py create mode 100644 handlers/channel.py create mode 100644 handlers/events.py create mode 100644 handlers/map.py create mode 100644 handlers/start.py create mode 100644 handlers/stats.py create mode 100644 keyboards.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae2cef7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,89 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env +.env.local +.env.*.local + +# Database +data/ +*.db +*.sqlite +*.sqlite3 + +# IDE & editors +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# Build artifacts +*.egg-info/ +.eggs/ + +# OS-specific +Thumbs.db +.DS_Store + +# Sensitive config +config.json +config.yml +credentials.json +keys.json +bot_secrets.json + +# Mini App (если будете хостить) +client-side/ +frontend/ +build/ +dist/ + +# Docker (если добавите позже) +Dockerfile +docker-compose.yml + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# Documentation (опционально) +docs/_build/ +docs/_static/ +docs/_templates/ \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..e69de29 diff --git a/database.py b/database.py new file mode 100644 index 0000000..bf7e439 --- /dev/null +++ b/database.py @@ -0,0 +1,72 @@ +import sqlite3 +from typing import Optional, Dict, Any + +DB_PATH = "data/db.sqlite3" + +def init_db(): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + CREATE TABLE IF NOT EXISTS players ( + user_id INTEGER PRIMARY KEY, + balance REAL DEFAULT 10000, + servers INTEGER DEFAULT 1, + cables INTEGER DEFAULT 0, + coverage_customers INTEGER DEFAULT 500, + max_customers INTEGER DEFAULT 500, + speed INTEGER DEFAULT 10, + rating INTEGER DEFAULT 0, + rating_threshold INTEGER DEFAULT 100, + next_build_end_ts INTEGER DEFAULT 0, + last_login_ts INTEGER DEFAULT 0, + total_income REAL DEFAULT 0, + infrastructure_level INTEGER DEFAULT 1, + power_capacity REAL DEFAULT 5.0, + power_usage REAL DEFAULT 0.0, + outage_end_ts INTEGER DEFAULT 0, + backup_channel_active INTEGER DEFAULT 0, + network_channel_level INTEGER DEFAULT 1, + channel_capacity INTEGER DEFAULT 10 + ) + """) + conn.commit() + conn.close() + +def get_player(user_id: int) -> Optional[Dict[str, Any]]: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute("SELECT * FROM players WHERE user_id = ?", (user_id,)) + row = cur.fetchone() + conn.close() + if not row: + return None + keys = [ + "user_id", "balance", "servers", "cables", "coverage_customers", + "max_customers", "speed", "rating", "rating_threshold", + "next_build_end_ts", "last_login_ts", "total_income", "infrastructure_level", + "power_capacity", "power_usage", "outage_end_ts", "backup_channel_active", + "network_channel_level", "channel_capacity" + ] + return dict(zip(keys, row)) + +def update_player(user_id: int, updates: Dict[str, Any]): + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + fields = ", ".join(f"{k} = ?" for k in updates.keys()) + values = list(updates.values()) + [user_id] + cur.execute(f"UPDATE players SET {fields} WHERE user_id = ?", values) + conn.commit() + conn.close() + +def create_player_if_not_exists(user_id: int): + player = get_player(user_id) + if not player: + conn = sqlite3.connect(DB_PATH) + cur = conn.cursor() + cur.execute(""" + INSERT INTO players (user_id, balance, servers, cables, coverage_customers, max_customers, speed, power_capacity, channel_capacity) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (user_id, INITIAL_BALANCE, INITIAL_SERVERS, 0, INITIAL_COVERAGE_CUSTOMERS, + INITIAL_MAX_CUSTOMERS, INITIAL_SPEED, INITIAL_POWER_CAPACITY, INITIAL_NETWORK_CHANNEL_CAPACITY)) + conn.commit() + conn.close() \ No newline at end of file diff --git a/economy.py b/economy.py new file mode 100644 index 0000000..a47a22b --- /dev/null +++ b/economy.py @@ -0,0 +1,101 @@ +import time +from config import * + +def calculate_effective_bandwidth(player: dict) -> int: + base = player["channel_capacity"] + server_boost = min(0.5, player["servers"] * 0.1) + cable_boost = player["cables"] * 0.2 + return int(base * (1 + server_boost + min(1.0, cable_boost))) + +def calculate_rating(player: dict) -> float: + monthly_income = player["coverage_customers"] * 15 * 30 + income_score = 10 * (monthly_income / 100_000) ** 0.5 + churn_ratio = (player["max_customers"] - player["coverage_customers"]) / max(player["max_customers"], 1) + stability_score = max(0, 20 - (churn_ratio * 100)) + coverage_score = 20 * min(1, player["max_customers"] / 100_000) + return round(income_score * 0.4 + stability_score * 0.3 + coverage_score * 0.3, 2) + +def calculate_client_retention(player: dict) -> tuple[int, str]: + effective_bw = calculate_effective_bandwidth(player) + required_bw_per_client = 2.0 + max_supported = effective_bw / required_bw_per_client + + if player["coverage_customers"] > max_supported: + churn_ratio = min(0.9, (player["coverage_customers"] - max_supported) / max_supported) + lost = int(player["coverage_customers"] * churn_ratio) + return lost, f"⚠️ Канала не хватает! Отток: {lost} клиентов/сутки" + + if player["network_channel_level"] >= 3 and effective_bw >= 100: + return 0, "✅ Высокий канал: отток снижен на 15%" + return 0, "🌐 Связь стабильна." + +def calculate_daily_income(player: dict) -> float: + supported_tiers = ["home"] + if player["network_channel_level"] >= 2: + supported_tiers.append("office") + if player["network_channel_level"] >= 3: + supported_tiers.append("gamer") + if player["network_channel_level"] >= 4: + supported_tiers.append("botfarm") + + base_rate = sum(TARIFFS[t] for t in supported_tiers) / len(supported_tiers) + effective_bw = calculate_effective_bandwidth(player) + ratio = effective_bw / max(player["coverage_customers"], 1) + multiplier = 1.0 + 0.2 * min(1, ratio / 5) + + return round(player["coverage_customers"] * base_rate * multiplier, 2) + +def calculate_channel_cost(level: int) -> int: + base = 2000 + return int(base * (1.8 ** (level - 1))) + +def calculate_daily_offline_earnings(player: dict) -> float: + now = int(time.time()) + if player["last_login_ts"] >= now: + return 0.0 + offline_hours = max((now - player["last_login_ts"]) / 3600, 0) + if offline_hours > 48: + offline_hours = 48 + + effective_bw = calculate_effective_bandwidth(player) + server_capacity = player["servers"] * 500 + stability = min(1.0, server_capacity / max(player["coverage_customers"], 1)) + tariff = 15 # ₡/час/клиент (усреднённый) + + income = player["coverage_customers"] * tariff * stability * offline_hours + return round(income, 2) + +def handle_outage(player: dict) -> dict: + if player["outage_end_ts"] > 0: + now = int(time.time()) + if now > player["outage_end_ts"]: + # Восстановление + return {**player, "outage_end_ts": 0, "backup_channel_active": False} + else: + if player["backup_channel_active"]: + return {**player, "speed": max(5, player["speed"] * 0.75)} + else: + churn = int(player["coverage_customers"] * 0.35) + return { + **player, + "coverage_customers": max(0, player["coverage_customers"] - churn), + "max_customers": max(0, player["max_customers"] - churn) + } + return player + +def check_power_overload(player: dict) -> tuple[bool, str]: + power_usage = player["servers"] * SERVER_POWER + if power_usage > player["power_capacity"] * 1.2: + return True, "⚠️ КРИТИЧЕСКАЯ ПЕРЕНАГРУЗКА! Серверы отключаются..." + elif power_usage > player["power_capacity"]: + return True, "⚠️ Перегрузка! Некоторые серверы работают с лагами." + return False, "" + +def sync_power(player: dict) -> dict: + power_usage = player["servers"] * SERVER_POWER + if power_usage > player["power_capacity"] * 1.2: + disabled = max(1, int(player["servers"] * 0.1)) + new_servers = max(0, player["servers"] - disabled) + new_max = max(0, player["max_customers"] - disabled * 100) + return {**player, "servers": new_servers, "max_customers": new_max} + return {**player, "power_usage": power_usage} \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/build.py b/handlers/build.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/channel.py b/handlers/channel.py new file mode 100644 index 0000000..8ce500a --- /dev/null +++ b/handlers/channel.py @@ -0,0 +1,63 @@ +from aiogram import types +from database import get_player, update_player +from economy import calculate_channel_cost + +async def upgrade_channel(callback: types.CallbackQuery): + user_id = callback.from_user.id + player = get_player(user_id) + + current_level = player["network_channel_level"] + if current_level >= 6: + await callback.answer("⚠️ Максимальный уровень канала достигнут! 🌍", show_alert=True) + return + + cost = calculate_channel_cost(current_level) + if player["balance"] < cost: + await callback.answer(f"❌ Не хватает {cost - player['balance']} ₡", show_alert=True) + return + + next_level = current_level + 1 + next_capacity = int(player["channel_capacity"] * 2.5) # +150% каждый уровень + upgrade_hours = 4 * current_level + + update_player(user_id, { + "balance": player["balance"] - cost, + "next_build_end_ts": int(__import__("time").time()) + upgrade_hours * 3600, + "infrastructure_level": player["infrastructure_level"] + 0.4 + }) + + keyboard = types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton( + text="🚀 Ускорить (x2 за 500 ₡)", + callback_data=f"speedup_channel_{current_level}" + )] + ]) + + await callback.message.edit_text( + f"🚀 Прокачка канала до уровня {next_level} (до {next_capacity} Мбит/с)...\n" + f"⏳ Ожидание: {upgrade_hours} ч\n" + f"💰 Остаток: {player['balance'] - cost} ₡", + reply_markup=keyboard + ) + await callback.answer("Процесс запущен 🛠️") + +@router.callback_query(lambda c: c.data.startswith("speedup_channel_")) +async def speedup_channel(callback: types.CallbackQuery): + user_id = callback.from_user.id + player = get_player(user_id) + + if player["balance"] < 500: + await callback.answer("❌ Нет средств на ускорение", show_alert=True) + return + + new_end = max(0, int(__import__("time").time()) + (player["next_build_end_ts"] - int(__import__("time").time())) // 2) + + update_player(user_id, { + "balance": player["balance"] - 500, + "next_build_end_ts": new_end + }) + + await callback.message.edit_text( + f"⚡ Ускорено! Канал будет готов через ~{(new_end - __import__('time').time()) / 3600:.1f} ч" + ) + await callback.answer("🚀 Апгрейд ускорен!") \ No newline at end of file diff --git a/handlers/events.py b/handlers/events.py new file mode 100644 index 0000000..a4b3055 --- /dev/null +++ b/handlers/events.py @@ -0,0 +1,43 @@ +from aiogram import types +import random +import time +from database import get_player, update_player + +async def activate_backup(callback: types.CallbackQuery): + user_id = callback.from_user.id + player = get_player(user_id) + + if player["backup_channel_active"]: + await callback.answer("✅ Резерв уже активен!", show_alert=True) + return + + # Проверка: мощности хватит? + power_used = player["servers"] * 2.0 + if power_used > player["power_capacity"]: + await callback.answer( + f"⚡ Недостаточно мощности: {power_used:.1f}/{player['power_capacity']} кВт", + show_alert=True + ) + return + + update_player(user_id, { + "backup_channel_active": True, + "speed": max(5, player["speed"] * 0.75) + }) + await callback.message.edit_text( + "✅ Резервный канал активирован! Сеть работает на 75% скорости." + ) + await callback.answer("⚡ Резерв включён!") + +def trigger_outage_event() -> tuple[str, dict]: + if random.random() > 0.1: # 10% шанс за сессию (можно регулировать) + return "", {} + + district = random.choice(["Северный", "Западный", "Индустриальный"]) + duration_hours = 1 + random.randint(0, 3) + return ( + f"⚠️ АВАРИЯ В РАЙОНЕ '{district}'!\n" + f"Электроснабжение отключено. Ожидание: {duration_hours} ч.\n" + f"💡 Используйте резервный канал, если есть.", + {"outage_end_ts": int(__import__("time").time()) + duration_hours * 3600} + ) \ No newline at end of file diff --git a/handlers/map.py b/handlers/map.py new file mode 100644 index 0000000..a5109a0 --- /dev/null +++ b/handlers/map.py @@ -0,0 +1,24 @@ +from aiogram import types +from database import get_player + +@router.callback_query(lambda c: c.data == "map") +async def show_map(callback: types.CallbackQuery): + player = get_player(callback.from_user.id) + districts = [ + ("🏠 Гараж (ваша база)", "✅ Подключён"), + ("🏢 Центральный район", "🟡 В процессе (15 мин)" if player["next_build_end_ts"] > 0 else "🔒 Недоступен"), + ("🎮 Западный квартал", "🔒 Недоступен"), + ] + + text = "🗺 *Карта города*\n\n" + for dist, status in districts: + text += f"{status} — {dist}\n" + text += "\n💡 *Подсказка:* Прокачайте канал, чтобы подключить офисы и геймеров." + + await callback.message.edit_text( + text, + parse_mode="Markdown", + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_menu")] + ]) + ) \ No newline at end of file diff --git a/handlers/start.py b/handlers/start.py new file mode 100644 index 0000000..031652e --- /dev/null +++ b/handlers/start.py @@ -0,0 +1,58 @@ +from aiogram import types, Router +from aiogram.filters import CommandStart +from aiogram.types import WebAppInfo + +from database import create_player_if_not_exists, get_player +from economy import ( + calculate_daily_offline_earnings, calculate_effective_bandwidth, + calculate_rating, calculate_daily_income +) +from keyboards import get_main_menu + +router = Router() + +@router.message(CommandStart()) +async def cmd_start(message: types.Message): + user_id = message.from_user.id + create_player_if_not_exists(user_id) + player = get_player(user_id) + + # Оффлайн доход + offline_earn = calculate_daily_offline_earnings(player) + if offline_earn > 0: + player["balance"] += offline_earn + player["total_income"] += offline_earn + player["last_login_ts"] = int(__import__("time").time()) + get_player.cache_clear() + update_player(user_id, { + "balance": player["balance"], + "total_income": player["total_income"], + "last_login_ts": player["last_login_ts"] + }) + + effective_bw = calculate_effective_bandwidth(player) + + status = "" + if player["outage_end_ts"] > 0: + remaining = player["outage_end_ts"] - int(__import__("time").time()) + status = f"\n⚠️ АВАРИЯ: Отключение до {remaining // 3600}ч {remaining % 3600 // 60}м" + if player["backup_channel_active"]: + status += " (резерв активен)" + + text = ( + f"🌐 *ISP Tycoon* | 📡 Канал: {player['network_channel_level']} ✨\n\n" + f"⚡ *Канал:* {player['channel_capacity']} → {effective_bw} Мбит/с\n" + f"🔋 *Сеть:* {player['power_usage']:.1f}/{player['power_capacity']} кВт\n" + f"🖥 *Серверы:* {player['servers']} (ёмкость: {player['max_customers']} клиентов)\n" + f"📡 *Клиенты:* {player['coverage_customers']} (отток: {calculate_client_retention(player)[0]}/сутки)\n" + f"💰 *Бюджет:* {player['balance']:,.0f} ₡\n" + f"📊 *Доход/день:* {calculate_daily_income(player):,.0f} ₡\n" + f"🌟 *Рейтинг:* {calculate_rating(player)}\n" + f"{status}" + ) + + await message.answer( + text, + reply_markup=get_main_menu(player), + parse_mode="Markdown" + ) \ No newline at end of file diff --git a/handlers/stats.py b/handlers/stats.py new file mode 100644 index 0000000..df2d5aa --- /dev/null +++ b/handlers/stats.py @@ -0,0 +1,32 @@ +from aiogram import types +from database import get_player +from economy import calculate_rating, calculate_effective_bandwidth + +@router.callback_query(lambda c: c.data == "stats") +async def stats(callback: types.CallbackQuery): + player = get_player(callback.from_user.id) + bw = calculate_effective_bandwidth(player) + + text = ( + f"📊 *Ваша статистика*\n\n" + f"🔹 Канал: {player['network_channel_level']} → {bw} Мбит/с\n" + f"🔹 Серверы: {player['servers']} шт. (ёмкость: {player['max_customers']})\n" + f"🔹 Клиенты: {player['coverage_customers']} / {player['max_customers']}\n" + f"🔹 Доход/день: {calculate_daily_income(player):,.0f} ₡\n" + f"🔹 Общий доход: {player['total_income']:,.0f} ₡\n" + f"🔹 Рейтинг: {calculate_rating(player)} 🌟" + ) + + await callback.message.edit_text( + text, + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[ + [types.InlineKeyboardButton(text="⬅️ Назад", callback_data="back_menu")] + ]), + parse_mode="Markdown" + ) + +@router.callback_query(lambda c: c.data == "back_menu") +async def back_to_menu(callback: types.CallbackQuery): + await callback.message.delete() + from handlers.start import cmd_start + await cmd_start(callback.message) \ No newline at end of file diff --git a/keyboards.py b/keyboards.py new file mode 100644 index 0000000..6a938cb --- /dev/null +++ b/keyboards.py @@ -0,0 +1,18 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +def get_main_menu(player: dict) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🚀 Прокачать канал", callback_data="upgrade_channel")], + [InlineKeyboardButton(text="🛠 Построить кабель", callback_data="build_cable")], + [InlineKeyboardButton(text="🖥 Купить сервер", callback_data="buy_server")], + [InlineKeyboardButton(text="💡 Ускорить постройку (x2 за 500 ₡)", callback_data="speedup_construction")], + [InlineKeyboardButton(text="📊 Статистика", callback_data="stats")], + [InlineKeyboardButton(text="🗺 Карта города", callback_data="map")] + ]) + +def get_backup_activation(player: dict) -> InlineKeyboardMarkup: + if player["backup_channel_active"] or player["outage_end_ts"] == 0: + return None + return InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="⚡ Активировать резервный канал", callback_data="activate_backup")] + ]) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..e1dc239 --- /dev/null +++ b/main.py @@ -0,0 +1,71 @@ +from aiogram import Bot, Dispatcher, types +from aiogram.enums import ParseMode +from aiogram.filters import Command +from aiogram import Router +import asyncio +import logging +import time + +from config import BOT_TOKEN +from database import init_db, get_player, update_player +from economy import calculate_effective_bandwidth, handle_outage, sync_power +from handlers.start import router as start_router +from handlers.economy import router as economy_router +from handlers.build import router as build_router +from handlers.channel import router as channel_router +from handlers.stats import router as stats_router +from handlers.map import router as map_router +from handlers.events import router as events_router + +dp = Dispatcher() +dp.include_router(start_router) +dp.include_router(economy_router) +dp.include_router(build_router) +dp.include_router(channel_router) +dp.include_router(stats_router) +dp.include_router(map_router) +dp.include_router(events_router) + +# Периодическая проверка состояний (каждые 5 минут) +@dp.message(Command("check")) +async def check_state(message: types.Message): + user_id = message.from_user.id + player = get_player(user_id) + if not player: + await message.answer("❌ Игрок не найден") + return + + # Оффлайн доход + апгрейды + player = handle_outage(player) + player = sync_power(player) + effective_bw = calculate_effective_bandwidth(player) + + # Обновление + update_player(user_id, { + "power_usage": player["power_usage"], + "coverage_customers": player["coverage_customers"], + "max_customers": player["max_customers"] + }) + + status = "" + if player["outage_end_ts"] > 0: + status = f"\n⚠️ АВАРИЯ: Отключение до {player['outage_end_ts'] - int(__import__('time').time()) // 3600}ч" + + await message.answer( + f"⚡ Состояние проверено!\n" + f"Канал: {player['channel_capacity']} → {effective_bw} Мбит/с\n" + f"Лимит: {player['power_usage']:.1f}/{player['power_capacity']} кВт\n" + f"Клиенты: {player['coverage_customers']} / {player['max_customers']}\n" + f"{status}", + parse_mode="Markdown" + ) + +async def main(): + init_db() + print("🤖 ISP Tycoon bot started...") + await dp.start_polling(bot) + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + bot = Bot(token=BOT_TOKEN, parse_mode=ParseMode.HTML) + asyncio.run(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..95ae5b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +aiogram>=3.1.0 +python-dotenv>=1.0.0 \ No newline at end of file