Добавлена система квестов

This commit is contained in:
2025-09-24 09:00:34 +03:00
parent 5d19b6339e
commit c189eed962
10 changed files with 1099 additions and 410 deletions

48
db2.js Normal file
View File

@@ -0,0 +1,48 @@
require('dotenv').config();
const { Pool } = require('pg');
// Проверяем наличие строки подключения
if (!process.env.DATABASE_QUEST_NEW_QUESTS) {
console.error('❌ Ошибка: DATABASE_QUEST_NEW_QUESTS не задана в .env файле');
throw new Error('DATABASE_QUEST_NEW_QUESTS environment variable is required');
}
const connectionStr = process.env.DATABASE_QUEST_NEW_QUESTS;
console.log('Подключение к базе данных new_quests');
// Проверяем, содержит ли строка подключения пароль
if (!connectionStr.includes(':')) {
console.error('❌ Ошибка: Строка подключения не содержит пароль');
throw new Error('Database connection string must include password');
}
const new_quest_Base = new Pool({
connectionString: connectionStr,
ssl: false
});
// Обработчики событий
new_quest_Base.on('error', (err) => {
console.error('❌ Ошибка подключения к базе данных new_quests:', err.message);
});
new_quest_Base.on('connect', () => {
console.log('✅ Успешное подключение к базе данных new_quests');
});
// Функция для проверки подключения
new_quest_Base.testConnection = async () => {
try {
await new_quest_Base.query('SELECT 1 as test');
console.log('✅ Тест подключения к new_quests успешен');
return true;
} catch (error) {
console.error('❌ Тест подключения к new_quests failed:', error.message);
return false;
}
};
module.exports = {
query: (text, params) => new_quest_Base.query(text, params),
testConnection: () => new_quest_Base.testConnection()
};

View File

