diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5b1b58 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# 1. Удалите старую базу данных +rm app.db + +# 2. Создайте новую базу данных +flask init-db + +# 3. Создайте тестовых пользователей +flask create-test-users + +# 4. Создайте демо-комнаты +flask create-demo-rooms + +# 5. Валидация баланса +flask validate-balance + +# 6. Запустите сервер +python app.py \ No newline at end of file diff --git a/app.py b/app.py index 0655363..e431116 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,14 @@ import random import logging from werkzeug.security import generate_password_hash, check_password_hash +import sys +sys.path.append('.') + +from game_balance.config import BalanceConfig +from game_balance.validator import BalanceValidator +from game_balance.assets_config import AssetsConfig +from game_balance.players_config import PlayersConfig + # Настройка логирования logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) @@ -40,6 +48,8 @@ class GameRoom(db.Model): start_capital = db.Column(db.Integer, default=100000) settings = db.Column(db.Text, default='{}') # JSON с настройками + balance_mode = db.Column(db.String(20), default='standard') + # Связи - убираем lazy='dynamic' для players players = db.relationship('GamePlayer', backref='room', lazy='select') # Изменено game_state = db.relationship('GameState', backref='room', uselist=False) @@ -106,6 +116,42 @@ class GameState(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow) +class AssetInventory(db.Model): + """Остатки активов на рынке""" + __tablename__ = 'asset_inventories' + + id = db.Column(db.Integer, primary_key=True) + room_id = db.Column(db.Integer, db.ForeignKey('game_rooms.id')) + asset_id = db.Column(db.String(50), nullable=False) + total_quantity = db.Column(db.Float, nullable=False) + remaining_quantity = db.Column(db.Float, nullable=False) + current_price = db.Column(db.Float, nullable=False) + base_price = db.Column(db.Float, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + __table_args__ = ( + db.UniqueConstraint('room_id', 'asset_id', name='unique_room_asset'), + ) + + +class Transaction(db.Model): + """История транзакций""" + __tablename__ = 'transactions' + + id = db.Column(db.Integer, primary_key=True) + room_id = db.Column(db.Integer, db.ForeignKey('game_rooms.id')) + player_id = db.Column(db.Integer, db.ForeignKey('game_players.id')) + asset_id = db.Column(db.String(50), nullable=False) + transaction_type = db.Column(db.String(20), nullable=False) # buy, sell + quantity = db.Column(db.Float, nullable=False) + price = db.Column(db.Float, nullable=False) + total_amount = db.Column(db.Float, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Связи + player = db.relationship('GamePlayer', backref='transactions') + + # --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ --- def generate_room_code(): @@ -400,6 +446,7 @@ def create_room(): room_name = request.form.get('name', 'Новая комната').strip() total_months = int(request.form.get('total_months', 12)) start_capital = int(request.form.get('start_capital', 100000)) + balance_mode = request.form.get('balance_mode', 'standard') allow_loans = request.form.get('allow_loans') == 'on' allow_black_market = request.form.get('allow_black_market') == 'on' private_room = request.form.get('private_room') == 'on' @@ -408,14 +455,13 @@ def create_room(): if not room_name: return jsonify({'error': 'Введите название комнаты'}), 400 - # Генерируем уникальный код комнаты - import string - import random - - def generate_room_code(): - chars = string.ascii_uppercase + string.digits - return ''.join(random.choice(chars) for _ in range(6)) + # Загружаем баланс + balance = BalanceConfig.load_balance_mode(balance_mode) + players_config = balance.get_players_config() + assets_config = balance.get_assets_config() + economy_config = balance.get_economy_config() + # Генерируем код комнаты room_code = generate_room_code() # Создаем комнату @@ -425,6 +471,7 @@ def create_room(): creator_id=current_user.id, total_months=total_months, start_capital=start_capital, + balance_mode=balance_mode, settings=json.dumps({ 'allow_loans': allow_loans, 'allow_black_market': allow_black_market, @@ -435,41 +482,57 @@ def create_room(): db.session.add(room) db.session.commit() - # Добавляем создателя как игрока-администратора + # Добавляем создателя как игрока + abilities = list(players_config.get('ABILITIES', PlayersConfig.ABILITIES).keys()) player = GamePlayer( user_id=current_user.id, room_id=room.id, is_admin=True, is_ready=True, capital=start_capital, - ability=random.choice([ - 'crisis_investor', 'lobbyist', 'predictor', - 'golden_pillow', 'shadow_accountant', 'credit_magnate' - ]) + ability=random.choice(abilities) if abilities else None ) db.session.add(player) db.session.commit() - # Создаем начальное состояние игры + # Создаем состояние игры + market_data = { + 'initialized': True, + 'assets': assets_config, + 'balance_mode': balance_mode, + 'last_update': datetime.utcnow().isoformat(), + 'correlations': economy_config.get('ASSET_CORRELATIONS', {}) + } + game_state = GameState( room_id=room.id, - market_data=json.dumps({ - 'initialized': True, - 'assets': { - 'stock_gazprom': {'price': 1000, 'volatility': 0.2}, - 'real_estate': {'price': 3000000, 'volatility': 0.1}, - 'bitcoin': {'price': 1850000, 'volatility': 0.3}, - 'oil': {'price': 5000, 'volatility': 0.25} - }, - 'last_update': datetime.utcnow().isoformat() - }) + phase='waiting', # Не 'action', а 'waiting' до начала игры + phase_end=None, # Без времени до начала игры + market_data=json.dumps(market_data) ) db.session.add(game_state) + + # ========== ДОБАВЛЯЕМ ИНВЕНТАРЬ ЗДЕСЬ ========== + # Создаем инвентарь для ограниченных активов + from game_balance.assets_config import AssetsConfig # импорт в начале файла + + for asset_id, asset_data in assets_config.items(): + if asset_data.get('total_quantity') is not None: + inventory = AssetInventory( + room_id=room.id, + asset_id=asset_id, + total_quantity=asset_data['total_quantity'], + remaining_quantity=asset_data['total_quantity'], + current_price=asset_data['base_price'], + base_price=asset_data['base_price'] + ) + db.session.add(inventory) + # ================================================ + db.session.commit() - # Отправляем успешный ответ return jsonify({ 'success': True, 'room_code': room.code, @@ -580,24 +643,46 @@ def start_room_game(room_code): # Меняем статус комнаты room.status = 'playing' - # Создаем начальное состояние игры если его нет + # Получаем длительность фазы из баланса + try: + balance = BalanceConfig.load_balance_mode(room.balance_mode) + game_config = balance.get_game_config() + phase_duration = game_config.get('PHASE_DURATIONS', {}).get('action', 120) + except: + # Если баланс не загружается, используем значение по умолчанию + phase_duration = 120 + + # Создаем или обновляем состояние игры if not room.game_state: + # Загружаем конфигурацию активов из баланса + try: + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + economy_config = balance.get_economy_config() + except: + from game_balance.assets_config import AssetsConfig + assets_config = AssetsConfig.ASSETS + economy_config = {'ASSET_CORRELATIONS': {}} + + # Создаем новое состояние game_state = GameState( room_id=room.id, phase='action', - phase_end=datetime.utcnow() + timedelta(seconds=120), # 2 минуты на действия + phase_end=datetime.utcnow() + timedelta(seconds=phase_duration), market_data=json.dumps({ 'initialized': True, - 'assets': { - 'stock_gazprom': {'price': 1000, 'volatility': 0.2}, - 'real_estate': {'price': 3000000, 'volatility': 0.1}, - 'bitcoin': {'price': 1850000, 'volatility': 0.3}, - 'oil': {'price': 5000, 'volatility': 0.25} - }, - 'last_update': datetime.utcnow().isoformat() + 'assets': assets_config, + 'balance_mode': room.balance_mode, + 'last_update': datetime.utcnow().isoformat(), + 'correlations': economy_config.get('ASSET_CORRELATIONS', {}) }) ) db.session.add(game_state) + else: + # Обновляем существующее состояние + room.game_state.phase = 'action' + room.game_state.phase_end = datetime.utcnow() + timedelta(seconds=phase_duration) + room.game_state.updated_at = datetime.utcnow() db.session.commit() @@ -606,7 +691,7 @@ def start_room_game(room_code): 'room': room.code, 'message': 'Игра началась!', 'phase': 'action', - 'phase_end': room.game_state.phase_end.isoformat() if room.game_state else None + 'phase_end': room.game_state.phase_end.isoformat() if room.game_state and room.game_state.phase_end else None }, room=room.code) return jsonify({'success': True}) @@ -981,15 +1066,131 @@ def exit_lobby(room_code): @app.route('/api/game//assets') @login_required -def get_assets(room_code): +def get_game_assets(room_code): + """Получение текущих цен и доступности активов""" room = GameRoom.query.filter_by(code=room_code).first_or_404() + + # Проверяем, что игрок в комнате + player = GamePlayer.query.filter_by( + user_id=current_user.id, + room_id=room.id + ).first_or_404() + + # Загружаем баланс + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + + # Получаем текущие цены из game_state game_state = room.game_state + market_data = json.loads(game_state.market_data) if game_state else {} + market_assets = market_data.get('assets', {}) - if not game_state: - return jsonify({'error': 'Game state not found'}), 404 + # Получаем инвентарь для ограниченных активов + inventories = {} + inventory_list = AssetInventory.query.filter_by(room_id=room.id).all() + for inv in inventory_list: + inventories[inv.asset_id] = inv - market_data = json.loads(game_state.market_data) - return jsonify(market_data) + # Получаем активы игрока + player_assets = json.loads(player.assets) if player.assets else [] + player_assets_dict = {} + for pa in player_assets: + player_assets_dict[pa['id']] = pa['quantity'] + + # Формируем ответ + result = {} + for asset_id, asset_config in assets_config.items(): + asset_data = asset_config.copy() + + # Обновляем цену + if asset_id in market_assets: + asset_data['price'] = market_assets[asset_id].get('price', asset_config['base_price']) + else: + asset_data['price'] = asset_config['base_price'] + + # Добавляем информацию о доступности + if asset_config.get('total_quantity') is not None: + inv = inventories.get(asset_id) + if inv: + asset_data['available'] = inv.remaining_quantity + asset_data['total'] = inv.total_quantity + asset_data['current_price'] = inv.current_price + else: + # Если инвентаря нет, создаем его + new_inv = AssetInventory( + room_id=room.id, + asset_id=asset_id, + total_quantity=asset_config['total_quantity'], + remaining_quantity=asset_config['total_quantity'], + current_price=asset_data['price'], + base_price=asset_config['base_price'] + ) + db.session.add(new_inv) + db.session.commit() + asset_data['available'] = asset_config['total_quantity'] + asset_data['total'] = asset_config['total_quantity'] + else: + asset_data['available'] = None + asset_data['total'] = None + + # Добавляем информацию о владении игрока + asset_data['player_quantity'] = player_assets_dict.get(asset_id, 0) + asset_data['max_per_player'] = asset_config.get('max_per_player') + asset_data['min_purchase'] = asset_config.get('min_purchase', 1) + + result[asset_id] = asset_data + + # Определяем категории активов прямо здесь + categories = { + 'bonds': {'name': 'Облигации', 'icon': '🏦'}, + 'stocks': {'name': 'Акции', 'icon': '📈'}, + 'real_estate': {'name': 'Недвижимость', 'icon': '🏠'}, + 'crypto': {'name': 'Криптовалюта', 'icon': '💰'}, + 'commodities': {'name': 'Сырьевые товары', 'icon': '⛽'}, + 'business': {'name': 'Бизнес', 'icon': '🏢'}, + 'unique': {'name': 'Уникальные активы', 'icon': '🏆'} + } + + return jsonify({ + 'success': True, + 'assets': result, + 'categories': categories + }) + + +@app.route('/api/game//transactions') +@login_required +def get_transactions(room_code): + """Получение истории транзакций игрока""" + room = GameRoom.query.filter_by(code=room_code).first_or_404() + + player = GamePlayer.query.filter_by( + user_id=current_user.id, + room_id=room.id + ).first_or_404() + + # Получаем последние 50 транзакций + transactions = Transaction.query.filter_by( + room_id=room.id, + player_id=player.id + ).order_by(Transaction.created_at.desc()).limit(50).all() + + result = [] + for t in transactions: + result.append({ + 'id': t.id, + 'type': t.transaction_type, + 'asset_id': t.asset_id, + 'quantity': t.quantity, + 'price': t.price, + 'total': t.total_amount, + 'created_at': t.created_at.isoformat() + }) + + return jsonify({ + 'success': True, + 'transactions': result + }) @app.route('/api/game//action', methods=['POST']) @@ -1433,6 +1634,35 @@ def reset_db(): print('\n🎉 База данных успешно сброшена и заполнена тестовыми данными!') +@app.cli.command('validate-balance') +def validate_balance_command(): + """Проверка всех режимов баланса""" + from game_balance.validator import BalanceValidator + + modes = BalanceConfig.get_available_modes() + print(f"Проверка баланса для {len(modes)} режимов...") + print("-" * 50) + + all_valid = True + for mode_key, mode_name in modes.items(): + print(f"\nРежим: {mode_name}") + balance = BalanceConfig.load_balance_mode(mode_key) + errors = BalanceValidator.validate_balance_mode(balance) + + if errors: + print(f"❌ Найдено {len(errors)} ошибок:") + for error in errors: + print(f" - {error}") + all_valid = False + else: + print("✅ Баланс корректен") + + if all_valid: + print("\n✅ Все режимы баланса валидны!") + else: + print("\n❌ Обнаружены ошибки в балансе") + + # --- ОБРАБОТЧИКИ ОШИБОК --- from datetime import datetime @@ -1506,7 +1736,7 @@ def utility_processor(): # Логика игры @app.route('/game/') @login_required -def game(room_code): +def game_page(room_code): """Страница игры""" room = GameRoom.query.filter_by(code=room_code).first_or_404() @@ -1525,17 +1755,22 @@ def game(room_code): game_state = room.game_state if not game_state: # Создаем начальное состояние игры если его нет + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + economy_config = balance.get_economy_config() + game_config = balance.get_game_config() + phase_duration = game_config.get('PHASE_DURATIONS', {}).get('action', 120) + game_state = GameState( room_id=room.id, + phase='action', + phase_end=datetime.utcnow() + timedelta(seconds=phase_duration), market_data=json.dumps({ 'initialized': True, - 'assets': { - 'stock_gazprom': {'price': 1000, 'volatility': 0.2}, - 'real_estate': {'price': 3000000, 'volatility': 0.1}, - 'bitcoin': {'price': 1850000, 'volatility': 0.3}, - 'oil': {'price': 5000, 'volatility': 0.25} - }, - 'last_update': datetime.utcnow().isoformat() + 'assets': assets_config, + 'balance_mode': room.balance_mode, + 'last_update': datetime.utcnow().isoformat(), + 'correlations': economy_config.get('ASSET_CORRELATIONS', {}) }) ) db.session.add(game_state) @@ -1546,10 +1781,19 @@ def game(room_code): # Словарь названий активов для шаблона asset_names = { + 'gov_bonds': 'Гособлигации', 'stock_gazprom': 'Акции Газпрома', - 'real_estate': 'Недвижимость', + 'stock_sberbank': 'Акции Сбербанка', + 'stock_yandex': 'Акции Яндекса', + 'apartment_small': 'Небольшая квартира', + 'apartment_elite': 'Элитная квартира', 'bitcoin': 'Биткоин', - 'oil': 'Нефть' + 'oil': 'Нефть Brent', + 'natural_gas': 'Природный газ', + 'coffee_shop': 'Кофейня', + 'it_startup': 'IT-стартап', + 'shopping_mall': 'Торговый центр', + 'oil_field': 'Нефтяное месторождение' } return render_template('game.html', @@ -1563,7 +1807,7 @@ def game(room_code): # API эндпоинты для игры @app.route('/api/game//state') @login_required -def get_game_state(room_code): +def get_game_state_api(room_code): # Переименовано, чтобы не конфликтовать """Получение текущего состояния игры""" room = GameRoom.query.filter_by(code=room_code).first_or_404() @@ -1576,7 +1820,7 @@ def get_game_state(room_code): return jsonify({ 'success': True, - 'phase': game_state.phase if game_state else 'action', + 'phase': game_state.phase if game_state else 'waiting', 'phase_end': game_state.phase_end.isoformat() if game_state and game_state.phase_end else None, 'current_month': room.current_month, 'total_months': room.total_months, @@ -1585,36 +1829,13 @@ def get_game_state(room_code): }) -@app.route('/api/game//assets') -@login_required -def get_game_assets(room_code): - """Получение текущих цен активов""" - room = GameRoom.query.filter_by(code=room_code).first_or_404() - - # Проверяем, что игрок в комнате - GamePlayer.query.filter_by( - user_id=current_user.id, - room_id=room.id - ).first_or_404() - - game_state = room.game_state - if not game_state: - return jsonify({'success': False, 'error': 'Game state not found'}), 404 - - market_data = json.loads(game_state.market_data) - - return jsonify({ - 'success': True, - 'assets': market_data.get('assets', {}) - }) - - @app.route('/api/game//buy', methods=['POST']) @login_required def buy_asset(room_code): """Покупка актива""" room = GameRoom.query.filter_by(code=room_code).first_or_404() + # Проверяем, что игрок в комнате player = GamePlayer.query.filter_by( user_id=current_user.id, room_id=room.id @@ -1622,42 +1843,164 @@ def buy_asset(room_code): # Проверяем, что сейчас фаза действий if room.game_state and room.game_state.phase != 'action': - return jsonify({'success': False, 'error': 'Не фаза действий'}), 400 + return jsonify({'success': False, 'error': 'Покупка доступна только в фазу действий'}), 400 data = request.json asset_id = data.get('asset_id') - quantity = data.get('quantity', 1) - price = data.get('price') + quantity = float(data.get('quantity', 1)) - if not asset_id or not price: - return jsonify({'success': False, 'error': 'Не указан актив или цена'}), 400 + if not asset_id or quantity <= 0: + return jsonify({'success': False, 'error': 'Не указан актив или количество'}), 400 - total_cost = price * quantity + # Загружаем баланс комнаты + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + # Получаем конфигурацию актива + asset_config = assets_config.get(asset_id) + if not asset_config: + return jsonify({'success': False, 'error': 'Актив не найден'}), 404 + + # Проверяем минимальное количество для покупки + min_purchase = asset_config.get('min_purchase', 1) + if quantity < min_purchase: + return jsonify({'success': False, 'error': f'Минимальное количество для покупки: {min_purchase}'}), 400 + + # Получаем текущую цену из инвентаря или конфига + inventory = None + current_price = asset_config['base_price'] + + if asset_config.get('total_quantity') is not None: + # Ограниченный актив - берем из инвентаря + inventory = AssetInventory.query.filter_by( + room_id=room.id, + asset_id=asset_id + ).first() + + if not inventory: + return jsonify({'success': False, 'error': 'Инвентарь актива не найден'}), 404 + + # Проверяем остаток + if inventory.remaining_quantity < quantity: + return jsonify({'success': False, + 'error': f'Доступно только {inventory.remaining_quantity} {asset_config["name"]}'}), 400 + + current_price = inventory.current_price + else: + # Безлимитный актив - берем из game_state + game_state = room.game_state + market_data = json.loads(game_state.market_data) if game_state else {} + assets = market_data.get('assets', {}) + if asset_id in assets: + current_price = assets[asset_id].get('price', asset_config['base_price']) + + # Рассчитываем стоимость + total_cost = current_price * quantity + + # Проверяем достаточно ли средств if player.capital < total_cost: - return jsonify({'success': False, 'error': 'Недостаточно средств'}), 400 + return jsonify({'success': False, 'error': f'Недостаточно средств. Нужно: {format_currency(total_cost)}'}), 400 - # Получаем текущие активы игрока - assets = json.loads(player.assets) if player.assets else [] + # Проверяем лимиты на владение + if asset_config.get('max_per_player'): + # Считаем сколько уже есть у игрока + current_assets = json.loads(player.assets) if player.assets else [] + current_quantity = 0 + for asset in current_assets: + if asset['id'] == asset_id: + current_quantity += asset['quantity'] - # Добавляем новый актив - assets.append({ - 'id': asset_id, - 'quantity': quantity, - 'purchase_price': price, - 'timestamp': datetime.utcnow().isoformat() - }) + if current_quantity + quantity > asset_config['max_per_player']: + return jsonify({ + 'success': False, + 'error': f'Максимум {asset_config["max_per_player"]} {asset_config["name"]} на игрока' + }), 400 - # Обновляем капитал и активы игрока + # Проверяем месячные лимиты + players_config = balance.get_players_config() + purchase_limits = players_config.get('PURCHASE_LIMITS_BY_MONTH', {}) + month_limit = purchase_limits.get(room.current_month, 1.0) + + max_allowed = asset_config.get('max_per_player', float('inf')) + if max_allowed != float('inf'): + max_this_month = max_allowed * month_limit + if current_quantity + quantity > max_this_month: + return jsonify({ + 'success': False, + 'error': f'В этом месяце можно купить не более {max_this_month} {asset_config["name"]}' + }), 400 + + # ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ - ВЫПОЛНЯЕМ ПОКУПКУ + + # 1. Обновляем инвентарь (для ограниченных активов) + if inventory: + inventory.remaining_quantity -= quantity + inventory.updated_at = datetime.utcnow() + + # 2. Обновляем активы игрока + player_assets = json.loads(player.assets) if player.assets else [] + + # Ищем существующий актив + found = False + for asset in player_assets: + if asset['id'] == asset_id: + asset['quantity'] += quantity + asset['purchase_price'] = (asset['purchase_price'] * asset['quantity'] + current_price * quantity) / ( + asset['quantity'] + quantity) + found = True + break + + # Если не нашли, добавляем новый + if not found: + player_assets.append({ + 'id': asset_id, + 'quantity': quantity, + 'purchase_price': current_price, + 'purchase_date': datetime.utcnow().isoformat() + }) + + player.assets = json.dumps(player_assets) + + # 3. Списываем деньги player.capital -= total_cost - player.assets = json.dumps(assets) + + # 4. Сохраняем транзакцию + transaction = Transaction( + room_id=room.id, + player_id=player.id, + asset_id=asset_id, + transaction_type='buy', + quantity=quantity, + price=current_price, + total_amount=total_cost + ) + db.session.add(transaction) db.session.commit() + # 5. Отправляем WebSocket уведомление + socketio.emit('player_action', { + 'room': room.code, + 'player_id': player.user_id, + 'player_name': current_user.username, + 'action': 'buy', + 'asset_id': asset_id, + 'asset_name': asset_config['name'], + 'quantity': quantity, + 'price': current_price, + 'total': total_cost, + 'timestamp': datetime.utcnow().isoformat() + }, room=room.code) + return jsonify({ 'success': True, 'new_capital': player.capital, - 'message': f'Куплено {quantity} {asset_id}' + 'asset_name': asset_config['name'], + 'quantity': quantity, + 'price': current_price, + 'total': total_cost, + 'remaining': inventory.remaining_quantity if inventory else None, + 'message': f'Куплено {quantity} {asset_config["name"]} за {format_currency(total_cost)}' }) @@ -1667,6 +2010,7 @@ def sell_asset(room_code): """Продажа актива""" room = GameRoom.query.filter_by(code=room_code).first_or_404() + # Проверяем, что игрок в комнате player = GamePlayer.query.filter_by( user_id=current_user.id, room_id=room.id @@ -1674,55 +2018,132 @@ def sell_asset(room_code): # Проверяем, что сейчас фаза действий if room.game_state and room.game_state.phase != 'action': - return jsonify({'success': False, 'error': 'Не фаза действий'}), 400 + return jsonify({'success': False, 'error': 'Продажа доступна только в фазу действий'}), 400 data = request.json asset_id = data.get('asset_id') - quantity = data.get('quantity', 1) + quantity = float(data.get('quantity', 1)) - if not asset_id: - return jsonify({'success': False, 'error': 'Не указан актив'}), 400 + if not asset_id or quantity <= 0: + return jsonify({'success': False, 'error': 'Не указан актив или количество'}), 400 + + # Загружаем баланс комнаты + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + + # Получаем конфигурацию актива + asset_config = assets_config.get(asset_id) + if not asset_config: + return jsonify({'success': False, 'error': 'Актив не найден'}), 404 # Получаем текущие активы игрока - assets = json.loads(player.assets) if player.assets else [] + player_assets = json.loads(player.assets) if player.assets else [] # Ищем актив для продажи asset_to_sell = None - for asset in assets: - if asset['id'] == asset_id and asset['quantity'] >= quantity: + for asset in player_assets: + if asset['id'] == asset_id: asset_to_sell = asset break if not asset_to_sell: - return jsonify({'success': False, 'error': 'Недостаточно активов для продажи'}), 400 + return jsonify({'success': False, 'error': f'У вас нет {asset_config["name"]}'}), 400 - # Получаем текущую цену актива - game_state = room.game_state - if not game_state: - return jsonify({'success': False, 'error': 'Состояние игры не найдено'}), 404 + if asset_to_sell['quantity'] < quantity: + return jsonify({ + 'success': False, + 'error': f'У вас только {asset_to_sell["quantity"]} {asset_config["name"]}' + }), 400 - market_data = json.loads(game_state.market_data) - current_price = market_data.get('assets', {}).get(asset_id, {}).get('price', asset_to_sell['purchase_price']) + # Получаем текущую цену + current_price = None + + if asset_config.get('total_quantity') is not None: + # Ограниченный актив - берем из инвентаря + inventory = AssetInventory.query.filter_by( + room_id=room.id, + asset_id=asset_id + ).first() + + if inventory: + current_price = inventory.current_price + else: + # Безлимитный актив - берем из game_state + game_state = room.game_state + if game_state: + market_data = json.loads(game_state.market_data) + assets = market_data.get('assets', {}) + if asset_id in assets: + current_price = assets[asset_id].get('price', asset_config['base_price']) + + # Если цену не нашли, используем цену покупки + if current_price is None: + current_price = asset_to_sell['purchase_price'] # Рассчитываем выручку revenue = current_price * quantity - # Обновляем активы игрока + # Комиссия за продажу + economy_config = balance.get_economy_config() + transaction_fee = economy_config.get('TAX_SYSTEM', {}).get('transaction_fee', 0.01) + fee = revenue * transaction_fee + revenue_after_fee = revenue - fee + + # ВЫПОЛНЯЕМ ПРОДАЖУ + + # 1. Обновляем инвентарь (для ограниченных активов) + if asset_config.get('total_quantity') is not None and inventory: + inventory.remaining_quantity += quantity + inventory.updated_at = datetime.utcnow() + + # 2. Обновляем активы игрока asset_to_sell['quantity'] -= quantity if asset_to_sell['quantity'] <= 0: - assets.remove(asset_to_sell) + player_assets.remove(asset_to_sell) - # Обновляем капитал - player.capital += revenue - player.assets = json.dumps(assets) + player.assets = json.dumps(player_assets) + + # 3. Начисляем деньги + player.capital += revenue_after_fee + + # 4. Сохраняем транзакцию + transaction = Transaction( + room_id=room.id, + player_id=player.id, + asset_id=asset_id, + transaction_type='sell', + quantity=quantity, + price=current_price, + total_amount=revenue_after_fee + ) + db.session.add(transaction) db.session.commit() + # 5. Отправляем WebSocket уведомление + socketio.emit('player_action', { + 'room': room.code, + 'player_id': player.user_id, + 'player_name': current_user.username, + 'action': 'sell', + 'asset_id': asset_id, + 'asset_name': asset_config['name'], + 'quantity': quantity, + 'price': current_price, + 'total': revenue_after_fee, + 'fee': fee, + 'timestamp': datetime.utcnow().isoformat() + }, room=room.code) + return jsonify({ 'success': True, 'new_capital': player.capital, - 'revenue': revenue, - 'message': f'Продано {quantity} {asset_id}' + 'asset_name': asset_config['name'], + 'quantity': quantity, + 'price': current_price, + 'total': revenue_after_fee, + 'fee': fee, + 'message': f'Продано {quantity} {asset_config["name"]} за {format_currency(revenue_after_fee)}' }) @@ -1790,80 +2211,103 @@ def end_action_phase(room_code): def calculate_and_update_market(room_id): - """Расчет реакции рынка (в фоновом потоке)""" + """Расчет реакции рынка с использованием баланса""" import time + import random - # Ждем 5 секунд перед расчетом time.sleep(5) room = GameRoom.query.get(room_id) if not room or room.status != 'playing': return + # Загружаем баланс комнаты + balance = BalanceConfig.load_balance_mode(room.balance_mode) + assets_config = balance.get_assets_config() + economy_config = balance.get_economy_config() + game_state = room.game_state if not game_state or game_state.phase != 'market': return - # Получаем всех игроков + # Получаем игроков players = GamePlayer.query.filter_by(room_id=room_id).all() # Получаем текущие данные рынка market_data = json.loads(game_state.market_data) if game_state.market_data else {} assets = market_data.get('assets', {}) - # Базовые цены для расчета перегрева - base_prices = { - 'stock_gazprom': 1000, - 'real_estate': 3000000, - 'bitcoin': 1850000, - 'oil': 5000 - } + # Рассчитываем спрос по каждому активу + for asset_id, asset_data in assets.items(): + if asset_id not in assets_config: + continue - # Считаем спрос по каждому активу - for asset_id in assets.keys(): - demand = 0 + # Считаем общее количество активов у игроков + total_held = 0 for player in players: player_assets = json.loads(player.assets) if player.assets else [] - for player_asset in player_assets: - if player_asset.get('id') == asset_id: - demand += player_asset.get('quantity', 0) + for pa in player_assets: + if pa.get('id') == asset_id: + total_held += pa.get('quantity', 0) - # Формула влияния спроса - total_players = len(players) - if total_players > 0 and asset_id in assets: - volatility = assets[asset_id].get('volatility', 0.2) - price_change = volatility * (demand / (total_players * 10)) - new_price = assets[asset_id]['price'] * (1 + price_change) + # Коэффициент спроса + total_supply = asset_data.get('total_quantity') + if total_supply is not None: + demand_factor = total_held / total_supply if total_supply > 0 else 0 + else: + demand_factor = total_held / (len(players) * 100) if players else 0 # Для безлимитных - # Эффект перегрева - if base_prices.get(asset_id) and new_price > base_prices[asset_id] * (1 + 2 * volatility): - new_price *= random.uniform(0.7, 0.9) + demand_factor = min(demand_factor, 1.0) # Ограничиваем - assets[asset_id]['price'] = new_price + # Рассчитываем новую цену + new_price = AssetsConfig.calculate_price( + asset_data, + demand_factor, + room.current_month, + len(players) + ) + + asset_data['price'] = new_price + + # Применяем корреляции - ИСПРАВЛЕНО + correlations = economy_config.get('ASSET_CORRELATIONS', {}) + for corr_key, corr_value in correlations.items(): + # Разбираем ключ вида "asset1:asset2" + if ':' in corr_key: + asset1, asset2 = corr_key.split(':', 1) + if asset1 in assets and asset2 in assets: + price2 = assets[asset2]['price'] + base2 = assets_config.get(asset2, {}).get('base_price', price2) + change2 = (price2 / base2) - 1 if base2 > 0 else 0 + assets[asset1]['price'] *= (1 + corr_value * change2) # Обновляем данные рынка market_data['assets'] = assets market_data['last_update'] = datetime.utcnow().isoformat() - game_state.market_data = json.dumps(market_data) + db.session.commit() - # Отправляем обновление через WebSocket + # Отправляем обновление socketio.emit('market_updated', { 'room': room.code, 'assets': assets, 'timestamp': datetime.utcnow().isoformat() }, room=room.code) - # Через 25 секунд переходим к событиям + # Переходим к следующей фазе time.sleep(25) - # Проверяем, что мы все еще в фазе рынка room = GameRoom.query.get(room_id) if room and room.game_state and room.game_state.phase == 'market': game_state = room.game_state game_state.phase = 'event' - game_state.phase_end = datetime.utcnow() + timedelta(seconds=30) + + # Получаем длительность фазы из конфига + game_config = balance.get_game_config() + phase_duration = game_config.get('PHASE_DURATIONS', {}).get('event', 30) + + game_state.phase_end = datetime.utcnow() + timedelta(seconds=phase_duration) db.session.commit() diff --git a/game_balance/__init__.py b/game_balance/__init__.py new file mode 100644 index 0000000..1d7196d --- /dev/null +++ b/game_balance/__init__.py @@ -0,0 +1,15 @@ +from game_balance.config import BalanceConfig +from game_balance.assets_config import AssetsConfig +from game_balance.players_config import PlayersConfig +from game_balance.game_config import GameConfig +from game_balance.economy_config import EconomyConfig +from game_balance.events_config import EventsConfig + +__all__ = [ + 'BalanceConfig', + 'AssetsConfig', + 'PlayersConfig', + 'GameConfig', + 'EconomyConfig', + 'EventsConfig' +] \ No newline at end of file diff --git a/game_balance/assets_config.py b/game_balance/assets_config.py new file mode 100644 index 0000000..3910fe1 --- /dev/null +++ b/game_balance/assets_config.py @@ -0,0 +1,252 @@ +""" +НАСТРОЙКИ АКТИВОВ +""" + + +class AssetsConfig: + # Категории активов + ASSET_CATEGORIES = { + 'bonds': {'name': 'Облигации', 'color': '#4CAF50', 'icon': '🏦'}, + 'stocks': {'name': 'Акции', 'color': '#2196F3', 'icon': '📈'}, + 'real_estate': {'name': 'Недвижимость', 'color': '#FF9800', 'icon': '🏠'}, + 'crypto': {'name': 'Криптовалюта', 'color': '#9C27B0', 'icon': '💰'}, + 'commodities': {'name': 'Сырье', 'color': '#795548', 'icon': '⛽'}, + 'business': {'name': 'Бизнес', 'color': '#F44336', 'icon': '🏢'}, + 'unique': {'name': 'Уникальные', 'color': '#FFD700', 'icon': '🏆'}, + } + + # Список всех активов + ASSETS = { + # ОБЛИГАЦИИ + 'gov_bonds': { + 'name': 'Государственные облигации', + 'category': 'bonds', + 'base_price': 10000, + 'volatility': 0.05, + 'income_per_month': 0.01, + 'risk_level': 1, + 'liquidity': 10, + 'total_quantity': None, + 'max_per_player': None, + 'min_purchase': 1, + 'description': 'Самый надежный актив. Защита от кризисов.' + }, + + # АКЦИИ + 'stock_gazprom': { + 'name': 'Акции Газпрома', + 'category': 'stocks', + 'base_price': 1000, + 'volatility': 0.15, + 'income_per_month': 0.03, + 'risk_level': 3, + 'liquidity': 8, + 'total_quantity': 10000, + 'max_per_player': 2000, + 'min_purchase': 10, + 'description': 'Голубые фишки. Стабильные дивиденды.' + }, + 'stock_sberbank': { + 'name': 'Акции Сбербанка', + 'category': 'stocks', + 'base_price': 300, + 'volatility': 0.18, + 'income_per_month': 0.04, + 'risk_level': 4, + 'liquidity': 9, + 'total_quantity': 15000, + 'max_per_player': 3000, + 'min_purchase': 10, + 'description': 'Крупнейший банк. Высокая ликвидность.' + }, + 'stock_yandex': { + 'name': 'Акции Яндекса', + 'category': 'stocks', + 'base_price': 3500, + 'volatility': 0.25, + 'income_per_month': 0.00, + 'risk_level': 6, + 'liquidity': 7, + 'total_quantity': 5000, + 'max_per_player': 1000, + 'min_purchase': 1, + 'description': 'IT-гигант. Высокий рост, высокая волатильность.' + }, + + # НЕДВИЖИМОСТЬ + 'apartment_small': { + 'name': 'Небольшая квартира', + 'category': 'real_estate', + 'base_price': 5000000, + 'volatility': 0.10, + 'income_per_month': 0.02, + 'risk_level': 2, + 'liquidity': 4, + 'total_quantity': 12, + 'max_per_player': 3, + 'min_purchase': 1, + 'description': 'Стабильный доход от аренды.' + }, + 'apartment_elite': { + 'name': 'Элитная квартира', + 'category': 'real_estate', + 'base_price': 15000000, + 'volatility': 0.08, + 'income_per_month': 0.03, + 'risk_level': 2, + 'liquidity': 2, + 'total_quantity': 3, + 'max_per_player': 1, + 'min_purchase': 1, + 'description': 'Статусный актив. Низкая волатильность.' + }, + + # КРИПТОВАЛЮТА + 'bitcoin': { + 'name': 'Биткоин', + 'category': 'crypto', + 'base_price': 2000000, + 'volatility': 0.35, + 'income_per_month': 0.00, + 'risk_level': 9, + 'liquidity': 7, + 'total_quantity': None, + 'max_per_player': None, + 'min_purchase': 0.001, + 'description': 'Высокорисковый актив. Только спекуляции.' + }, + + # СЫРЬЕ + 'oil': { + 'name': 'Нефть Brent', + 'category': 'commodities', + 'base_price': 5500, + 'volatility': 0.25, + 'income_per_month': 0.00, + 'risk_level': 6, + 'liquidity': 6, + 'total_quantity': 200, + 'max_per_player': 40, + 'min_purchase': 10, + 'description': 'Зависит от политики. Высокая волатильность.' + }, + 'natural_gas': { + 'name': 'Природный газ', + 'category': 'commodities', + 'base_price': 3000, + 'volatility': 0.20, + 'income_per_month': 0.00, + 'risk_level': 5, + 'liquidity': 5, + 'total_quantity': 300, + 'max_per_player': 50, + 'min_purchase': 100, + 'description': 'Сезонный актив. Пик зимой.' + }, + + # БИЗНЕС + 'coffee_shop': { + 'name': 'Кофейня', + 'category': 'business', + 'base_price': 2000000, + 'volatility': 0.12, + 'income_per_month': 0.05, + 'risk_level': 5, + 'liquidity': 3, + 'total_quantity': 8, + 'max_per_player': 2, + 'min_purchase': 1, + 'description': 'Пассивный бизнес. Средний риск.' + }, + 'it_startup': { + 'name': 'IT-стартап', + 'category': 'business', + 'base_price': 1000000, + 'volatility': 0.40, + 'income_per_month': 0.00, + 'risk_level': 10, + 'liquidity': 1, + 'total_quantity': 4, + 'max_per_player': 1, + 'min_purchase': 1, + 'description': 'Венчурные инвестиции. Может взлететь или провалиться.' + }, + + # УНИКАЛЬНЫЕ + 'shopping_mall': { + 'name': 'Торговый центр', + 'category': 'unique', + 'base_price': 50000000, + 'volatility': 0.10, + 'income_per_month': 0.06, + 'risk_level': 4, + 'liquidity': 1, + 'total_quantity': 1, + 'max_per_player': 1, + 'min_purchase': 1, + 'description': 'Ключевой актив. Контроль над малым бизнесом.', + 'special_rules': { + 'can_be_fractional': True, + 'min_fraction': 0.1, + 'control_bonus': 0.1 + } + }, + 'oil_field': { + 'name': 'Нефтяное месторождение', + 'category': 'unique', + 'base_price': 100000000, + 'volatility': 0.20, + 'income_per_month': 0.08, + 'risk_level': 7, + 'liquidity': 1, + 'total_quantity': 1, + 'max_per_player': 1, + 'min_purchase': 1, + 'description': 'Стратегический актив. Контроль над ценами на нефть.', + 'special_rules': { + 'affects_other_assets': ['oil'], + 'price_influence': 0.15 + } + } + } + + @staticmethod + def calculate_price(asset_data, demand_factor, month, total_players): + """Расчет текущей цены актива""" + base_price = asset_data['base_price'] + volatility = asset_data['volatility'] + + # Базовое изменение от спроса + price = base_price * (1 + demand_factor * volatility) + + # Инфляция (0.5% в месяц) + price *= (1.005 ** month) + + # Ограничения + price = max(price, base_price * 0.1) # Не ниже 10% от базы + price = min(price, base_price * 5) # Не выше 500% от базы + + # Округление + if price < 1000: + return round(price, 2) + elif price < 1000000: + return round(price, 0) + else: + return round(price, -3) + + @staticmethod + def calculate_income(asset_data, player_share, total_owners): + """Расчет доходности с учетом баланса""" + base_income = asset_data['income_per_month'] + + if base_income <= 0: + return 0 + + # Штраф за концентрацию (если много владельцев у одного игрока) + if total_owners > 0: + concentration_penalty = (player_share ** 2) * 0.2 + income = base_income * (1 - concentration_penalty) + else: + income = base_income + + return max(income, 0.001) # Минимум 0.1% \ No newline at end of file diff --git a/game_balance/config.py b/game_balance/config.py new file mode 100644 index 0000000..896d704 --- /dev/null +++ b/game_balance/config.py @@ -0,0 +1,34 @@ +""" +КОНФИГУРАЦИЯ БАЛАНСА ИГРЫ "КАПИТАЛ & РЫНОК" +""" + + +class BalanceConfig: + # Текущий режим баланса + BALANCE_MODE = "standard" # standard, easy, hard, tournament + + @classmethod + def load_balance_mode(cls, mode): + """Загрузка определенного режима баланса""" + if mode == "easy": + from game_balance.test_balance.easy_mode import EasyBalance + return EasyBalance() + elif mode == "hard": + from game_balance.test_balance.hard_mode import HardBalance + return HardBalance() + elif mode == "tournament": + from game_balance.test_balance.tournament import TournamentBalance + return TournamentBalance() + else: + from game_balance.test_balance.standard_mode import StandardBalance + return StandardBalance() + + @classmethod + def get_available_modes(cls): + """Возвращает список доступных режимов""" + return { + 'standard': 'Стандартный', + 'easy': 'Легкий (новички)', + 'hard': 'Сложный (эксперты)', + 'tournament': 'Турнирный' + } \ No newline at end of file diff --git a/game_balance/economy_config.py b/game_balance/economy_config.py new file mode 100644 index 0000000..3fba31d --- /dev/null +++ b/game_balance/economy_config.py @@ -0,0 +1,74 @@ +""" +ЭКОНОМИЧЕСКИЕ ПАРАМЕТРЫ +""" + +class EconomyConfig: + # Налоговая система + TAX_SYSTEM = { + 'income_tax': [ + {'threshold': 0, 'rate': 0.00}, + {'threshold': 50000, 'rate': 0.10}, + {'threshold': 200000, 'rate': 0.15}, + {'threshold': 500000, 'rate': 0.20}, + {'threshold': 1000000, 'rate': 0.25} + ], + 'wealth_tax': { + 'threshold': 5000000, + 'rate': 0.01 + }, + 'monopoly_tax': { + 'threshold': 0.4, + 'rate': 0.03 + }, + 'transaction_fee': 0.01, + 'auction_fee': 0.02, + } + + # Кредитная система + LOAN_SYSTEM = { + 'max_loan_multiplier': 10.0, + 'interest_rates': { + 'standard': 0.05, + 'crisis': 0.10, + 'black_market': 0.15, + }, + 'repayment_periods': [3, 6, 12, 24], + 'late_fee': 0.02, + 'default_threshold': 3, + } + + # Макроэкономика + MACROECONOMICS = { + 'base_inflation': 0.005, + 'inflation_multipliers': { + 'crisis': 1.5, + 'boom': 0.7, + 'default': 2.0, + }, + 'central_bank_rate': 0.04, + } + + # Корреляции активов - ИСПРАВЛЕНО: ключи-строки вместо кортежей + ASSET_CORRELATIONS = { + 'stock_gazprom:oil': 0.6, + 'stock_sberbank:stock_gazprom': 0.4, + 'oil:natural_gas': 0.7, + 'apartment_small:apartment_elite': 0.5, + 'gold:bitcoin': -0.3, + 'gov_bonds:stock_gazprom': -0.2, + } + + @staticmethod + def get_correlation(asset1, asset2): + """Получить корреляцию между активами""" + # Пробуем прямой ключ + key1 = f"{asset1}:{asset2}" + if key1 in EconomyConfig.ASSET_CORRELATIONS: + return EconomyConfig.ASSET_CORRELATIONS[key1] + + # Пробуем обратный ключ + key2 = f"{asset2}:{asset1}" + if key2 in EconomyConfig.ASSET_CORRELATIONS: + return EconomyConfig.ASSET_CORRELATIONS[key2] + + return 0 # Нет корреляции \ No newline at end of file diff --git a/game_balance/events_config.py b/game_balance/events_config.py new file mode 100644 index 0000000..36f8124 --- /dev/null +++ b/game_balance/events_config.py @@ -0,0 +1,102 @@ +""" +СОБЫТИЯ И КРИЗИСЫ +""" + + +class EventsConfig: + # Типы событий + EVENT_TYPES = { + 'economic': {'name': 'Экономические', 'frequency': 0.4, 'color': '#FF9800'}, + 'political': {'name': 'Политические', 'frequency': 0.3, 'color': '#F44336'}, + 'natural': {'name': 'Природные', 'frequency': 0.15, 'color': '#4CAF50'}, + 'technological': {'name': 'Технологические', 'frequency': 0.15, 'color': '#2196F3'} + } + + # События + EVENTS = { + 'oil_boom': { + 'name': 'Бум нефти', + 'type': 'economic', + 'probability': 0.08, + 'effects': { + 'oil': {'price_change': 0.30, 'duration': 2}, + 'stock_gazprom': {'price_change': 0.15, 'duration': 2}, + }, + 'description': 'Цены на нефть взлетели на 30%' + }, + 'financial_crisis': { + 'name': 'Финансовый кризис', + 'type': 'economic', + 'probability': 0.10, + 'effects': { + 'ALL': {'price_change': -0.20, 'duration': 2}, + 'gov_bonds': {'price_change': 0.05, 'duration': 2}, + }, + 'description': 'Кризис ликвидности на рынках' + }, + 'sanctions': { + 'name': 'Международные санкции', + 'type': 'political', + 'probability': 0.07, + 'effects': { + 'stock_gazprom': {'price_change': -0.35, 'duration': 3}, + 'stock_sberbank': {'price_change': -0.25, 'duration': 3}, + }, + 'description': 'Новые санкции против российских компаний' + }, + 'tech_revolution': { + 'name': 'Технологическая революция', + 'type': 'technological', + 'probability': 0.05, + 'effects': { + 'it_startup': {'price_change': 0.50, 'duration': 3}, + 'stock_yandex': {'price_change': 0.25, 'duration': 2}, + 'bitcoin': {'price_change': 0.20, 'duration': 1}, + }, + 'description': 'Прорыв в IT-технологиях' + }, + 'elections': { + 'name': 'Президентские выборы', + 'type': 'political', + 'probability': 0.06, + 'effects': { + 'ALL': {'price_change': -0.10, 'duration': 1}, + 'gov_bonds': {'price_change': 0.05, 'duration': 1}, + }, + 'description': 'Неопределенность перед выборами' + }, + 'earthquake': { + 'name': 'Землетрясение', + 'type': 'natural', + 'probability': 0.03, + 'effects': { + 'real_estate': {'price_change': -0.25, 'duration': 3}, + }, + 'description': 'Разрушена инфраструктура в ключевом регионе' + } + } + + # Кризисы + CRISES = { + 'hyperinflation': { + 'name': 'Гиперинфляция', + 'probability': 0.02, + 'conditions': ['month > 6'], + 'effects': { + 'ALL': {'price_change': 0.50, 'duration': 3}, + 'cash': {'value_change': -0.30, 'duration': 3}, + }, + 'description': 'Гиперинфляция! Цены растут на 50% в месяц' + }, + 'market_crash': { + 'name': 'Обвал рынка', + 'probability': 0.025, + 'conditions': ['month > 4'], + 'effects': { + 'stocks': {'price_change': -0.60, 'duration': 4}, + 'real_estate': {'price_change': -0.40, 'duration': 6}, + 'crypto': {'price_change': -0.80, 'duration': 2}, + }, + 'description': 'Лопнул финансовый пузырь. Рынки рухнули' + } + } \ No newline at end of file diff --git a/game_balance/game_config.py b/game_balance/game_config.py new file mode 100644 index 0000000..05065c6 --- /dev/null +++ b/game_balance/game_config.py @@ -0,0 +1,43 @@ +""" +НАСТРОЙКИ ИГРОВОГО ПРОЦЕССА +""" + + +class GameConfig: + # Основные параметры + GAME_NAME = "Капитал & Рынок" + VERSION = "1.0.0" + + DEFAULT_TOTAL_MONTHS = 12 + MIN_TOTAL_MONTHS = 6 + MAX_TOTAL_MONTHS = 24 + + # Тайминги фаз (секунды) + PHASE_DURATIONS = { + 'action': 120, + 'market': 30, + 'event': 30, + 'results': 45 + } + + # Режимы скорости + SPEED_MODES = { + 'slow': 2.0, + 'normal': 1.0, + 'fast': 0.5, + 'blitz': 0.25 + } + + # Настройки комнат + MAX_PLAYERS_PER_ROOM = 10 + MIN_PLAYERS_TO_START = 2 + MIN_PLAYERS_TO_CONTINUE = 2 + + # Ограничения + MAX_TRANSACTIONS_PER_PHASE = 20 + MIN_BID_AMOUNT = 1000 + + # Настройки интерфейса + DEFAULT_LANGUAGE = 'ru' + CURRENCY_SYMBOL = '₽' + CURRENCY_CODE = 'RUB' \ No newline at end of file diff --git a/game_balance/players_config.py b/game_balance/players_config.py new file mode 100644 index 0000000..6512d1e --- /dev/null +++ b/game_balance/players_config.py @@ -0,0 +1,158 @@ +""" +НАСТРОЙКИ ИГРОКОВ +""" + + +class PlayersConfig: + # Стартовые условия + STARTING_CAPITAL = 100000 + TARGET_CAPITAL = 50000000 + + # Способности игроков + ABILITIES = { + 'crisis_investor': { + 'name': 'Кризисный инвестор', + 'description': '+20% к доходу при падении рынка более 10%', + 'cooldown': 3, + 'effect': { + 'type': 'crisis_bonus', + 'value': 0.2, + 'condition': 'market_drop > 0.1' + } + }, + 'lobbyist': { + 'name': 'Лоббист', + 'description': 'Может временно снизить налоги на 5%', + 'cooldown': 2, + 'effect': { + 'type': 'tax_reduction', + 'value': 0.05, + 'duration': 1 + } + }, + 'predictor': { + 'name': 'Предсказатель', + 'description': 'Видит следующее случайное событие за 1 месяц', + 'cooldown': 4, + 'effect': { + 'type': 'event_preview', + 'lookahead': 1 + } + }, + 'golden_pillow': { + 'name': 'Золотая подушка', + 'description': 'Может защитить 20% капитала от любых потерь', + 'cooldown': 6, + 'effect': { + 'type': 'capital_protection', + 'percentage': 0.2, + 'duration': 1 + } + }, + 'shadow_accountant': { + 'name': 'Теневая бухгалтерия', + 'description': 'Раз в игру уменьшить налоги на 50%', + 'cooldown': None, + 'effect': { + 'type': 'tax_evasion', + 'value': 0.5 + } + }, + 'credit_magnate': { + 'name': 'Кредитный магнат', + 'description': 'Может давать кредиты другим игрокам под свой процент', + 'cooldown': 1, + 'effect': { + 'type': 'lend_money', + 'max_amount_multiplier': 0.5 + } + }, + 'bear_raid': { + 'name': 'Медвежий набег', + 'description': 'Вызвать искусственное падение цены актива на 15%', + 'cooldown': 4, + 'effect': { + 'type': 'price_manipulation', + 'direction': 'down', + 'value': 0.15 + } + }, + 'fake_news': { + 'name': 'Фейковые новости', + 'description': 'Подменить одно случайное событие', + 'cooldown': 6, + 'effect': { + 'type': 'event_manipulation', + 'scope': 'next_event' + } + }, + 'dividend_king': { + 'name': 'Король дивидендов', + 'description': '+10% дохода от всех дивидендных активов', + 'cooldown': 0, + 'effect': { + 'type': 'dividend_bonus', + 'value': 0.1 + } + }, + 'raider_capture': { + 'name': 'Рейдерский захват', + 'description': 'Попытаться отобрать 5% капитала у лидера', + 'cooldown': 3, + 'effect': { + 'type': 'capital_raid', + 'percentage': 0.05, + 'success_chance': 0.6, + 'penalty': 0.1 + } + }, + 'mafia_connections': { + 'name': 'Мафиозные связи', + 'description': 'Отменить одно негативное событие для себя', + 'cooldown': 5, + 'effect': { + 'type': 'event_block', + 'condition': 'negative_event' + } + }, + 'economic_advisor': { + 'name': 'Экономический советник', + 'description': 'Раз в 3 месяца изменить налоговую ставку для всех', + 'cooldown': 3, + 'effect': { + 'type': 'tax_policy', + 'change_range': (-0.05, 0.05) + } + }, + 'currency_speculator': { + 'name': 'Валютный спекулянт', + 'description': 'На 1 месяц отвязать рубль от нефти', + 'cooldown': 4, + 'effect': { + 'type': 'correlation_break', + 'assets': ['oil', 'stock_gazprom'], + 'duration': 1 + } + } + } + + # Ограничения по владению + MAX_ASSETS_PER_TYPE = { + 'bonds': None, + 'stocks': 0.4, + 'real_estate': 0.5, + 'crypto': None, + 'commodities': 0.3, + 'business': 0.4, + 'unique': 1.0, + } + + # Лимиты по месяцам + PURCHASE_LIMITS_BY_MONTH = { + 1: 0.1, + 2: 0.2, + 3: 0.4, + 4: 0.6, + 5: 0.8, + 6: 1.0, + } \ No newline at end of file diff --git a/game_balance/test_balance/__init__.py b/game_balance/test_balance/__init__.py new file mode 100644 index 0000000..19f17d3 --- /dev/null +++ b/game_balance/test_balance/__init__.py @@ -0,0 +1,11 @@ +from game_balance.test_balance.easy_mode import EasyBalance +from game_balance.test_balance.hard_mode import HardBalance +from game_balance.test_balance.tournament import TournamentBalance +from game_balance.test_balance.standard_mode import StandardBalance + +__all__ = [ + 'EasyBalance', + 'HardBalance', + 'TournamentBalance', + 'StandardBalance' +] \ No newline at end of file diff --git a/game_balance/test_balance/easy_mode.py b/game_balance/test_balance/easy_mode.py new file mode 100644 index 0000000..083790e --- /dev/null +++ b/game_balance/test_balance/easy_mode.py @@ -0,0 +1,73 @@ +""" +ЛЕГКИЙ РЕЖИМ БАЛАНСА +Для новичков +""" + +from game_balance.test_balance.standard_mode import StandardBalance + + +class EasyBalance(StandardBalance): + """Облегченный режим""" + + @classmethod + def get_assets_config(cls): + config = super().get_assets_config() + + # Делаем активы дешевле и стабильнее + for asset_id, asset_data in config.items(): + if asset_data.get('base_price'): + asset_data['base_price'] *= 0.5 + if asset_data.get('volatility'): + asset_data['volatility'] *= 0.7 + if asset_data.get('income_per_month'): + asset_data['income_per_month'] *= 1.3 + + return config + + @classmethod + def get_players_config(cls): + config = super().get_players_config() + config['STARTING_CAPITAL'] = 200000 + config['TARGET_CAPITAL'] = 25000000 + + # Более мягкие ограничения + config['MAX_ASSETS_PER_TYPE'] = { + 'bonds': None, + 'stocks': 0.6, + 'real_estate': 0.7, + 'crypto': None, + 'commodities': 0.5, + 'business': 0.6, + 'unique': 1.0, + } + + # Быстрое снятие лимитов + config['PURCHASE_LIMITS_BY_MONTH'] = { + 1: 0.3, + 2: 0.6, + 3: 1.0, + } + + return config + + @classmethod + def get_economy_config(cls): + config = super().get_economy_config() + + # Меньше налогов + config['TAX_SYSTEM']['income_tax'] = [ + {'threshold': 0, 'rate': 0.00}, + {'threshold': 100000, 'rate': 0.05}, + {'threshold': 500000, 'rate': 0.10}, + {'threshold': 2000000, 'rate': 0.15}, + {'threshold': 5000000, 'rate': 0.20}, + ] + config['TAX_SYSTEM']['wealth_tax']['threshold'] = 10000000 + + # Ниже ставки по кредитам + config['LOAN_SYSTEM']['interest_rates']['standard'] = 0.03 + + # Меньше инфляции + config['MACROECONOMICS']['base_inflation'] = 0.002 + + return config \ No newline at end of file diff --git a/game_balance/test_balance/hard_mode.py b/game_balance/test_balance/hard_mode.py new file mode 100644 index 0000000..6ce6049 --- /dev/null +++ b/game_balance/test_balance/hard_mode.py @@ -0,0 +1,73 @@ +""" +ЛЕГКИЙ РЕЖИМ БАЛАНСА +Для новичков +""" + +from game_balance.test_balance.standard_mode import StandardBalance + + +class HardBalance(StandardBalance): + """Облегченный режим""" + + @classmethod + def get_assets_config(cls): + config = super().get_assets_config() + + # Делаем активы дешевле и стабильнее + for asset_id, asset_data in config.items(): + if asset_data.get('base_price'): + asset_data['base_price'] *= 0.5 + if asset_data.get('volatility'): + asset_data['volatility'] *= 0.7 + if asset_data.get('income_per_month'): + asset_data['income_per_month'] *= 1.3 + + return config + + @classmethod + def get_players_config(cls): + config = super().get_players_config() + config['STARTING_CAPITAL'] = 200000 + config['TARGET_CAPITAL'] = 25000000 + + # Более мягкие ограничения + config['MAX_ASSETS_PER_TYPE'] = { + 'bonds': None, + 'stocks': 0.6, + 'real_estate': 0.7, + 'crypto': None, + 'commodities': 0.5, + 'business': 0.6, + 'unique': 1.0, + } + + # Быстрое снятие лимитов + config['PURCHASE_LIMITS_BY_MONTH'] = { + 1: 0.3, + 2: 0.6, + 3: 1.0, + } + + return config + + @classmethod + def get_economy_config(cls): + config = super().get_economy_config() + + # Меньше налогов + config['TAX_SYSTEM']['income_tax'] = [ + {'threshold': 0, 'rate': 0.00}, + {'threshold': 100000, 'rate': 0.05}, + {'threshold': 500000, 'rate': 0.10}, + {'threshold': 2000000, 'rate': 0.15}, + {'threshold': 5000000, 'rate': 0.20}, + ] + config['TAX_SYSTEM']['wealth_tax']['threshold'] = 10000000 + + # Ниже ставки по кредитам + config['LOAN_SYSTEM']['interest_rates']['standard'] = 0.03 + + # Меньше инфляции + config['MACROECONOMICS']['base_inflation'] = 0.002 + + return config \ No newline at end of file diff --git a/game_balance/test_balance/standard_mode.py b/game_balance/test_balance/standard_mode.py new file mode 100644 index 0000000..73c917f --- /dev/null +++ b/game_balance/test_balance/standard_mode.py @@ -0,0 +1,61 @@ +""" +СТАНДАРТНЫЙ РЕЖИМ БАЛАНСА +Используется по умолчанию +""" + +from game_balance.assets_config import AssetsConfig +from game_balance.players_config import PlayersConfig +from game_balance.economy_config import EconomyConfig +from game_balance.events_config import EventsConfig +from game_balance.game_config import GameConfig + +class StandardBalance: + """Стандартный сбалансированный режим""" + + @classmethod + def get_assets_config(cls): + """Возвращает конфигурацию активов""" + return AssetsConfig.ASSETS.copy() + + @classmethod + def get_players_config(cls): + """Возвращает конфигурацию игроков""" + return { + 'STARTING_CAPITAL': PlayersConfig.STARTING_CAPITAL, + 'TARGET_CAPITAL': PlayersConfig.TARGET_CAPITAL, + 'MAX_ASSETS_PER_TYPE': PlayersConfig.MAX_ASSETS_PER_TYPE.copy(), + 'PURCHASE_LIMITS_BY_MONTH': PlayersConfig.PURCHASE_LIMITS_BY_MONTH.copy(), + 'ABILITIES': PlayersConfig.ABILITIES.copy() + } + + @classmethod + def get_economy_config(cls): + """Возвращает экономическую конфигурацию""" + return { + 'TAX_SYSTEM': EconomyConfig.TAX_SYSTEM.copy(), + 'LOAN_SYSTEM': EconomyConfig.LOAN_SYSTEM.copy(), + 'MACROECONOMICS': EconomyConfig.MACROECONOMICS.copy(), + 'ASSET_CORRELATIONS': EconomyConfig.ASSET_CORRELATIONS.copy() + } + + @classmethod + def get_events_config(cls): + """Возвращает конфигурацию событий""" + return { + 'EVENT_TYPES': EventsConfig.EVENT_TYPES.copy(), + 'EVENTS': EventsConfig.EVENTS.copy(), + 'CRISES': EventsConfig.CRISES.copy() + } + + @classmethod + def get_game_config(cls): + """Возвращает игровую конфигурацию""" + return { + 'PHASE_DURATIONS': GameConfig.PHASE_DURATIONS.copy(), + 'SPEED_MODES': GameConfig.SPEED_MODES.copy(), + 'MAX_PLAYERS_PER_ROOM': GameConfig.MAX_PLAYERS_PER_ROOM, + 'MIN_PLAYERS_TO_START': GameConfig.MIN_PLAYERS_TO_START, + 'MIN_PLAYERS_TO_CONTINUE': GameConfig.MIN_PLAYERS_TO_CONTINUE, + 'MAX_TRANSACTIONS_PER_PHASE': GameConfig.MAX_TRANSACTIONS_PER_PHASE, + 'MIN_BID_AMOUNT': GameConfig.MIN_BID_AMOUNT + } \ No newline at end of file diff --git a/game_balance/test_balance/tournament.py b/game_balance/test_balance/tournament.py new file mode 100644 index 0000000..2fff1b2 --- /dev/null +++ b/game_balance/test_balance/tournament.py @@ -0,0 +1,83 @@ +""" +ТУРНИРНЫЙ РЕЖИМ БАЛАНСА +Для соревнований +""" + +from game_balance.test_balance.standard_mode import StandardBalance + + +class TournamentBalance(StandardBalance): + """Турнирный режим""" + + @classmethod + def get_assets_config(cls): + config = super().get_assets_config() + + # Балансировка цен + price_adjustments = { + 'gov_bonds': 1.0, + 'stock_gazprom': 0.8, + 'stock_sberbank': 0.8, + 'apartment_small': 0.7, + 'apartment_elite': 0.6, + 'bitcoin': 1.2, + 'oil': 1.0, + 'coffee_shop': 0.8, + 'it_startup': 1.0, + 'shopping_mall': 0.5, + 'oil_field': 0.4, + } + + for asset_id, multiplier in price_adjustments.items(): + if asset_id in config and config[asset_id].get('base_price'): + config[asset_id]['base_price'] *= multiplier + + return config + + @classmethod + def get_players_config(cls): + config = super().get_players_config() + config['STARTING_CAPITAL'] = 500000 + config['TARGET_CAPITAL'] = 100000000 + + # Жесткие ограничения против монополий + config['MAX_ASSETS_PER_TYPE'] = { + 'bonds': 0.5, + 'stocks': 0.3, + 'real_estate': 0.4, + 'crypto': 0.5, + 'commodities': 0.3, + 'business': 0.4, + 'unique': 0.5, + } + + # Быстрый старт + config['PURCHASE_LIMITS_BY_MONTH'] = { + 1: 0.5, + 2: 1.0, + } + + return config + + @classmethod + def get_economy_config(cls): + config = super().get_economy_config() + + # Высокие налоги + config['TAX_SYSTEM']['income_tax'] = [ + {'threshold': 0, 'rate': 0.00}, + {'threshold': 100000, 'rate': 0.15}, + {'threshold': 500000, 'rate': 0.25}, + {'threshold': 2000000, 'rate': 0.35}, + {'threshold': 5000000, 'rate': 0.45}, + ] + config['TAX_SYSTEM']['wealth_tax']['rate'] = 0.02 + config['TAX_SYSTEM']['transaction_fee'] = 0.02 + + # Высокие ставки + config['LOAN_SYSTEM']['interest_rates']['standard'] = 0.08 + + # Высокая инфляция + config['MACROECONOMICS']['base_inflation'] = 0.01 + + return config \ No newline at end of file diff --git a/game_balance/validator.py b/game_balance/validator.py new file mode 100644 index 0000000..52c464a --- /dev/null +++ b/game_balance/validator.py @@ -0,0 +1,124 @@ +""" +ВАЛИДАТОР БАЛАНСА +Проверяет корректность настроек +""" + +class BalanceValidator: + @staticmethod + def validate_assets(assets): + """Проверка конфигурации активов""" + errors = [] + + if not isinstance(assets, dict): + errors.append("Assets config must be a dictionary") + return errors + + for asset_id, asset_data in assets.items(): + if not isinstance(asset_data, dict): + errors.append(f"Asset {asset_id}: data must be a dictionary") + continue + + # Проверка обязательных полей + required_fields = ['name', 'category', 'base_price', 'volatility'] + for field in required_fields: + if field not in asset_data: + errors.append(f"Asset {asset_id}: missing required field '{field}'") + + # Проверка цены + price = asset_data.get('base_price', 0) + if not isinstance(price, (int, float)) or price <= 0: + errors.append(f"Asset {asset_id}: base_price must be positive number") + + # Проверка волатильности + volatility = asset_data.get('volatility', 0) + if not isinstance(volatility, (int, float)) or volatility < 0 or volatility > 1: + errors.append(f"Asset {asset_id}: volatility must be between 0 and 1") + + # Проверка доходности + income = asset_data.get('income_per_month', 0) + if not isinstance(income, (int, float)) or income < 0 or income > 0.5: + errors.append(f"Asset {asset_id}: income_per_month must be between 0 and 0.5") + + # Проверка количества + total = asset_data.get('total_quantity') + if total is not None: + if not isinstance(total, (int, float)) or total <= 0: + errors.append(f"Asset {asset_id}: total_quantity must be positive or None") + + return errors + + @staticmethod + def validate_economy(economy_config): + """Проверка экономической конфигурации""" + errors = [] + + if not isinstance(economy_config, dict): + errors.append("Economy config must be a dictionary") + return errors + + # Проверка налогов + tax_system = economy_config.get('TAX_SYSTEM', {}) + income_tax = tax_system.get('income_tax', []) + + if not isinstance(income_tax, list): + errors.append("income_tax must be a list") + else: + last_threshold = -1 + for i, bracket in enumerate(income_tax): + if not isinstance(bracket, dict): + errors.append(f"Tax bracket {i}: must be a dictionary") + continue + threshold = bracket.get('threshold', 0) + if threshold <= last_threshold: + errors.append(f"Tax brackets must be in ascending order at index {i}") + last_threshold = threshold + + # Проверка кредитов + loan_system = economy_config.get('LOAN_SYSTEM', {}) + max_multiplier = loan_system.get('max_loan_multiplier', 0) + if not isinstance(max_multiplier, (int, float)) or max_multiplier <= 0: + errors.append("max_loan_multiplier must be positive") + + # Проверка корреляций + correlations = economy_config.get('ASSET_CORRELATIONS', {}) + if not isinstance(correlations, dict): + errors.append("ASSET_CORRELATIONS must be a dictionary") + else: + for key, value in correlations.items(): + if not isinstance(key, str): + errors.append(f"Correlation key must be string, got {type(key)}") + if not isinstance(value, (int, float)) or value < -1 or value > 1: + errors.append(f"Correlation value must be between -1 and 1, got {value}") + + return errors + + @staticmethod + def validate_balance_mode(balance): + """Полная проверка режима баланса""" + all_errors = [] + + # Проверяем наличие необходимых методов + required_methods = ['get_assets_config', 'get_players_config', 'get_economy_config'] + for method in required_methods: + if not hasattr(balance, method): + all_errors.append(f"Balance mode missing required method: {method}") + return all_errors + + try: + # Проверка активов + assets = balance.get_assets_config() + all_errors.extend(BalanceValidator.validate_assets(assets)) + + # Проверка экономики + economy = balance.get_economy_config() + all_errors.extend(BalanceValidator.validate_economy(economy)) + + # Проверка конфигурации игроков + players = balance.get_players_config() + if not isinstance(players, dict): + all_errors.append("Players config must be a dictionary") + + except Exception as e: + all_errors.append(f"Error validating balance: {str(e)}") + + return all_errors \ No newline at end of file diff --git a/templates/game.html b/templates/game.html index 3f5fb65..5d3a33e 100644 --- a/templates/game.html +++ b/templates/game.html @@ -362,6 +362,57 @@ + + + {% endblock %} {% block scripts %} @@ -374,6 +425,7 @@ let currentPhase = '{{ game_state.phase }}'; let phaseEndTime = '{{ game_state.phase_end }}'; let selectedAsset = null; let gameTimer = null; +let selectedAssetForSell = null; // Инициализация при загрузке document.addEventListener('DOMContentLoaded', function() { @@ -643,7 +695,8 @@ function loadAssets() { .then(response => response.json()) .then(data => { if (data.success && data.assets) { - displayAssets(data.assets); + window.allAssetsData = data.assets; + displayAssets(data.assets, data.categories); } }) .catch(error => { @@ -652,37 +705,194 @@ function loadAssets() { }); } -function displayAssets(assets) { +function displayAssets(assets, categories) { const assetsList = document.getElementById('assets-list'); if (!assetsList) return; assetsList.innerHTML = ''; - Object.entries(assets).forEach(([assetId, assetData]) => { - const assetElement = document.createElement('div'); - assetElement.className = 'asset-item'; - assetElement.innerHTML = ` -
-
- ${getAssetName(assetId)} -
-
- Волатильность: ${(assetData.volatility * 100).toFixed(1)}% -
-
-
-
- ${formatCurrency(assetData.price)} -
- -
- `; + // Сохраняем категории глобально для использования в других функциях + window.assetCategories = categories || { + 'bonds': {'name': 'Облигации', 'icon': '🏦'}, + 'stocks': {'name': 'Акции', 'icon': '📈'}, + 'real_estate': {'name': 'Недвижимость', 'icon': '🏠'}, + 'crypto': {'name': 'Криптовалюта', 'icon': '💰'}, + 'commodities': {'name': 'Сырьевые товары', 'icon': '⛽'}, + 'business': {'name': 'Бизнес', 'icon': '🏢'}, + 'unique': {'name': 'Уникальные активы', 'icon': '🏆'} + }; - assetsList.appendChild(assetElement); + // Группируем активы по категориям + const groupedAssets = {}; + + Object.entries(assets).forEach(([assetId, assetData]) => { + const category = assetData.category || 'other'; + if (!groupedAssets[category]) { + groupedAssets[category] = []; + } + groupedAssets[category].push({ id: assetId, ...assetData }); }); + + // Порядок категорий для отображения + const categoryOrder = ['bonds', 'stocks', 'real_estate', 'business', 'commodities', 'crypto', 'unique']; + + // Отображаем активы по категориям в заданном порядке + categoryOrder.forEach(categoryKey => { + if (groupedAssets[categoryKey] && groupedAssets[categoryKey].length > 0) { + const category = window.assetCategories[categoryKey] || { name: categoryKey, icon: '📊' }; + + // Заголовок категории + const categoryHeader = document.createElement('div'); + categoryHeader.className = 'category-header'; + categoryHeader.innerHTML = ` + + ${category.icon || '📊'} + ${category.name || categoryKey} + + `; + assetsList.appendChild(categoryHeader); + + // Сортируем активы внутри категории по цене (от дешевых к дорогим) + groupedAssets[categoryKey].sort((a, b) => a.price - b.price); + + // Активы категории + groupedAssets[categoryKey].forEach(asset => { + const assetElement = createAssetElement(asset); + assetsList.appendChild(assetElement); + }); + } + }); + + // Показываем оставшиеся категории, которые не вошли в порядок + Object.keys(groupedAssets).forEach(categoryKey => { + if (!categoryOrder.includes(categoryKey) && groupedAssets[categoryKey].length > 0) { + const category = window.assetCategories[categoryKey] || { name: categoryKey, icon: '📊' }; + + const categoryHeader = document.createElement('div'); + categoryHeader.className = 'category-header'; + categoryHeader.innerHTML = ` + + ${category.icon || '📊'} + ${category.name || categoryKey} + + `; + assetsList.appendChild(categoryHeader); + + groupedAssets[categoryKey].forEach(asset => { + const assetElement = createAssetElement(asset); + assetsList.appendChild(assetElement); + }); + } + }); +} + +function createAssetElement(asset) { + const assetElement = document.createElement('div'); + assetElement.className = 'asset-item'; + + const isAvailable = asset.available === null || asset.available > 0; + const canAfford = window.playerCapital >= asset.price * asset.min_purchase; + const canBuyMore = !asset.max_per_player || asset.player_quantity < asset.max_per_player; + + let availabilityText = ''; + if (asset.available !== null) { + const percentage = ((asset.available / asset.total) * 100).toFixed(0); + availabilityText = `
+ Доступно: + ${asset.available} / ${asset.total} + (${percentage}%) +
`; + } + + let playerQuantityText = ''; + if (asset.player_quantity > 0) { + playerQuantityText = `
+ У вас: ${asset.player_quantity} шт. +
`; + } + + let limitText = ''; + if (asset.max_per_player) { + limitText = `
+ Лимит: ${asset.player_quantity} / ${asset.max_per_player} +
`; + } + + const volatilityLevel = getVolatilityLevel(asset.volatility); + const volatilityColors = { + 'low': '#4CAF50', + 'medium': '#FF9800', + 'high': '#F44336' + }; + + assetElement.innerHTML = ` +
+
+ ${getAssetIcon(asset.category)} + ${asset.name} +
+
+ + ⚡ Волатильность: ${(asset.volatility * 100).toFixed(0)}% + + ${asset.income_per_month > 0 ? + ` + 💰 Доход: +${(asset.income_per_month * 100).toFixed(1)}%/мес + ` : '' + } +
+ ${availabilityText} + ${playerQuantityText} + ${limitText} +
+ ${asset.description || 'Нет описания'} +
+
+
+
+ ${formatCurrency(asset.price)} +
+
+ ${asset.player_quantity > 0 ? + `` : '' + } + ${isAvailable && canAfford && canBuyMore ? + `` : + `` + } +
+
+ `; + + return assetElement; +} + +function getAssetIcon(category) { + const icons = { + 'bonds': '🏦', + 'stocks': '📈', + 'real_estate': '🏠', + 'crypto': '💰', + 'commodities': '⛽', + 'business': '🏢', + 'unique': '🏆', + 'other': '📊' + }; + return icons[category] || '📊'; +} + +function getVolatilityLevel(volatility) { + if (volatility < 0.15) return 'low'; + if (volatility < 0.25) return 'medium'; + return 'high'; } function getAssetName(assetId) { @@ -695,18 +905,40 @@ function getAssetName(assetId) { return names[assetId] || assetId; } -function showBuyAssetModal(assetId, price) { - selectedAsset = { id: assetId, price: price }; +function showBuyAssetModal(assetId, price, assetName, minQuantity, available) { + selectedAsset = { + id: assetId, + price: price, + name: assetName, + minQuantity: minQuantity, + available: available + }; - document.getElementById('buy-asset-title').textContent = `Покупка ${getAssetName(assetId)}`; + document.getElementById('buy-asset-title').textContent = `Покупка: ${assetName}`; document.getElementById('asset-price').textContent = formatCurrency(price); document.getElementById('player-capital-display').textContent = formatCurrency(playerCapital); - const maxQuantity = Math.floor(playerCapital / price); - document.getElementById('max-quantity').textContent = `${maxQuantity} шт.`; + const maxByCapital = Math.floor(playerCapital / price); + let maxQuantity = maxByCapital; + // Учитываем доступность + if (available !== null && available !== undefined) { + maxQuantity = Math.min(maxQuantity, available); + } + + // Учитываем лимит на игрока + const assetData = allAssetsData?.[assetId]; + if (assetData?.max_per_player) { + const currentQuantity = assetData.player_quantity || 0; + const remainingLimit = assetData.max_per_player - currentQuantity; + maxQuantity = Math.min(maxQuantity, remainingLimit); + } + + document.getElementById('max-quantity').textContent = `${maxQuantity} шт.`; document.getElementById('buy-quantity').max = maxQuantity; - document.getElementById('buy-quantity').value = 1; + document.getElementById('buy-quantity').value = Math.min(minQuantity, maxQuantity); + document.getElementById('buy-quantity').min = minQuantity; + document.getElementById('buy-quantity').step = minQuantity; updateBuyCalculations(); @@ -846,6 +1078,102 @@ function useAbility() { }); } +//продажа активов +function showSellModal(assetId, price, assetName, maxQuantity) { + selectedAssetForSell = { + id: assetId, + price: price, + name: assetName, + maxQuantity: maxQuantity + }; + + document.getElementById('sell-asset-title').textContent = `Продажа: ${assetName}`; + document.getElementById('sell-asset-price').textContent = formatCurrency(price); + document.getElementById('sell-player-quantity').textContent = `${maxQuantity} шт.`; + + const feeRate = 0.01; // 1% комиссия + document.getElementById('sell-fee').textContent = `${feeRate * 100}%`; + + document.getElementById('sell-quantity').max = maxQuantity; + document.getElementById('sell-quantity').value = 1; + document.getElementById('sell-quantity').min = 1; + + updateSellCalculations(); + + document.getElementById('sell-asset-modal').classList.add('active'); +} + +function updateSellCalculations() { + if (!selectedAssetForSell) return; + + const quantity = parseInt(document.getElementById('sell-quantity').value) || 1; + const price = selectedAssetForSell.price; + const feeRate = 0.01; + + const revenue = quantity * price; + const fee = revenue * feeRate; + const total = revenue - fee; + + document.getElementById('sell-revenue').textContent = formatCurrency(revenue); + document.getElementById('sell-fee-amount').textContent = formatCurrency(fee); + document.getElementById('sell-total').textContent = formatCurrency(total); +} + +function cancelSellAsset() { + selectedAssetForSell = null; + document.getElementById('sell-asset-modal').classList.remove('active'); +} + +function confirmSellAsset() { + if (!selectedAssetForSell) return; + + const quantity = parseInt(document.getElementById('sell-quantity').value) || 1; + + if (quantity > selectedAssetForSell.maxQuantity) { + showNotification(`❌ У вас только ${selectedAssetForSell.maxQuantity} шт.`, 'error'); + return; + } + + fetch(`/api/game/${roomCode}/sell`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': '{{ csrf_token() if csrf_token else "" }}' + }, + body: JSON.stringify({ + asset_id: selectedAssetForSell.id, + quantity: quantity + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + showNotification(data.message, 'success'); + cancelSellAsset(); + + // Обновляем капитал + playerCapital = data.new_capital; + updateCapitalProgress(); + + // Перезагружаем активы + loadAssets(); + + // Обновляем отображение капитала + document.getElementById('player-capital-display').textContent = + formatCurrency(playerCapital); + } else { + showNotification('❌ ' + data.error, 'error'); + } + }) + .catch(error => { + console.error('Error selling asset:', error); + showNotification('❌ Ошибка при продаже', 'error'); + }); +} + +// Добавляем обработчик input для поля количества +document.getElementById('sell-quantity')?.addEventListener('input', updateSellCalculations); + // Завершение фазы действий function endActionPhase() { if (!confirm('Завершить ваши действия и перейти к следующей фазе?')) { @@ -1270,6 +1598,112 @@ document.addEventListener('keydown', function(event) { font-size: 0.9rem; padding: 10px 5px; } +} + /* Стили для категорий активов */ +.category-header { + margin: 25px 0 15px 0; + padding-bottom: 10px; + border-bottom: 3px solid var(--primary-color); + position: relative; +} + +.category-header:first-of-type { + margin-top: 5px; +} + +.category-header span { + background-color: var(--primary-color); + color: white; + padding: 8px 16px; + border-radius: 20px; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 1rem; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} + +.asset-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 16px; + margin-bottom: 10px; + background-color: white; + border-radius: var(--border-radius); + border: 1px solid #eee; + transition: all 0.3s ease; +} + +.asset-item:hover { + transform: translateX(5px); + box-shadow: var(--box-shadow); + border-color: var(--primary-color); +} + +.asset-info { + flex: 1; + padding-right: 20px; +} + +.asset-name { + color: var(--text-color); + font-size: 1.1rem; +} + +.asset-price { + color: var(--primary-color); + font-size: 1.3rem; + font-weight: 700; +} + +.volatility-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 16px; + font-size: 0.8rem; + font-weight: 600; +} + +.volatility-low { + background-color: #e8f5e9; + color: #388e3c; +} + +.volatility-medium { + background-color: #fff3e0; + color: #ef6c00; +} + +.volatility-high { + background-color: #ffebee; + color: #d32f2f; +} + +.income-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 16px; + font-size: 0.8rem; + font-weight: 600; + background-color: #e8f5e9; + color: var(--success-color); +} + +@media (max-width: 768px) { + .asset-item { + flex-direction: column; + gap: 15px; + } + + .asset-info { + padding-right: 0; + width: 100%; + } + + .asset-item > div:last-child { + width: 100%; + } } {% endblock %} \ No newline at end of file diff --git a/templates/rooms.html b/templates/rooms.html index 06f10bb..c8ba78d 100644 --- a/templates/rooms.html +++ b/templates/rooms.html @@ -226,6 +226,19 @@ +
+ + + + Влияет на цены, налоги, доступность активов + +
+