@@ -1,150 +1,30 @@
{
"name": "Галина",
"avatar": "/images/npc/bartender.jpg",
"name": "Искатель приключений",
"avatar": "/images/npc/adventurer.jpg",
"filename": "Adventurer.json",
"npc_id": 3,
"dialogue_key": "adventurer/advice",
"dialog": [
{
"id": 0,
"text": "Ну? Значит, ты к нам за работой припёрся? Ладно, давай документы...",
"text": "А, новое лицо! Ищешь работу? Я бы на твоем месте сначала поговорил со стражником у входа. Он всем новичкам дает наводки.",
"answers": [
{
"text": "Документов нет",
"next": 1
},
{
"text": "Я просто осматриваюсь...",
"next": 2
},
{
"text": "...",
"text": "Спасибо за совет",
"end": true
},
{
"text": "А что ты знаешь о работе?",
"next": 1
}
]
},
{
"id": 1,
"text": "Ах, у тебя их нет? Я бы удивилась другому развитию событий. Тогда придется заполнить их. Писать то хоть умеешь?",
"answers": [
{
"text": "Как-нибудь справлюсь",
"next": "form_data"
},
{
"text": "Может, есть работа без документов?",
"next": 3
}
]
},
{
"id": 2,
"text": "Осматриваешься? Ну-ну... Только не задерживайся слишком долго. В нашем городе без дела болтаться — себе дороже.",
"answers": [
{
"text": "А куда можно пойти?",
"next": 3
},
{
"text": "Ладно, тогда давай документы...",
"next": 1
}
]
},
{
"id": 3,
"text": "Слушай, есть один парень... сидит в кустах за углом. Зовут его Костя Ключник. Он как раз набирает людей для одной работы. Только не говори, что я тебя направила.",
"answers": [
{
"text": "Спасибо, попробую",
"next": 4
},
{
"text": "А что за работа?",
"next": 5
}
]
},
{
"id": 4,
"text": "Ну иди уже, не задерживайся. И смотри в оба — Костя любит шутки, но не над собой.",
"answers": [
{
"end": true
}
]
},
{
"id": 5,
"text": "Если бы я знала все детали, сама бы там работала. Иди сам узнаешь. Если, конечно, не струсишь.",
"answers": [
{
"text": "Ладно, иду",
"next": 4
},
{
"text": "Может, лучше заполню документы?",
"next": "form_data"
}
]
},
{
"id": "form_data",
"type": "form",
"title": "Заполнение анкеты",
"fields": [
{
"name": "skills",
"label": "Твои навыки и перки:",
"type": "text",
"placeholder": "Навыки через запятую",
"required": true
},
{
"name": "work_experience",
"label": "Предыдущие места работы:",
"type": "textarea",
"placeholder": "Где и кем работал",
"required": false
},
{
"name": "background",
"label": "Твое прошлое:",
"type": "textarea",
"placeholder": "Краткая информация о себе",
"required": false
}
],
"submit_text": "Отправить данные",
"next": 6
},
{
"id": 6,
"text": "Так... посмотрим что тут... (долго листает бумаги) Ну ты и говно, дружок. Ладно, есть для тебя один вариант.",
"answers": [
{
"text": "Какой?",
"next": 7
}
]
},
{
"id": 7,
"text": "Слушай сюда. Есть один парень... сидит в кустах за углом. Зовут его Костя Ключник. Он как раз набирает людей. Вот и вся вакансия.",
"answers": [
{
"text": "И это всё?",
"next": 8
},
{
"text": "Ладно, пойду",
"next": 4
}
]
},
{
"id": 8,
"text": "Да, это всё. Ты думал, у нас тут офис белых воротничков? Иди уже, не задерживайся.",
"text": "Я-то? Да много чего... Но сначала пройди базовый инструктаж. Без этого тебя никто серьезный не возьмет.",
"answers": [
{
"text": "Понял, спасибо",
"end": true
}
]

View File

@@ -1,167 +1,47 @@
{
"name": "Костя Ключник",
"avatar": "/images/npc/guard.jpg",
"name": "Пляжный персонаж",
"avatar": "/images/npc/beach_character.jpg",
"filename": "BeachCharacter.json",
"npc_id": 4,
"dialogue_key": "beachcharacter/meet",
"dialog": [
{
"id": 0,
"text": "(нервно озираясь по сторонам) Тссс... Ты кто такой? Мент? А? Нет? Ну ладно... (быстро чешет нос) Галина говорила? Ну эта... которая в баре...",
"text": "Эй, приятель! Ты выглядишь как человек, который ищет приключений. У меня есть одно дельце...",
"answers": [
{
"text": "Да, она направила",
"text": "Какое дело?",
"next": 1
},
{
"text": "Кто такая Галина?",
"next": 2
"text": "Извини, я спешу",
"end": true
}
]
},
{
"id": 1,
"text": "(хихикает) Ага, ну конечно направила... Она у нас вся такая... (внезапно серьёзнеет) Ладно, браток, работа есть. Но сначала... (ковыряет в носу) Ты че, стремаешься?",
"text": "Нужно кое-что доставить в старую часть города. Опасно, но платят хорошо. Заинтересован?",
"answers": [
{
"text": "Какая работа?",
"next": 3
"text": "Расскажи подробнее",
"next": 2
},
{
"text": "Ты че такой странный?",
"next": 4
"text": "Слишком рискованно",
"end": true
}
]
},
{
"id": 2,
"text": "(параноидально оглядывается) Ты че, прикалываешься? Галина! Ну... (делает жест рукой у рта) Которая... Ну в общем... (внезапно меняет тему) Ты хочешь заработать или нет?",
"answers": [
{
"text": "Хочу заработать",
"next": 3
},
{
"text": "Ты точно нормальный?",
"next": 4
}
]
},
{
"id": 3,
"text": "(потирает руки) Оооо, братан, работа огонь! Цифры взламывать будем! (внезапно замолкает, прислушивается) Ты слышал? Нет? Ну ладно... (быстро) Но сначала... (достаёт из кармана пакетик) Хочешь попробовать? Для смелости...",
"answers": [
{
"text": "Давай попробую",
"next": 5
},
{
"text": "Нет, я не употребляю",
"next": 6
}
]
},
{
"id": 4,
"text": "(нервно смеётся) Нормальный? Да я самый нормальный тут! (вдруг серьёзнеет) Вот только вчера... Нет, не буду рассказывать... (глаза бегают) Ты работу хочешь или нет?",
"answers": [
{
"text": "Хочу работу",
"next": 3
},
{
"text": "Ты пугаешь меня",
"next": 7
}
]
},
{
"id": 5,
"text": "(радостно) Ооо, наш человек! (суёт пакетик) На вот, только не всё сразу, а то... (делает широкий жест руками) Бах! И тебя нет! Ха-ха! Ладно, заходи завтра, когда... ну... разберёшься со своими делами. Я тебе всё расскажу.",
"answers": [
{
"text": "Ладно, приду завтра",
"next": 8,
"quest_start": "hack_job"
},
{
"text": "Может лучше прямо сейчас?",
"next": 9
}
]
},
{
"id": 6,
"text": "(разочарованно) Фу, скукота... (пожимает плечами) Ну ладно, работа всё равно есть. Заходи завтра, я тебе всё расскажу. Только... (понижает голос) Никому не говори, ладно?",
"answers": [
{
"text": "Хорошо, приду завтра",
"next": 8,
"quest_start": "hack_job"
},
{
"text": "А можно подробнее?",
"next": 10
}
]
},
{
"id": 7,
"text": "(внезапно злится) Пугаю? Да я тебя... (резко успокаивается) Ладно, братан, иди отсюда. Не для тебя эта работа. (начинает что-то бормотать себе под нос)",
"text": "Встреться с моим контактом у заброшенного дока. Скажешь, что от Кости. Деньги получишь по завершению.",
"answers": [
{
"text": "Договорились",
"end": true
}
]
},
{
"id": 8,
"text": "(кивает) Молодец. Запомни: подвал за углом, охраннику скажешь... (шёпотом) 'берёзовый сок'. Он тебя пропустит. И... (внезапно хватает за руку) Только никому, понял? Ни-ко-му!",
"answers": [
{
"end": true
}
]
},
{
"id": 9,
"text": "(панически) Сейчас? Нет, нет, нет! (осматривается) Слишком много... глаз. Завтра. Только завтра. (начинает быстро уходить)",
"answers": [
{
"end": true
}
]
},
{
"id": 10,
"text": "(нервно оглядывается) Подробнее? Ну... (понижает голос) Есть подвал. Там компы. Надо... ну... (делает движение пальцами как при печати) Взламывать. Охраннику скажешь 'берёзовый сок' - пропустит. Всё. Больше ничего не знаю. (начинает чесаться)",
"answers": [
{
"text": "Понятно, приду завтра",
"next": 8,
"quest_start": "hack_job"
},
{
"text": "Это незаконно!",
"next": 11
}
]
},
{
"id": 11,
"text": "(истерично смеётся) Законно? Ха! В этом городе? (внезапно серьёзнеет) Ладно, иди отсюда, мальчик. Ищи себе 'законную' работу. (поворачивается спиной)",
"answers": [
{
"end": true
}
]
},
{
"id": "hack_job",
"type": "quest",
"title": "Взлом в подвале",
"description": "Нужно проникнуть в подвал, сказав охраннику пароль 'берёзовый сок', и взломать данные",
"location": "/locations/basement.json",
"reward": "500 кредитов",
"next": 8
}
]
}

View File

@@ -2,29 +2,43 @@
"name": "Охранник",
"avatar": "/images/npc/guard.jpg",
"filename": "Oxranik.json",
"npc_id": 5,
"dialogue_key": "oxranik/report",
"dialog": [
{
"id": 0,
"text": "Стоять! Кто такой?",
"text": "Стоять! Что нужно?",
"answers": [
{
"text": "Березовый сок",
"next": 1,
"required_quest": "hack_job"
"text": "Я выполнил задания",
"next": 1
},
{
"text": "Я ошибся дверью",
"text": "Ничего, ошибся",
"end": true
}
]
},
{
"id": 1,
"text": "(кивает) Проходи. Но предупреждаю - если что-то пойдет не так, я тебя не знал.",
"text": "Так... Вижу, ты поработал. Неплохо для новичка. Держи награду и приходи за новыми заданиями завтра.",
"answers": [
{
"text": "Понял",
"quest_progress": "hack_job",
"text": "Спасибо",
"end": true
},
{
"text": "Что дальше?",
"next": 2
}
]
},
{
"id": 2,
"text": "Отдохни сегодня. Завтра будут новые поручения. Спроси у бармена - он знает.",
"answers": [
{
"text": "Понял, до завтра",
"end": true
}
]

View File

@@ -1,42 +1,26 @@
{
"name": "Серега Пират",
"name": "Бармен",
"avatar": "/images/npc/bartender.jpg",
"filename": "bartender.json",
"npc_id": 2,
"dialogue_key": "bartender/dialogue",
"dialog": [
{
"id": 0,
"text": "Ну что, дружок, застрял как муха в паутине? Или просто решил проверить, насколько крепки эти стены?",
"text": "Привет, новичок. Вижу, ты уже поговорил со стражником. Ну что, готов к настоящей работе?",
"answers": [
{
"text": "Я... кажется, ошибся дверью.",
"end": true
},
{
"text": "Мне сказали, тут можно «устроиться». От Галины.",
"next": 2
"text": "Расскажи, что есть",
"next": 1
}
]
},
{
"id": 2,
"text": "Ага, Галка-весточка. Слушай сюда: правила простые — садишься за комп, выполняешь два задания. Первое — по звукам угадаешь пароль. Второе — в финансовых документах найдёшь косяк. Справишься — получишь свои кровные. Не справишься... ну, сам понимаешь.",
"id": 1,
"text": "У меня есть пара контактов. Но сначала послушай того искателя приключений в углу - у него есть полезная информация для новичков.",
"answers": [
{
"text": "И где этот ваш комп?",
"next": 3
},
{
"text": "А сложно будет?",
"next": 3
}
]
},
{
"id": 3,
"text": "*кивает на потрёпанный системник в углу* Там всё включено. Разберёшься. Главное — уши навостри и глаза пошире открой. Как будешь готов — жми любую кнопку.",
"answers": [
{
"text": "*Подойти к компьютеру*",
"text": "Хорошо, поговорю с ним",
"end": true
}
]

View File

@@ -1,102 +1,26 @@
{
"name": "Саша Белый",
"name": "Стражник",
"avatar": "/images/npc/guard.jpg",
"filename": "guard.json",
"npc_id": 1,
"dialogue_key": "guard/intro",
"dialog": [
{
"id": 0,
"text": "А вот и новенький подъехал… Чё, глаза такие круглые? Добро пожаловать в Realternity Moscow City, братан. Тут не экскурсия, так что уши на макушке держи.",
"text": "Стой! Новенький? Добро пожаловать в наш город. Первое правило - хочешь выжить, ищи работу.",
"answers": [
{
"text": "Понял...",
"next": 1
},
{
"text": "Что это за место?",
"text": "Где можно найти работу?",
"next": 1
}
]
},
{
"id": 1,
"text": "Город у нас большой, светится неоном, как новогодняя ёлка, но под этой мишурой — помойка, крысы да волки. Вверх глянешь — небоскрёбы корпораций, вниз — подземка, где людей за карточку еды режут. Каждый тут сам за себя.",
"text": "Начни с таверны. Бармен всегда в курсе, кому нужны руки. Иди к нему.",
"answers": [
{
"text": "И что делать?",
"next": 2
},
{
"text": "Звучит жутко...",
"next": 2
}
]
},
{
"id": 2,
"text": "Первое правило — хочешь жить, ищи работу. Деньги — это воздух. Без них ты тут не человек, а мусор под ногами.",
"answers": [
{
"text": "А если работы нет?",
"next": 3
},
{
"text": "Где искать?",
"next": 4
}
]
},
{
"id": 3,
"text": "Нет работы? Ну, значит, ищешь... альтернативы. Тут таких путей — как тараканов в общаге. Главное — не ной, действуй.",
"answers": [
{
"text": "Какие альтернативы?",
"next": 5
},
{
"text": "Понятно...",
"next": 6
}
]
},
{
"id": 4,
"text": "Короче, если совсем не врубаешься, топай в Центр Трудоустройства «Нижний Эшелон». Там помогут... ну, если не кинут. Смотри в оба, братан.",
"answers": [
{
"text": "Спасибо за совет",
"next": 6
},
{
"text": "А где это?",
"next": 6
}
]
},
{
"id": 5,
"text": "Честный ты, нечестный — неважно. Главное — чтоб ты выбрал, по какой дорожке топать. По свету, по тени… или, может, будешь тем, кто идёт посередине и стреляет в обе стороны. Тут за всё платят — вопрос только, чем.",
"answers": [
{
"text": "Ясно...",
"next": 6
},
{
"text": "Страшновато",
"next": 6
}
]
},
{
"id": 6,
"text": "И запомни: здесь не детский сад. Никто за ручку водить не будет. Или ты адаптируешься… или сгниёшь на подворотне. Всё просто.",
"answers": [
{
"text": "Понял, спасибо",
"end": true
},
{
"text": "До встречи",
"text": "Спасибо, пойду в таверну",
"end": true
}
]

480
server.js
View File

@@ -33,7 +33,7 @@ catch (e) {
console.error('Ошибка при импорте db1 - virtual_World:', e);
throw e;
}
/*
try {
new_quest_Base = require('./db2');
console.log('db2 - new_quest_Base - успешно импортирован');
@@ -42,7 +42,7 @@ catch (e) {
console.error('Ошибка при импорте db2 - new_quest_Base: ', e);
throw e;
}
*/
try {
db = require('./db');
console.log('db успешно импортирован');
@@ -1379,6 +1379,482 @@ function generateTransactions() {
return transactions;
}
// Начало кода для квестов
// Маршрут для получения информации о квестах игрока
app.get('/api/quests/player-status', authenticate, async (req, res) => {
try {
const userId = req.user.id;
console.log(`[QUESTS] Запрос статуса квестов для пользователя ${userId}`);
// Получаем уровень игрока (по умолчанию 1)
const playerLevel = 1; // Пока используем фиксированный уровень
// Получаем все активные квесты с их статусами для игрока
const questsData = await getPlayerQuestsData(userId, playerLevel);
console.log(`[QUESTS] Возвращаем данные для пользователя ${userId}: ${questsData.length} квестов`);
res.json({
success: true,
playerLevel: playerLevel,
quests: questsData
});
} catch (error) {
console.error('Ошибка получения статуса квестов игрока:', error);
res.status(500).json({
success: false,
error: 'Ошибка получения данных о квестах'
});
}
});
// Вспомогательная функция для получения уровня игрока
async function getPlayerLevel(userId) {
try {
// Пока используем уровень по умолчанию 1
// В будущем можно добавить логику расчета уровня на основе опыта
return 1;
} catch (error) {
console.error('Ошибка получения уровня игрока:', error);
return 1;
}
}
// Основная функция для получения данных о квестах
async function getPlayerQuestsData(userId, playerLevel) {
try {
// Получаем все доступные квесты
const availableQuests = await new_quest_Base.query(`
SELECT q.id, q.title, q.description, q.kind, q.is_active, q.metadata,
pq.status as player_status, pq.current_step_id,
pq.started_at, pq.completed_at
FROM quests q
LEFT JOIN player_quests pq ON q.id = pq.quest_id AND pq.player_id = $1
WHERE q.is_active = true
ORDER BY q.id
`, [userId]);
const questsData = [];
for (const quest of availableQuests.rows) {
// Проверяем условия доступа к квесту
const hasAccess = await checkQuestAccess(quest.id, playerLevel, userId);
// Получаем информацию о текущем шаге
const currentStepInfo = await getCurrentStepInfo(quest.id, quest.current_step_id, userId);
// Получаем все шаги квеста
const questSteps = await new_quest_Base.query(`
SELECT qs.id, qs.step_index, qs.title, qs.description,
qs.action_type, qs.action_payload, qs.dialogue_scene,
qs.is_optional, ps.status as player_step_status,
ps.started_at as step_started, ps.completed_at as step_completed
FROM quest_steps qs
LEFT JOIN player_steps ps ON qs.id = ps.quest_step_id AND ps.player_id = $1
WHERE qs.quest_id = $2
ORDER BY qs.step_index
`, [userId, quest.id]);
// Определяем статус квеста для игрока
let questStatus = 'available'; // доступен
if (quest.player_status === 'completed') {
questStatus = 'completed';
} else if (quest.player_status === 'in_progress') {
questStatus = 'in_progress';
} else if (!hasAccess) {
questStatus = 'locked';
}
questsData.push({
id: quest.id,
title: quest.title,
description: quest.description,
kind: quest.kind,
status: questStatus,
hasAccess: hasAccess,
currentStep: currentStepInfo,
steps: questSteps.rows.map(step => ({
id: step.id,
stepIndex: step.step_index,
title: step.title,
description: step.description,
actionType: step.action_type,
actionPayload: step.action_payload,
dialogueScene: step.dialogue_scene,
isOptional: step.is_optional,
playerStatus: step.player_step_status || 'not_started',
startedAt: step.step_started,
completedAt: step.step_completed
})),
metadata: quest.metadata,
startedAt: quest.started_at,
completedAt: quest.completed_at
});
}
return questsData;
} catch (error) {
console.error('Ошибка получения данных квестов:', error);
throw error;
}
}
// Функция проверки доступа к квесту
async function checkQuestAccess(questId, playerLevel, userId) {
try {
console.log('Сосал');
// Проверяем группы условий
const prerequisiteGroups = await new_quest_Base.query(`
SELECT qpg.id, qpg.group_index
FROM quest_prereq_groups qpg
WHERE qpg.quest_id = $1
ORDER BY qpg.group_index
`, [questId]);
// Если нет групп условий - квест доступен
if (prerequisiteGroups.rows.length === 0) {
return true;
}
// Проверяем каждую группу условий
for (const group of prerequisiteGroups.rows) {
const groupConditions = await new_quest_Base.query(`
SELECT qpc.condition_type, qpc.condition_payload
FROM quest_prereq_conditions qpc
WHERE qpc.group_id = $1
`, [group.id]);
let allConditionsMet = true;
for (const condition of groupConditions.rows) {
const conditionMet = await checkCondition(
condition.condition_type,
condition.condition_payload,
playerLevel,
userId
);
if (!conditionMet) {
allConditionsMet = false;
break;
}
}
// Если хотя бы одна группа условий выполнена - квест доступен
if (allConditionsMet) {
return true;
}
}
return false;
} catch (error) {
console.error('Ошибка проверки доступа к квесту:', error);
return false;
}
}
// Функция проверки конкретного условия
async function checkCondition(conditionType, conditionPayload, playerLevel, userId) {
try {
const payload = typeof conditionPayload === 'string'
? JSON.parse(conditionPayload)
: conditionPayload;
switch (conditionType) {
case 'level_ge':
return playerLevel >= payload.level;
case 'quest_completed':
// Проверяем завершенность другого квеста
const questCheck = await new_quest_Base.query(`
SELECT 1 FROM player_quests
WHERE player_id = $1 AND quest_id = $2 AND status = 'completed'
`, [userId, payload.quest_id]);
return questCheck.rows.length > 0;
case 'step_completed':
// Проверяем завершенность шага
const stepCheck = await new_quest_Base.query(`
SELECT 1 FROM player_steps
WHERE player_id = $1 AND quest_step_id = $2 AND status = 'completed'
`, [userId, payload.step_id]);
return stepCheck.rows.length > 0;
default:
console.warn(`Неизвестный тип условия: ${conditionType}`);
return false;
}
} catch (error) {
console.error('Ошибка проверки условия:', error);
return false;
}
}
// Функция получения информации о текущем шаге
async function getCurrentStepInfo(questId, currentStepId, userId) {
if (!currentStepId) {
// Если текущего шага нет, возвращаем первый шаг квеста
const firstStep = await new_quest_Base.query(`
SELECT id, step_index, title, description
FROM quest_steps
WHERE quest_id = $1
ORDER BY step_index ASC
LIMIT 1
`, [questId]);
return firstStep.rows.length > 0 ? {
id: firstStep.rows[0].id,
stepIndex: firstStep.rows[0].step_index,
title: firstStep.rows[0].title,
description: firstStep.rows[0].description
} : null;
}
// Получаем информацию о текущем шаге
const currentStep = await new_quest_Base.query(`
SELECT qs.id, qs.step_index, qs.title, qs.description,
ps.status as player_status
FROM quest_steps qs
LEFT JOIN player_steps ps ON qs.id = ps.quest_step_id AND ps.player_id = $1
WHERE qs.id = $2
`, [userId, currentStepId]);
return currentStep.rows.length > 0 ? {
id: currentStep.rows[0].id,
stepIndex: currentStep.rows[0].step_index,
title: currentStep.rows[0].title,
description: currentStep.rows[0].description,
playerStatus: currentStep.rows[0].player_status
} : null;
}
// Маршрут для старта квеста
app.post('/api/quests/:questId/start', authenticate, async (req, res) => {
try {
const userId = req.user.id;
const questId = parseInt(req.params.questId);
const playerLevel = await getPlayerLevel(userId);
// Проверяем доступность квеста
const hasAccess = await checkQuestAccess(questId, playerLevel, userId);
if (!hasAccess) {
return res.status(403).json({
success: false,
error: 'Квест недоступен'
});
}
// Проверяем, не начат ли уже квест
const existingQuest = await new_quest_Base.query(`
SELECT id FROM player_quests
WHERE player_id = $1 AND quest_id = $2
`, [userId, questId]);
if (existingQuest.rows.length > 0) {
return res.status(400).json({
success: false,
error: 'Квест уже начат'
});
}
// Получаем первый шаг квеста
const firstStep = await new_quest_Base.query(`
SELECT id FROM quest_steps
WHERE quest_id = $1
ORDER BY step_index ASC
LIMIT 1
`, [questId]);
if (firstStep.rows.length === 0) {
return res.status(400).json({
success: false,
error: 'Квест не имеет шагов'
});
}
// Начинаем квест
await new_quest_Base.query(`
INSERT INTO player_quests
(player_id, quest_id, current_step_id, status, started_at, last_updated_at)
VALUES ($1, $2, $3, 'in_progress', NOW(), NOW())
`, [userId, questId, firstStep.rows[0].id]);
// Записываем первый шаг
await new_quest_Base.query(`
INSERT INTO player_steps
(player_id, quest_step_id, status, started_at)
VALUES ($1, $2, 'in_progress', NOW())
`, [userId, firstStep.rows[0].id]);
res.json({
success: true,
message: 'Квест начат',
currentStepId: firstStep.rows[0].id
});
} catch (error) {
console.error('Ошибка старта квеста:', error);
res.status(500).json({
success: false,
error: 'Ошибка начала квеста'
});
}
});
// Маршрут для отметки прослушанного диалога
app.post('/api/quests/mark-dialog-listened', authenticate, async (req, res) => {
try {
const userId = req.user.id;
const { npc_id, dialogue_key } = req.body;
console.log(`[QUESTS] Отметка прослушанного диалога: user=${userId}, npc=${npc_id}, dialogue=${dialogue_key}`);
if (!npc_id || !dialogue_key) {
return res.status(400).json({
success: false,
error: 'Не указаны npc_id или dialogue_key'
});
}
// 1. Записываем взаимодействие в player_npc_interactions
const interactionResult = await new_quest_Base.query(`
INSERT INTO player_npc_interactions
(player_id, npc_id, action_type, action_payload, created_at)
VALUES ($1, $2, 'listen_npc', $3, NOW())
RETURNING id
`, [userId, npc_id, JSON.stringify({ dialogue_key: dialogue_key })]);
console.log(`[QUESTS] Взаимодействие записано с ID: ${interactionResult.rows[0].id}`);
// 2. Проверяем, относится ли это взаимодействие к активному шагу квеста
const activeQuestStep = await new_quest_Base.query(`
SELECT pq.id as player_quest_id, pq.current_step_id, qs.id as step_id, qs.action_payload
FROM player_quests pq
JOIN quest_steps qs ON pq.current_step_id = qs.id
WHERE pq.player_id = $1 AND pq.status = 'in_progress'
AND qs.action_type = 'talk_npc'
`, [userId]);
if (activeQuestStep.rows.length > 0) {
const step = activeQuestStep.rows[0];
const actionPayload = typeof step.action_payload === 'string'
? JSON.parse(step.action_payload)
: step.action_payload;
// Проверяем, соответствует ли NPC текущему шагу квеста
if (actionPayload.npc_id == npc_id) {
console.log(`[QUESTS] Диалог соответствует активному шагу квеста: step_id=${step.step_id}`);
// 3. Проверяем требования для завершения шага
const stepRequirements = await new_quest_Base.query(`
SELECT requirement_type, requirement_payload
FROM step_requirements
WHERE quest_step_id = $1
ORDER BY ord
`, [step.step_id]);
let stepCompleted = true;
for (const requirement of stepRequirements.rows) {
const payload = typeof requirement.requirement_payload === 'string'
? JSON.parse(requirement.requirement_payload)
: requirement.requirement_payload;
if (requirement.requirement_type === 'listen_npc') {
// Проверяем, прослушал ли игрок нужное количество диалогов с указанными NPC
const listenCount = await new_quest_Base.query(`
SELECT COUNT(*) as count
FROM player_npc_interactions
WHERE player_id = $1
AND npc_id = ANY($2::int[])
AND action_type = 'listen_npc'
`, [userId, payload.npc_ids]);
if (listenCount.rows[0].count < payload.count) {
stepCompleted = false;
break;
}
}
}
if (stepCompleted) {
// 4. Отмечаем шаг как завершенный
await new_quest_Base.query(`
UPDATE player_steps
SET status = 'completed', completed_at = NOW()
WHERE player_id = $1 AND quest_step_id = $2
`, [userId, step.step_id]);
// 5. Находим следующий шаг
const nextStep = await new_quest_Base.query(`
SELECT qs.id, qs.step_index
FROM quest_steps qs
WHERE qs.quest_id = (
SELECT quest_id FROM quest_steps WHERE id = $1
)
AND qs.step_index > (
SELECT step_index FROM quest_steps WHERE id = $1
)
ORDER BY qs.step_index ASC
LIMIT 1
`, [step.step_id]);
if (nextStep.rows.length > 0) {
// 6. Обновляем текущий шаг в квесте
await new_quest_Base.query(`
UPDATE player_quests
SET current_step_id = $1, last_updated_at = NOW()
WHERE player_id = $2 AND id = $3
`, [nextStep.rows[0].id, userId, step.player_quest_id]);
// 7. Начинаем следующий шаг
await new_quest_Base.query(`
INSERT INTO player_steps
(player_id, quest_step_id, status, started_at)
VALUES ($1, $2, 'in_progress', NOW())
ON CONFLICT (player_id, quest_step_id)
DO UPDATE SET status = 'in_progress', started_at = NOW()
`, [userId, nextStep.rows[0].id]);
console.log(`[QUESTS] Шаг завершен, переход к шагу ${nextStep.rows[0].step_index}`);
} else {
// 8. Если это последний шаг - завершаем квест
await new_quest_Base.query(`
UPDATE player_quests
SET status = 'completed', completed_at = NOW(), last_updated_at = NOW()
WHERE player_id = $1 AND id = $2
`, [userId, step.player_quest_id]);
console.log(`[QUESTS] Квест завершен!`);
}
}
}
}
res.json({
success: true,
message: 'Диалог отмечен как прослушанный'
});
} catch (error) {
console.error('Ошибка при отметке прослушанного диалога:', error);
res.status(500).json({
success: false,
error: 'Ошибка сервера при обработке диалога'
});
}
});
//Конец копи
//Начало копи
app.get('/api/quests/progress', authenticate, async (req, res) => {

View File

@@ -16,9 +16,10 @@ import OrgControlPanel from './components/OrgControlPanel';
import DoubleTapWrapper from './pages/DoubleTapWrapper';
import WaveformPlayer from './pages/WaveformPlayer';
import { getUsersStatus, loadUserInfo } from './api/auth.js';
import QuestSystem from './pages/QuestSystem ';
function Game({ avatarUrl, gender }) {
const [showQuests, setShowQuests] = useState(false);
// 1) реф для хранилища сцены
const sceneRef = useRef(new THREE.Scene());
@@ -219,9 +220,9 @@ function Game({ avatarUrl, gender }) {
setTgLoading(true);
loadTelegramContacts().finally(() => setTgLoading(false));
}
if (appName === "Chrome") {
loadQuestsProgress();
}
//if (appName === "Chrome") {
// loadQuestsProgress();
//}
if (appName === "Settings") {
setShowMiniGame(true);
}
@@ -6055,7 +6056,25 @@ function Game({ avatarUrl, gender }) {
</div>
))}
</div>
<button
style={{
position: 'absolute',
top: 20,
right: 250, // Измените позицию по необходимости
zIndex: 1000,
padding: '10px 18px',
background: '#8B4513',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '18px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}
onClick={() => setShowQuests(true)}
>
Квесты
</button>
<div style={{ position: 'absolute', top: 80, left: 20, zIndex: 1000, background: 'rgba(0,0,0,0.6)', color: '#fff', padding: '4px 8px', borderRadius: 4 }}>
Баланс: {balance}
</div>
@@ -6235,7 +6254,9 @@ function Game({ avatarUrl, gender }) {
</div>
</div>
)}
{showQuests && (
<QuestSystem onClose={() => setShowQuests(false)} />
)}
{selectedHouse && (
<div style={{
position: 'absolute',

View File

@@ -8,32 +8,29 @@ export const useDialogManager = () => {
const [currentForm, setCurrentForm] = useState(null);
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
const markDialogAsListened = async (jsonFilename) => {
const markDialogAsListened = async (npcId, dialogueKey) => {
try {
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD>
const filename = jsonFilename.split('/').pop().split('\\').pop();
console.log('Normalized filename:', filename);
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD>111<31>");
const token = localStorage.getItem('token');
const response = await fetch('/api/listen', {
const response = await fetch('/api/quests/mark-dialog-listened', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
// player_id больше не обязателен: сервер возьмёт его из токена/сессии при наличии
json_filename: filename
npc_id: npcId,
dialogue_key: dialogueKey
})
});
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD><EFBFBD>3455654");
if (!response.ok) {
const txt = await response.text().catch(()=> '');
console.error('Ошибка при записи прослушанного:', response.status, txt);
const txt = await response.text().catch(() => '');
console.error('Ошибка при записи прослушанного диалога:', response.status, txt);
} else {
console.log('Диалог успешно отмечен как прослушанный');
}
} catch (error) {
console.error('Ошибка сети при записи прослушанного:', error);
console.error('Ошибка сети при записи прослушанного диалога:', error);
}
};
@@ -44,21 +41,32 @@ export const useDialogManager = () => {
setCurrentDialog(data);
setDialogIndex(0);
setShowDialog(true);
// Получаем dialogue_key из JSON или используем npcId как fallback
const dialogueKey = data.dialogue_key || npcId;
// Записываем начало прослушивания диалога
await markDialogAsListened(npcId, dialogueKey);
} catch (error) {
console.error('<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>:', error);
console.error('Ошибка загрузки диалога:', error);
}
};
const handleAnswerSelect = async (answer) => {
console.log('[Debug] Answer object:', answer); // <- <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>?
console.log('[Debug] "end" in answer:', 'end' in answer); // <- <20><><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD> end?
console.log('[Debug] Answer object:', answer);
if (answer.end !== undefined) {
console.log('[Debug] Dialog end triggered!');
// <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
if (currentDialog?.filename) {
await markDialogAsListened(currentDialog.filename);
console.log("<22><><EFBFBD><EFBFBD><EFBFBD> <20> <20><> <20><><EFBFBD><EFBFBD>");
// При завершении диалога записываем финальное взаимодействие
if (currentDialog) {
const npcId = currentDialog.npc_id;
const dialogueKey = currentDialog.dialogue_key || currentDialog.filename?.replace('.json', '');
if (npcId && dialogueKey) {
await markDialogAsListened(npcId, dialogueKey);
}
}
setShowDialog(false);
} else if (answer.next !== undefined) {
if (typeof answer.next === 'string' && answer.next.startsWith('form_')) {

454
src/pages/QuestSystem .jsx Normal file
View File

@@ -0,0 +1,454 @@
import React, { useState, useEffect } from 'react';
const QuestSystem = ({ onClose }) => {
const [quests, setQuests] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedQuest, setSelectedQuest] = useState(null);
const [activeTab, setActiveTab] = useState('available');
// Загрузка квестов при монтировании компонента
useEffect(() => {
loadQuests();
}, []);
// Функция загрузки квестов с сервера
const loadQuests = async () => {
try {
setLoading(true);
const token = localStorage.getItem('token');
const response = await fetch('/api/quests/player-status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Ошибка загрузки: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setQuests(data.quests || []);
} else {
throw new Error(data.error || 'Неизвестная ошибка сервера');
}
} catch (err) {
console.error('Ошибка загрузки квестов:', err);
setError(err.message);
} finally {
setLoading(false);
}
};
// Функция начала квеста
const startQuest = async (questId) => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/quests/${questId}/start`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Ошибка начала квеста: ${response.status}`);
}
const data = await response.json();
if (data.success) {
await loadQuests();
} else {
throw new Error(data.error || 'Неизвестная ошибка сервера');
}
} catch (err) {
console.error('Ошибка начала квеста:', err);
alert(`Ошибка начала квеста: ${err.message}`);
}
};
// Фильтрация квестов по статусу
const filteredQuests = quests.filter(quest => {
switch (activeTab) {
case 'available':
return quest.status === 'available' && quest.hasAccess;
case 'in_progress':
return quest.status === 'in_progress';
case 'completed':
return quest.status === 'completed';
case 'locked':
return quest.status === 'locked' || !quest.hasAccess;
default:
return true;
}
});
// Стили компонента
const styles = {
container: {
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '80%',
maxWidth: '800px',
maxHeight: '80vh',
backgroundColor: 'rgba(0, 0, 0, 0.95)',
border: '2px solid #444',
borderRadius: '12px',
padding: '20px',
zIndex: 10000,
color: 'white',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
},
header: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
borderBottom: '1px solid #444',
paddingBottom: '10px'
},
closeButton: {
background: 'transparent',
border: 'none',
color: 'white',
fontSize: '24px',
cursor: 'pointer',
padding: '5px 10px'
},
tabs: {
display: 'flex',
marginBottom: '20px',
borderBottom: '1px solid #444'
},
tab: {
padding: '10px 20px',
cursor: 'pointer',
border: 'none',
background: 'transparent',
color: '#aaa',
borderBottom: '2px solid transparent',
transition: 'all 0.3s'
},
activeTab: {
color: 'white',
borderBottom: '2px solid #4CAF50'
},
questList: {
flex: 1,
overflowY: 'auto',
paddingRight: '10px'
},
questItem: {
background: 'rgba(50, 50, 50, 0.7)',
borderRadius: '8px',
padding: '15px',
marginBottom: '10px',
border: '1px solid #444',
cursor: 'pointer',
transition: 'all 0.3s'
},
questHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '10px'
},
questTitle: {
fontSize: '18px',
fontWeight: 'bold',
margin: 0
},
questStatus: {
padding: '3px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold'
},
statusAvailable: {
backgroundColor: '#4CAF50',
color: 'white'
},
statusInProgress: {
backgroundColor: '#2196F3',
color: 'white'
},
statusCompleted: {
backgroundColor: '#9C27B0',
color: 'white'
},
statusLocked: {
backgroundColor: '#757575',
color: 'white'
},
questDescription: {
margin: '10px 0',
color: '#ccc',
fontSize: '14px'
},
questActions: {
display: 'flex',
gap: '10px',
marginTop: '10px'
},
button: {
padding: '8px 16px',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
transition: 'all 0.3s'
},
startButton: {
backgroundColor: '#4CAF50',
color: 'white'
},
viewButton: {
backgroundColor: '#2196F3',
color: 'white'
},
disabledButton: {
backgroundColor: '#757575',
color: '#aaa',
cursor: 'not-allowed'
},
questDetail: {
marginTop: '20px',
padding: '15px',
background: 'rgba(40, 40, 40, 0.8)',
borderRadius: '8px',
border: '1px solid #444'
},
stepList: {
marginTop: '15px'
},
stepItem: {
padding: '10px',
marginBottom: '5px',
background: 'rgba(60, 60, 60, 0.6)',
borderRadius: '4px',
borderLeft: '3px solid #444'
},
currentStep: {
borderLeft: '3px solid #4CAF50',
background: 'rgba(76, 175, 80, 0.1)'
},
completedStep: {
borderLeft: '3px solid #9C27B0',
background: 'rgba(156, 39, 176, 0.1)'
},
loading: {
textAlign: 'center',
padding: '20px',
color: '#aaa'
},
error: {
textAlign: 'center',
padding: '20px',
color: '#f44336'
}
};
// Функция для получения стиля статуса
const getStatusStyle = (status) => {
switch (status) {
case 'available': return { ...styles.questStatus, ...styles.statusAvailable };
case 'in_progress': return { ...styles.questStatus, ...styles.statusInProgress };
case 'completed': return { ...styles.questStatus, ...styles.statusCompleted };
case 'locked': return { ...styles.questStatus, ...styles.statusLocked };
default: return styles.questStatus;
}
};
// Функция для получения текста статуса
const getStatusText = (status) => {
switch (status) {
case 'available': return 'Доступен';
case 'in_progress': return 'В процессе';
case 'completed': return 'Завершен';
case 'locked': return 'Заблокирован';
default: return status;
}
};
return (
<div style={styles.container}>
{/* ЗДЕСЬ КНОПКА ЗАКРЫТИЯ - внутри header */}
<div style={styles.header}>
<h2 style={{ margin: 0 }}>Система квестов</h2>
<button
style={styles.closeButton}
onClick={onClose}
>
</button>
</div>
<div style={styles.tabs}>
<button
style={activeTab === 'available' ? { ...styles.tab, ...styles.activeTab } : styles.tab}
onClick={() => setActiveTab('available')}
>
Доступные
</button>
<button
style={activeTab === 'in_progress' ? { ...styles.tab, ...styles.activeTab } : styles.tab}
onClick={() => setActiveTab('in_progress')}
>
В процессе
</button>
<button
style={activeTab === 'completed' ? { ...styles.tab, ...styles.activeTab } : styles.tab}
onClick={() => setActiveTab('completed')}
>
Завершенные
</button>
<button
style={activeTab === 'locked' ? { ...styles.tab, ...styles.activeTab } : styles.tab}
onClick={() => setActiveTab('locked')}
>
Заблокированные
</button>
</div>
<div style={styles.questList}>
{loading ? (
<div style={styles.loading}>Загрузка квестов...</div>
) : error ? (
<div style={styles.error}>
<p>Ошибка загрузки квестов: {error}</p>
<button
style={{ ...styles.button, ...styles.viewButton }}
onClick={loadQuests}
>
Попробовать снова
</button>
</div>
) : filteredQuests.length === 0 ? (
<div style={styles.loading}>
{activeTab === 'available' && 'Нет доступных квестов'}
{activeTab === 'in_progress' && 'Нет активных квестов'}
{activeTab === 'completed' && 'Нет завершенных квестов'}
{activeTab === 'locked' && 'Нет заблокированных квестов'}
</div>
) : (
filteredQuests.map(quest => (
<div
key={quest.id}
style={styles.questItem}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(70, 70, 70, 0.7)';
e.currentTarget.style.borderColor = '#666';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = styles.questItem.background;
e.currentTarget.style.borderColor = styles.questItem.borderColor;
}}
onClick={() => setSelectedQuest(selectedQuest?.id === quest.id ? null : quest)}
>
<div style={styles.questHeader}>
<h3 style={styles.questTitle}>{quest.title}</h3>
<span style={getStatusStyle(quest.status)}>
{getStatusText(quest.status)}
</span>
</div>
<p style={styles.questDescription}>{quest.description}</p>
{quest.currentStep && quest.status === 'in_progress' && (
<div style={{ margin: '10px 0', padding: '8px', background: 'rgba(33, 150, 243, 0.1)', borderRadius: '4px' }}>
<strong>Текущий шаг:</strong> {quest.currentStep.title}
</div>
)}
<div style={styles.questActions}>
{quest.status === 'available' && quest.hasAccess && (
<button
style={{ ...styles.button, ...styles.startButton }}
onClick={(e) => {
e.stopPropagation();
startQuest(quest.id);
}}
>
Начать квест
</button>
)}
{(quest.status === 'in_progress' || quest.status === 'completed') && (
<button
style={{ ...styles.button, ...styles.viewButton }}
onClick={(e) => {
e.stopPropagation();
setSelectedQuest(selectedQuest?.id === quest.id ? null : quest);
}}
>
{selectedQuest?.id === quest.id ? 'Скрыть детали' : 'Показать детали'}
</button>
)}
{quest.status === 'locked' && (
<button
style={{ ...styles.button, ...styles.disabledButton }}
disabled
>
Недоступно
</button>
)}
</div>
{selectedQuest?.id === quest.id && (
<div style={styles.questDetail}>
<h4>Шаги квеста:</h4>
<div style={styles.stepList}>
{quest.steps.map(step => {
let stepStyle = styles.stepItem;
if (step.id === quest.currentStep?.id) {
stepStyle = { ...styles.stepItem, ...styles.currentStep };
} else if (step.playerStatus === 'completed') {
stepStyle = { ...styles.stepItem, ...styles.completedStep };
}
return (
<div key={step.id} style={stepStyle}>
<div style={{ fontWeight: 'bold' }}>
Шаг {step.stepIndex + 1}: {step.title}
{step.isOptional && ' (Опциональный)'}
</div>
<div style={{ marginTop: '5px', fontSize: '14px' }}>
{step.description}
</div>
{step.playerStatus && (
<div style={{
fontSize: '12px',
marginTop: '5px',
color: step.playerStatus === 'completed' ? '#4CAF50' : '#2196F3'
}}>
Статус: {step.playerStatus === 'completed' ? 'Завершен' : 'В процессе'}
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
))
)}
</div>
</div>
);
};
export default QuestSystem;