Added CombatService, Quests. Fixed CraftModal.

This commit is contained in:
MaksSlyzar 2026-04-21 08:48:52 +03:00
parent b3c2f31dcf
commit 9e0e7b5324
30 changed files with 2094 additions and 1582 deletions

View File

@ -6,6 +6,7 @@ class GameDataManager {
this.dungeons = new Map(); this.dungeons = new Map();
this.hostiles = new Map(); this.hostiles = new Map();
this.rooms = new Map(); this.rooms = new Map();
this.quests = new Map();
this.translations = {}; this.translations = {};
this.manifest = {}; this.manifest = {};
this.currentLang = localStorage.getItem("selected_lang") || "en_US"; this.currentLang = localStorage.getItem("selected_lang") || "en_US";
@ -31,10 +32,14 @@ class GameDataManager {
if (Array.isArray(data.rooms)) { if (Array.isArray(data.rooms)) {
data.rooms.forEach((r) => this.rooms.set(r.id, r)); data.rooms.forEach((r) => this.rooms.set(r.id, r));
} }
if (Array.isArray(data.quests)) {
data.quests.forEach((q) => this.quests.set(q.id, q));
}
console.log(this.quests);
if (data.languages) { if (data.languages) {
this.translations = data.languages; this.translations = data.languages;
} }
if (data.manifest) { if (data.manifest) {
this.manifest = data.manifest; this.manifest = data.manifest;
} }
@ -191,6 +196,24 @@ class GameDataManager {
}; };
} }
getQuest(id) {
const quest = this.quests.get(id);
if (!quest) return null;
return {
...quest,
displayName: this.t(quest.displayName),
description: this.t(quest.description),
objectives: (quest.objectives || []).map((obj) => ({
...obj,
description: obj.description ? this.t(obj.description) : "",
})),
};
}
getAllQuests() {
return Array.from(this.quests.values()).map((q) => this.getQuest(q.id));
}
setLanguage(langCode) { setLanguage(langCode) {
if (this.translations[langCode]) { if (this.translations[langCode]) {
this.currentLang = langCode; this.currentLang = langCode;

File diff suppressed because it is too large Load Diff

View File

@ -5,68 +5,89 @@ import DungeonFinish from "../tabs/components/DungeonFinish.jsx";
const DungeonScreen = ({ session, socket }) => { const DungeonScreen = ({ session, socket }) => {
const [roomData, setRoomData] = useState(session.room); const [roomData, setRoomData] = useState(session.room);
const [hostiles, setHostiles] = useState(session.hostiles || []);
const [roomIndex, setRoomIndex] = useState(session.roomIndex); const [roomIndex, setRoomIndex] = useState(session.roomIndex);
const [enemyHp, setEnemyHp] = useState(null); const [battle, setBattle] = useState(session.battle || null);
const [isEnemyDefeated, setIsEnemyDefeated] = useState(false); const [timeLeft, setTimeLeft] = useState(10);
const [isLooted, setIsLooted] = useState(false);
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [activeAttacker, setActiveAttacker] = useState(null);
const [selectedTarget, setSelectedTarget] = useState(null);
const [log, setLog] = useState([ const [log, setLog] = useState([
"SYSTEM: Neural link established. Scanning sector...", "SYSTEM: Neural link established. Scanning sector...",
]); ]);
const logEndRef = useRef(null); const logEndRef = useRef(null);
const timerRef = useRef(null);
const currentEnemy = hostiles.length > 0 ? hostiles[0] : null;
const dungeonData = GameDataManager.getDungeon(session.dungeonId); const dungeonData = GameDataManager.getDungeon(session.dungeonId);
const getEnemyDisplayName = (enemy) => {
if (!enemy) return "UNKNOWN_ENTITY";
const data = GameDataManager.getEnemy(enemy.id);
return data?.displayName || enemy.displayName || enemy.id;
};
const getRoomDisplayName = (room) => {
if (!room) return "UNKNOWN_LOCATION";
const data = GameDataManager.getRoom(room.id);
return data?.displayName || room.displayName || room.id;
};
useEffect(() => { useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" }); logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [log]); }, [log]);
useEffect(() => {
setSelectedTarget(null);
}, [battle?.currentTurnIndex]);
useEffect(() => {
if (!battle || battle.isOver || activeAttacker) return;
const isPlayer = battle.turnOrder[battle.currentTurnIndex] === "player";
const maxTime = isPlayer ? 10 : 4;
setTimeLeft(maxTime);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
if (isPlayer) handleCombatAction();
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timerRef.current);
}, [battle?.currentTurnIndex, battle?.isOver, activeAttacker]);
useEffect(() => { useEffect(() => {
socket.on("dungeon:room_update", (data) => { socket.on("dungeon:room_update", (data) => {
setRoomData(data.room); setRoomData(data.room);
setHostiles(data.hostiles || []);
setRoomIndex(data.roomIndex); setRoomIndex(data.roomIndex);
setIsEnemyDefeated(false); setBattle(data.battle);
setIsLooted(false);
setEnemyHp(null);
addLog(`--- ENTERING SECTOR ${data.roomIndex + 1} ---`); addLog(`--- ENTERING SECTOR ${data.roomIndex + 1} ---`);
}); });
socket.on("dungeon:combat_result", (data) => { socket.on("dungeon:failed", (data) => {
if (data.message) addLog(data.message); addLog(`--- TERMINAL ERROR: ${data.message} ---`);
setTimeout(() => window.location.reload(), 3000);
if (data.enemyHp !== undefined) {
const maxHp = currentEnemy?.stats?.health || 100;
const hpPercent = (data.enemyHp / maxHp) * 100;
setEnemyHp(hpPercent);
}
if (data.targetDefeated) {
setIsEnemyDefeated(true);
addLog("TARGET_NEUTRALIZED: Threat eliminated.");
if (data.loot && data.loot.length > 0) {
addLog("SCANNING FOR DROPPED ASSETS...");
data.loot.forEach((item) => {
const itemData = GameDataManager.getItem(item.id);
const itemName = itemData?.displayName || item.id;
addLog(`RECOVERED: ${itemName} x${item.count}`);
}); });
socket.on("dungeon:battle_update", async (data) => {
const turnOrder = data.battle.turnOrder;
const lastIndex =
(data.battle.currentTurnIndex - 1 + turnOrder.length) %
turnOrder.length;
const lastActorId = turnOrder[lastIndex];
if (lastActorId !== "player" && !data.battle.isOver) {
setActiveAttacker(lastActorId);
await new Promise((resolve) => setTimeout(resolve, 2000));
setBattle(data.battle);
if (data.log) data.log.forEach((msg) => addLog(msg));
setActiveAttacker(null);
} else {
setBattle(data.battle);
if (data.log) data.log.forEach((msg) => addLog(msg));
setActiveAttacker(null);
} }
if (data.status === "victory")
addLog("MISSION_OBJECTIVE: Threats neutralized.");
if (data.status === "defeat") {
addLog("CRITICAL_ERROR: Bio-sign lost.");
setTimeout(() => window.location.reload(), 3000);
} }
}); });
@ -77,10 +98,11 @@ const DungeonScreen = ({ session, socket }) => {
return () => { return () => {
socket.off("dungeon:room_update"); socket.off("dungeon:room_update");
socket.off("dungeon:combat_result"); socket.off("dungeon:battle_update");
socket.off("dungeon:completed"); socket.off("dungeon:completed");
socket.off("dungeon:failed");
}; };
}, [socket, currentEnemy]); }, [socket]);
const addLog = (text) => { const addLog = (text) => {
const time = new Date().toLocaleTimeString([], { const time = new Date().toLocaleTimeString([], {
@ -92,21 +114,22 @@ const DungeonScreen = ({ session, socket }) => {
setLog((prev) => [...prev, `[${time}] ${text}`]); setLog((prev) => [...prev, `[${time}] ${text}`]);
}; };
const handleCombat = () => { const handleCombatAction = () => {
if (isEnemyDefeated || !currentEnemy) return; const targetId = selectedTarget;
socket.emit("dungeon:combat_step", { enemyId: currentEnemy.id }); if (!battle || battle.isOver || activeAttacker || !targetId) return;
addLog(`Initiating strike sequence...`); if (battle.turnOrder[battle.currentTurnIndex] !== "player") return;
};
const handleLoot = () => { socket.emit("dungeon:combat_action", { targetInstanceId: targetId });
setIsLooted(true); addLog(`Initiating strike sequence...`);
addLog("Loot encryption bypassed. Resources transferred."); setSelectedTarget(null); // Скидаємо вибір після атаки
}; };
const handleNextRoom = () => { const handleNextRoom = () => {
socket.emit("dungeon:next_room"); socket.emit("dungeon:next_room");
}; };
const isPlayerTurn = battle?.turnOrder[battle?.currentTurnIndex] === "player";
return ( return (
<div className="dungeon-active-screen"> <div className="dungeon-active-screen">
{summary && ( {summary && (
@ -116,6 +139,7 @@ const DungeonScreen = ({ session, socket }) => {
/> />
)} )}
{/* Header section remains the same */}
<div className="dungeon-header"> <div className="dungeon-header">
<div className="room-progress"> <div className="room-progress">
<div className="progress-text"> <div className="progress-text">
@ -130,82 +154,102 @@ const DungeonScreen = ({ session, socket }) => {
></div> ></div>
</div> </div>
</div> </div>
<div className="dungeon-title-area">
<div className="dungeon-name">
{dungeonData?.displayName || "MISSION_ACTIVE"}
</div>
<div className="dungeon-status-tag">LIVE_FEED</div>
</div>
</div>
<div className="environment-panel"> {battle && (
<div className="env-header">ENVIRONMENT_SCAN</div>
<div className="env-info">
<div className="env-icon">
<i
className={`fas ${
roomData?.id?.includes("boss")
? "fa-skull"
: roomData?.id?.includes("loot")
? "fa-box-open"
: "fa-microchip"
}`}
></i>
</div>
<div className="env-details">
<div className="env-id">{getRoomDisplayName(roomData)}</div>
<div className="env-type">
TYPE: {hostiles.length > 0 ? "COMBAT_ZONE" : "SECURE_AREA"}
</div>
</div>
</div>
</div>
<div className="battle-layout">
<div <div
className={`enemy-display ${isEnemyDefeated ? "target-lost" : ""}`} className={`turn-progress-container ${isPlayerTurn ? "player-phase" : "enemy-phase"}`}
> >
{currentEnemy ? ( <div className="turn-label">
<div className={`enemy-card ${isEnemyDefeated ? "defeated" : ""}`}> {isPlayerTurn ? "YOUR TURN" : "ENEMY ACTION"}
<div className="threat-tag">
{isEnemyDefeated ? "SIGNAL_LOST" : "HOSTILE_DETECTED"}
</div> </div>
<div className="enemy-icon"> <div className="turn-timer-bar">
<i
className={`fas ${isEnemyDefeated ? "fa-skull-crossbones" : "fa-robot"}`}
></i>
</div>
<h3 className="enemy-name">
{getEnemyDisplayName(currentEnemy)}
</h3>
<div className="enemy-hp-container">
<div className="hp-label">STRUCTURE INTEGRITY</div>
<div className="hp-bar-mini">
<div <div
className="hp-fill-mini" className="turn-timer-fill"
style={{ style={{
width: isEnemyDefeated width: `${(timeLeft / (isPlayerTurn ? 10 : 4)) * 100}%`,
? "0%" transition:
: `${enemyHp !== null ? enemyHp : 100}%`, timeLeft === 10 || timeLeft === 4
? "none"
: "width 1s linear",
}} }}
></div> ></div>
</div> </div>
</div> </div>
<div className="enemy-info-footer"> )}
<span>LVL: {currentEnemy.level || 1}</span>
<span className="card-id">{currentEnemy.id}</span>
</div> </div>
<div className="battle-arena">
{battle ? (
<div className="mobs-grid">
{battle.enemies.map((mob) => (
<div
key={mob.instanceId}
className={`enemy-card
${mob.isDead ? "defeated" : ""}
${selectedTarget === mob.instanceId ? "selected" : ""}
${isPlayerTurn && !mob.isDead ? "selectable" : ""}
${activeAttacker === mob.instanceId ? "attacking" : ""}
`}
onClick={() =>
!mob.isDead &&
isPlayerTurn &&
setSelectedTarget(mob.instanceId)
}
>
{selectedTarget === mob.instanceId && (
<div className="target-aim">
<i className="fas fa-crosshairs"></i>
</div>
)}
<div className="enemy-hp-mini">
<div
className="fill"
style={{ width: `${(mob.hp / mob.maxHp) * 100}%` }}
></div>
</div>
<div className="enemy-icon">
<i
className={`fas ${mob.isDead ? "fa-skull-crossbones" : "fa-robot"}`}
></i>
</div>
<span className="mob-name">{GameDataManager.t(mob.name)}</span>
{!mob.isDead && <span className="mob-atk">ATK: {mob.atk}</span>}
</div>
))}
</div> </div>
) : ( ) : (
<div className="empty-room"> <div className="empty-room">
<i className="fas fa-satellite-dish"></i> <i className="fas fa-satellite-dish"></i>
<p>NO HOSTILES IN RANGE</p> <p>AREA SECURE</p>
</div> </div>
)} )}
</div> </div>
<div className="player-section">
{battle && (
<div
className={`player-hp-main ${activeAttacker ? "taking-damage" : ""}`}
>
<div className="hp-header">
<span>COMMANDER_INTEGRITY</span>
<span>
{battle.player.hp} / {battle.player.maxHp}
</span>
</div>
<div className="hp-bar-large">
<div
className="fill"
style={{
width: `${(battle.player.hp / battle.player.maxHp) * 100}%`,
}}
></div>
</div>
</div>
)}
<div className="combat-interface-row">
<div className="combat-log-wrapper"> <div className="combat-log-wrapper">
<div className="log-header">COMBAT_LOG_V3.0</div>
<div className="combat-log custom-scroll"> <div className="combat-log custom-scroll">
{log.map((entry, i) => ( {log.map((entry, i) => (
<div key={i} className="log-entry"> <div key={i} className="log-entry">
@ -215,22 +259,24 @@ const DungeonScreen = ({ session, socket }) => {
<div ref={logEndRef} /> <div ref={logEndRef} />
</div> </div>
</div> </div>
{battle && !battle.isOver && (
<button
className={`btn-execute-combat ${!selectedTarget || !isPlayerTurn ? "disabled" : ""}`}
disabled={!selectedTarget || !isPlayerTurn}
onClick={handleCombatAction}
>
<div className="btn-glitch-content">EXECUTE_STRIKE</div>
<div className="btn-sub-text">
{selectedTarget ? "TARGET_LOCKED" : "SELECT_TARGET"}
</div>
</button>
)}
</div>
</div> </div>
<div className="dungeon-controls"> <div className="dungeon-controls">
{!isEnemyDefeated && currentEnemy && ( {((battle?.isOver && battle.player.hp > 0) || !battle) && (
<button className="ctrl-btn combat" onClick={handleCombat}>
<i className="fas fa-bolt"></i> ENGAGE
</button>
)}
{isEnemyDefeated && !isLooted && (
<button className="ctrl-btn loot" onClick={handleLoot}>
<i className="fas fa-download"></i> COLLECT_ASSETS
</button>
)}
{(isLooted || !currentEnemy) && (
<button className="ctrl-btn next" onClick={handleNextRoom}> <button className="ctrl-btn next" onClick={handleNextRoom}>
PROCEED <i className="fas fa-chevron-right"></i> PROCEED <i className="fas fa-chevron-right"></i>
</button> </button>

View File

@ -27,6 +27,7 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
margin-right: 10px;
background: transparent; background: transparent;
border: none; border: none;
color: #4a5d75; color: #4a5d75;

View File

@ -11,6 +11,7 @@ const Navigation = ({ activeTab, onTabChange }) => {
{ id: "dashboard", icon: "fa-tachometer-alt" }, { id: "dashboard", icon: "fa-tachometer-alt" },
{ id: "dungeons", icon: "fa-dungeon" }, { id: "dungeons", icon: "fa-dungeon" },
{ id: "skills", icon: "fa-graduation-cap" }, { id: "skills", icon: "fa-graduation-cap" },
{ id: "quests", icon: "fa-store" },
{ id: "inventory", icon: "fa-archive" }, { id: "inventory", icon: "fa-archive" },
{ id: "shop", icon: "fa-store" }, { id: "shop", icon: "fa-store" },
{ id: "crafting", icon: "fa-hammer" }, { id: "crafting", icon: "fa-hammer" },

View File

@ -1,34 +1,172 @@
import React, { useState } from 'react'; import React, { useEffect, useState, useMemo } from "react";
import Card from "../../../components/ui/Card";
import { useSocket } from "../../../hooks/useSocket";
import gameDataManager from "../../../services/GameDataManager";
import "./styles/QuestsTab.css"; import "./styles/QuestsTab.css";
const QuestsTab = () => { const QuestsTab = () => {
const [type, setType] = useState('main'); const { socket } = useSocket();
const [quests, setQuests] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("ACTIVE");
useEffect(() => {
if (!socket) return;
socket.emit("quest:get_list");
const localize = (q) => {
const staticData = gameDataManager.getQuest(q.id);
return {
...q,
displayName: staticData?.displayName || q.id,
description: staticData?.description || "",
objectives: q.objectives.map((obj, idx) => ({
...obj,
description: staticData?.objectives[idx]?.description || obj.type,
})),
};
};
const handleQuestData = (data) => {
const uniqueQuests = new Map();
data.forEach((q) => {
uniqueQuests.set(q.id, localize(q));
});
setQuests(Array.from(uniqueQuests.values()));
setLoading(false);
};
const handleQuestUpdate = (updatedQuest) => {
setQuests((prev) => {
const localized = localize(updatedQuest);
const questMap = new Map(prev.map((q) => [q.id, q]));
questMap.set(localized.id, localized);
return Array.from(questMap.values());
});
};
socket.on("quest:list_data", handleQuestData);
socket.on("quest:update", handleQuestUpdate);
return () => {
socket.off("quest:list_data", handleQuestData);
socket.off("quest:update", handleQuestUpdate);
};
}, [socket]);
const filteredQuests = useMemo(() => {
return quests.filter((q) =>
activeTab === "ACTIVE"
? q.status === "active" || q.status === "ready"
: q.status === "completed",
);
}, [quests, activeTab]);
const renderObjective = (obj, index) => {
const progress = Math.min(
(obj.currentAmount / obj.requiredAmount) * 100,
100,
);
return ( return (
<div className="tab-content active"> <div key={index} className="objective-item">
<div className="quests-container"> <div className="objective-info">
<div className="quest-tabs"> <span className="objective-desc">{obj.description || obj.type}</span>
{['main', 'daily', 'weekly', 'completed'].map(t => ( <span className="objective-count">
<button {obj.currentAmount} / {obj.requiredAmount}
key={t} </span>
className={`quest-tab-btn ${type === t ? 'active' : ''}`}
onClick={() => setType(t)}
>
{t === 'main' ? 'Main Story' : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
<div className="daily-countdown">Daily quests reset in: 00:00:00</div>
<div className="quest-list">
<div className="empty-quests">
<p>No {type} quests available at the moment.</p>
</div>
</div> </div>
<div className="objective-progress-track">
<div
className="objective-progress-fill"
style={{ width: `${progress}%` }}
></div>
</div> </div>
</div> </div>
); );
}; };
return (
<div className="quests-container">
<div className="dash-scanline"></div>
<div className="quests-header">
<h2 className="glitch-text" data-text="MISSION_LOG">
MISSION_LOG
</h2>
<div className="quest-tabs-nav">
<button
className={`nav-btn ${activeTab === "ACTIVE" ? "active" : ""}`}
onClick={() => setActiveTab("ACTIVE")}
>
ACTIVE_OPERATIONS
</button>
<button
className={`nav-btn ${activeTab === "COMPLETED" ? "active" : ""}`}
onClick={() => setActiveTab("COMPLETED")}
>
ARCHIVED_DATA
</button>
</div>
<div className="header-line"></div>
</div>
{loading ? (
<div className="loading-status">SCANNING_NEURAL_NETWORK...</div>
) : (
<div className="quests-grid">
{filteredQuests.length > 0 ? (
filteredQuests.map((quest) => (
<Card key={quest.id} className={`quest-card ${quest.status}`}>
<div className="card-tag">{quest.category || "MISSION"}</div>
<div className="quest-main">
<h3 className="quest-title">{quest.displayName}</h3>
<p className="quest-description">{quest.description}</p>
</div>
<div className="quest-objectives">
<div className="section-label">OBJECTIVES</div>
{quest.objectives.map((obj, idx) =>
renderObjective(obj, idx),
)}
</div>
<div className="quest-rewards">
<div className="section-label">REWARDS</div>
<div className="rewards-row">
{quest.rewards?.credits > 0 && (
<span className="reward-pill credits">
+{quest.rewards.credits} CR
</span>
)}
{quest.rewards?.xp > 0 && (
<span className="reward-pill xp">
+{quest.rewards.xp} XP
</span>
)}
</div>
</div>
{quest.status === "ready" && (
<button
className="claim-btn"
onClick={() =>
socket.emit("quest:claim_reward", { questId: quest.id })
}
>
COMPLETE_MISSION
</button>
)}
{quest.status === "completed" && (
<div className="completed-stamp">MISSION_ACCOMPLISHED</div>
)}
</Card>
))
) : (
<div className="no-quests">
<p>NO_{activeTab}_SIGNALS_FOUND</p>
</div>
)}
</div>
)}
</div>
);
};
export default QuestsTab; export default QuestsTab;

View File

@ -2,256 +2,197 @@
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100vw;
height: 100%; height: 100vh;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 2000; z-index: 9999;
backdrop-filter: blur(4px); backdrop-filter: blur(6px);
} }
.craft-modal { .craft-modal {
background: #151921; background: #0f1115;
border: 1px solid #00ccff; border: 1px solid rgba(0, 210, 255, 0.3);
border-radius: 12px;
width: 90%; width: 90%;
max-width: 450px; max-width: 400px; /* Трохи вужча для компактності */
box-shadow: 0 0 30px rgba(0, 204, 255, 0.15); border-radius: 12px;
} position: relative;
padding: 20px;
.modal-headerr { box-shadow: 0 15px 40px rgba(0, 0, 0, 0.9);
display: flex; animation: modalSlideUp 0.3s ease-out;
justify-content: space-between;
align-items: center;
padding: 10px;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
margin: 0;
color: #00ccff;
font-family: "Orbitron", sans-serif;
font-size: 0.8rem;
}
.close-x {
background: none;
border: none;
color: #fff; color: #fff;
font-size: 1.5rem;
cursor: pointer;
} }
.requirements-section, /* Header Section */
.outcome-section { .modal-header-compact {
margin-bottom: 25px;
}
.res-grid {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 8px; gap: 15px;
background: rgba(255, 255, 255, 0.02); margin-bottom: 18px;
padding: 12px;
border-radius: 6px;
} }
.res-item { .item-icon-box {
display: flex; width: 70px;
justify-content: space-between; height: 70px;
color: #e0e0e0; background: rgba(0, 0, 0, 0.5);
} border: 1px solid rgba(0, 210, 255, 0.4);
.btn-start-craft {
width: 100%;
padding: 12px;
background: #00ccff;
border: none;
border-radius: 6px;
color: #000;
font-weight: bold;
text-transform: uppercase;
cursor: pointer;
margin-bottom: 10px;
transition: 0.2s;
}
.btn-start-craft:hover {
background: #0099cc;
box-shadow: 0 0 10px rgba(0, 204, 255, 0.4);
}
.btn-cancel {
width: 100%;
padding: 10px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
color: #888;
border-radius: 6px;
cursor: pointer;
}
.active-craft-panel {
background: rgba(0, 204, 255, 0.1);
border: 1px solid var(--primary-color);
padding: 15px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 20px;
animation: pulse 2s infinite;
}
.craft-info {
display: flex; display: flex;
justify-content: space-between; align-items: center;
margin-bottom: 8px; justify-content: center;
position: relative;
box-shadow: inset 0 0 10px rgba(0, 210, 255, 0.1);
}
.item-icon-box img {
width: 50px;
height: 50px;
object-fit: contain;
}
.item-info-title h3 {
margin: 0;
font-family: "Orbitron", sans-serif; font-family: "Orbitron", sans-serif;
font-size: 0.9rem; font-size: 1.1rem;
color: var(--primary-color); color: #00d2ff;
text-transform: uppercase;
} }
.progress-bar-bg { .item-tag {
width: 100%; font-size: 0.65rem;
height: 10px; color: #888;
background: rgba(255, 255, 255, 0.1); letter-spacing: 1px;
border-radius: 5px;
overflow: hidden;
} }
.progress-bar-fill { /* Sections */
height: 100%; .details-section {
background: var(--primary-color); margin-bottom: 15px;
box-shadow: 0 0 10px var(--primary-color);
transition: width 1s linear;
} }
@keyframes pulse { .section-label {
0% { font-size: 0.75rem;
box-shadow: 0 0 5px rgba(0, 204, 255, 0.2); text-transform: uppercase;
} color: #00d2ff;
50% { margin-bottom: 8px;
box-shadow: 0 0 15px rgba(0, 204, 255, 0.4); opacity: 0.8;
} display: block;
100% {
box-shadow: 0 0 5px rgba(0, 204, 255, 0.2);
}
} }
.res-item { .description-text {
font-size: 0.85rem;
color: #aaa;
font-style: italic;
line-height: 1.4;
}
/* Resource List */
.res-container {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.res-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 12px; padding: 6px 0;
background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid rgba(255, 255, 255, 0.03);
border-radius: 4px;
margin-bottom: 5px;
border-left: 4px solid transparent;
} }
.res-item.enough { .res-row:last-child {
border-left-color: #44ff44; border-bottom: none;
} }
.res-item.not-enough { .res-name {
border-left-color: #ff4444; font-size: 0.9rem;
background: rgba(255, 68, 68, 0.1); color: #ccc;
display: flex;
align-items: center;
gap: 8px;
} }
.val-red { .res-amount {
color: #ff4444;
font-weight: bold;
}
.val-green {
color: #44ff44;
}
.icon-red {
color: #ff4444;
}
.icon-green {
color: #44ff44;
}
.res-quantity-info {
font-family: "Courier New", monospace; font-family: "Courier New", monospace;
font-size: 0.9rem; font-size: 0.9rem;
} }
.btn-start-craft { .val-bad {
background: #28a745; color: #ff4444;
color: white; }
border: none; .val-good {
padding: 10px 20px; color: #00ff88;
border-radius: 4px;
font-weight: bold;
transition: 0.2s;
} }
.btn-start-craft.disabled { /* Progress & Outcome */
background: #444 !important; .outcome-bar {
opacity: 0.6;
cursor: not-allowed !important;
}
.btn-start-craft:not(.disabled):hover {
background: #218838;
box-shadow: 0 0 10px rgba(40, 167, 69, 0.4);
}
.item-preview-header {
display: flex; display: flex;
gap: 20px; justify-content: space-between;
padding: 15px; font-size: 0.85rem;
background: rgba(0, 212, 255, 0.05); margin-top: 10px;
border: 1px solid rgba(0, 212, 255, 0.1); color: #888;
margin-bottom: 20px; }
.outcome-bar strong {
color: #fff;
}
/* Buttons */
.btn-craft-action {
width: 100%;
padding: 12px;
margin-top: 20px;
font-family: "Orbitron", sans-serif;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
border-radius: 4px; border-radius: 4px;
text-transform: uppercase;
} }
.item-icon-container { .btn-primary-craft {
position: relative; background: rgba(0, 210, 255, 0.1);
width: 90px; border: 1px solid #00d2ff;
height: 90px; color: #00d2ff;
background: #000;
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.item-display-icon { .btn-primary-craft:hover:not(:disabled) {
max-width: 80%; background: #00d2ff;
max-height: 80%;
object-fit: contain;
filter: drop-shadow(0 0 5px var(--primary-color));
}
.item-qty-badge {
position: absolute;
top: -8px;
right: -8px;
background: var(--primary-color);
color: #000; color: #000;
padding: 2px 8px; box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
font-size: 11px;
font-weight: bold;
border-radius: 2px;
} }
.item-type-tag { .btn-primary-craft:disabled {
display: block; border-color: #444;
font-size: 10px; color: #444;
color: var(--primary-color); cursor: not-allowed;
letter-spacing: 1px;
margin-bottom: 5px;
opacity: 0.8;
} }
.item-description { .close-btn-top {
font-size: 13px; position: absolute;
color: #ccc; top: 12px;
line-height: 1.4; right: 12px;
margin: 0; background: none;
border: none;
color: #555;
font-size: 20px;
cursor: pointer;
}
.close-btn-top:hover {
color: #fff;
}
@keyframes modalSlideUp {
from {
transform: translateY(15px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }

View File

@ -16,8 +16,7 @@ const CraftModal = ({
const getFullTextureUrl = (path) => { const getFullTextureUrl = (path) => {
if (!path) return "/assets/no-image.png"; if (!path) return "/assets/no-image.png";
if (path.startsWith("http")) return path; return path.startsWith("http") ? path : `${ASSET_BASE_URL}${path}`;
return `${ASSET_BASE_URL}${path}`;
}; };
const isBusy = !!activeCraft; const isBusy = !!activeCraft;
@ -29,120 +28,131 @@ const CraftModal = ({
return ( return (
<div className="craft-modal-overlay" onClick={onClose}> <div className="craft-modal-overlay" onClick={onClose}>
<div className="craft-modal" onClick={(e) => e.stopPropagation()}> <div className="craft-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-headerr"> <button className="close-btn-top" onClick={onClose}>
<h3>
<i className="fas fa-tools"></i> Construction: {recipe.displayName}
</h3>
<button className="close-x" onClick={onClose}>
&times; &times;
</button> </button>
</div>
<div className="modal-body"> {/* Header: Icon + Title */}
{/* Секція з картинкою предмета */} <div className="modal-header-compact">
<div className="item-preview-header"> <div className="item-icon-box">
<div className="item-icon-container">
<img <img
src={getFullTextureUrl(recipe.texture)} src={getFullTextureUrl(recipe.texture)}
alt={recipe.displayName} alt={recipe.displayName}
className="item-display-icon"
/> />
<div className="item-qty-badge">x{outputQty}</div> <div
className="item-qty-badge"
style={{
position: "absolute",
bottom: "-5px",
right: "-5px",
background: "#00d2ff",
color: "#000",
fontSize: "10px",
padding: "2px 5px",
borderRadius: "3px",
fontWeight: "bold",
}}
>
x{outputQty}
</div> </div>
<div className="item-header-info"> </div>
<span className="item-type-tag">PROTOTYPE_UNIT</span> <div className="item-info-title">
<p className="item-description"> <span className="item-tag">PROTOTYPE_UNIT</span>
{recipe.description || <h3>{recipe.displayName}</h3>
"Technical data encrypted or unavailable."}
</p>
</div> </div>
</div> </div>
<div className="requirements-section"> {/* Description */}
<h4> <div className="details-section">
<i className="fas fa-list-ul"></i> Required Resources <p className="description-text">
</h4> {recipe.description ||
<div className="res-grid"> "Advanced composite material for high-tier construction."}
</p>
</div>
{/* Resources */}
<div className="details-section">
<span className="section-label">Required Materials</span>
<div className="res-container">
{recipe.ingredients?.map((ing) => { {recipe.ingredients?.map((ing) => {
const owned = getOwnedAmount(ing.itemId); const owned = getOwnedAmount(ing.itemId);
const hasEnough = owned >= ing.quantity; const hasEnough = owned >= ing.quantity;
return ( return (
<div <div key={ing.itemId} className="res-row">
key={ing.itemId} <span className="res-name">
className={`res-item ${hasEnough ? "enough" : "not-enough"}`}
>
<div className="res-main-info">
<i <i
className={`fas fa-cube ${hasEnough ? "icon-green" : "icon-red"}`} className={`fas fa-square`}
style={{
fontSize: "8px",
color: hasEnough ? "#00ff88" : "#ff4444",
}}
></i> ></i>
<span className="res-name">{ing.displayName}</span> {ing.displayName}
</div> </span>
<div className="res-quantity-info"> <span
<span className={`res-amount ${hasEnough ? "val-good" : "val-bad"}`}
className={`owned-val ${hasEnough ? "val-green" : "val-red"}`} >
> {owned} / {ing.quantity}
{owned}
</span> </span>
<span className="required-val"> / {ing.quantity}</span>
</div>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<div className="outcome-section"> {/* Outcome Info */}
<h4> <div className="outcome-bar">
<i className="fas fa-box-open"></i> Outcome <span>
</h4> Production Time: <strong>{recipe.time_seconds}s</strong>
<div className="outcome-info"> </span>
<div className="outcome-row">
<span>Result:</span>
<strong>
{recipe.displayName} x{outputQty}
</strong>
</div>
<div className="outcome-row">
<span>Time:</span>
<strong>{recipe.time_seconds}s</strong>
</div>
</div>
</div>
</div> </div>
<div className="modal-footer"> {/* Action Button */}
<div className="modal-footer-minimal">
{activeCraft && activeCraft.recipeId === recipe.id ? ( {activeCraft && activeCraft.recipeId === recipe.id ? (
<div className="modal-progress-container"> <div style={{ marginTop: "15px" }}>
<div className="progress-text"> <div
Processing... {activeCraft.timeLeft}s style={{
fontSize: "12px",
color: "#00d2ff",
marginBottom: "5px",
textAlign: "center",
}}
>
Constructing... {activeCraft.timeLeft}s
</div> </div>
<div className="progress-bar-bg"> <div
className="progress-bar-bg"
style={{
height: "4px",
background: "rgba(255,255,255,0.1)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div <div
className="progress-bar-fill" className="progress-bar-fill"
style={{ style={{
width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`, width: `${((activeCraft.totalTime - activeCraft.timeLeft) / activeCraft.totalTime) * 100}%`,
height: "100%",
background: "#00d2ff",
boxShadow: "0 0 10px #00d2ff",
}} }}
></div> ></div>
</div> </div>
</div> </div>
) : ( ) : (
<>
<button <button
className={`btn-start-craft ${!canAfford || isBusy ? "disabled" : ""}`} className="btn-craft-action btn-primary-craft"
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
disabled={!canAfford || isBusy} disabled={!canAfford || isBusy}
onClick={() => canAfford && !isBusy && onStartCraft(recipe)}
> >
{isBusy {isBusy
? "System Busy..." ? "System Busy"
: !canAfford : !canAfford
? "Insufficient Resources" ? "Low Resources"
: `Start Construction (${recipe.time_seconds}s)`} : "Begin Construction"}
</button> </button>
<button className="btn-cancel" onClick={onClose}>
Close
</button>
</>
)} )}
</div> </div>
</div> </div>

View File

@ -193,3 +193,22 @@
opacity: 1; opacity: 1;
} }
} }
.item-qty-tag {
position: absolute;
bottom: -5px;
right: -5px;
background: #00d2ff;
color: #000;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
border-radius: 3px;
font-family: monospace;
box-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
}
.btn-equip {
letter-spacing: 2px;
font-weight: 600;
}

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect } from "react";
import "./ItemModal.css"; import "./ItemModal.css";
import { getServerUrl } from "../../../../config/api"; import { getServerUrl } from "../../../../config/api";
@ -11,6 +11,25 @@ const ItemModal = ({
getStatIcon, getStatIcon,
formatStatName, formatStatName,
}) => { }) => {
useEffect(() => {
const handleKeyDown = (event) => {
if (event.keyCode === 69) {
if (isEquipped) {
onUnequip(item.currentSlot);
} else if (item && item.canEquip) {
onEquip(item);
}
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [item, isEquipped, onEquip, onUnequip, onClose]);
if (!item) return null; if (!item) return null;
const CONNECT_URL = getServerUrl(); const CONNECT_URL = getServerUrl();
@ -77,7 +96,7 @@ const ItemModal = ({
onClose(); onClose();
}} }}
> >
TERMINATE_CONNECTION TERMINATE_CONNECTION (E)
</button> </button>
) : ( ) : (
item.canEquip && ( item.canEquip && (
@ -88,7 +107,7 @@ const ItemModal = ({
onClose(); onClose();
}} }}
> >
INITIALIZE_EQUIP INITIALIZE_EQUIP (E)
</button> </button>
) )
)} )}

View File

@ -0,0 +1,211 @@
.quests-container {
padding: 15px;
position: relative;
overflow-y: auto;
height: 100%;
box-sizing: border-box;
}
.quests-header {
margin-bottom: 25px;
border-left: 3px solid #00d4ff;
padding-left: 15px;
}
.glitch-text {
font-size: 1.2rem;
letter-spacing: 2px;
color: #fff;
text-transform: uppercase;
margin: 0;
font-weight: 900;
}
.header-line {
height: 1px;
background: linear-gradient(90deg, #00d4ff, transparent);
margin-top: 5px;
width: 200px;
}
.quests-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 15px;
position: relative;
z-index: 2;
}
.quest-card {
background: rgba(10, 15, 24, 0.95) !important;
border: 1px solid #1a2638 !important;
border-radius: 2px !important;
padding: 20px !important;
transition: border-color 0.3s ease;
}
/* Стан готовності - міняємо бордер на Cyan замість фіолетового */
.quest-card.ready {
border-color: #00ff88 !important; /* Зеленуватий акцент для готових квестів */
box-shadow: inset 0 0 10px rgba(0, 255, 136, 0.05);
}
.quest-main h3 {
margin: 0 0 10px 0;
font-size: 1rem;
color: #00d4ff;
text-transform: uppercase;
}
.quest-description {
font-size: 11px;
color: #4a5d75;
line-height: 1.4;
margin-bottom: 15px;
}
.section-label {
font-size: 8px;
color: #4a5d75;
margin: 15px 0 8px 0;
letter-spacing: 1px;
font-weight: 900;
text-transform: uppercase;
}
.objective-item {
margin-bottom: 12px;
}
.objective-info {
display: flex;
justify-content: space-between;
font-size: 10px;
margin-bottom: 4px;
color: #fff;
}
.objective-progress-track {
height: 3px;
background: #05080c;
border: 1px solid #1a2638;
}
.objective-progress-fill {
height: 100%;
background: #00d4ff;
box-shadow: 0 0 5px rgba(0, 212, 255, 0.3);
transition: width 0.5s ease;
}
.rewards-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.reward-pill {
padding: 4px 8px;
background: rgba(0, 0, 0, 0.4);
border-left: 2px solid #00d4ff;
font-size: 10px;
color: #fff;
font-family: "Space Mono", monospace;
}
.reward-pill.credits {
border-left-color: #00ff88;
color: #00ff88;
}
.reward-pill.xp {
border-left-color: #00d4ff;
}
.claim-btn {
width: 100%;
margin-top: 20px;
background: #00ff88;
border: none;
color: #000;
padding: 10px;
cursor: pointer;
font-size: 10px;
font-weight: 900;
text-transform: uppercase;
letter-spacing: 2px;
transition: all 0.2s;
}
.claim-btn:hover {
background: #00cc6e;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 255, 136, 0.2);
}
.no-quests {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #4a5d75;
border: 1px dashed #1a2638;
}
.no-quests i {
font-size: 2rem;
margin-bottom: 10px;
display: block;
}
.quest-tabs-nav {
display: flex;
gap: 20px;
margin-top: 15px;
}
.nav-btn {
background: transparent;
border: none;
color: #4a5d75;
font-family: "Space Mono", monospace;
font-size: 10px;
font-weight: 900;
letter-spacing: 1px;
cursor: pointer;
padding: 5px 0;
position: relative;
transition: color 0.3s;
text-transform: uppercase;
}
.nav-btn.active {
color: #00d4ff;
}
.nav-btn.active::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: #00d4ff;
box-shadow: 0 0 8px #00d4ff;
}
.completed-stamp {
margin-top: 20px;
padding: 10px;
border: 1px solid #00ff88;
color: #00ff88;
text-align: center;
font-size: 10px;
font-weight: 900;
letter-spacing: 2px;
background: rgba(0, 255, 136, 0.05);
}
.quest-card.completed {
opacity: 0.7;
border-color: #1a2638 !important;
}

View File

@ -38,3 +38,4 @@
"category.tabs.core.shop": "Shop", "category.tabs.core.shop": "Shop",
"category.tabs.core.skills": "Skills" "category.tabs.core.skills": "Skills"
} }

View File

@ -173,6 +173,10 @@
"quests.category.original.weekly": "Weekly", "quests.category.original.weekly": "Weekly",
"quests.category.original.monthly": "Monthly", "quests.category.original.monthly": "Monthly",
"quests.category.original.seasonal": "Seasons", "quests.category.original.seasonal": "Seasons",
"quests.original.tutorial.starter_kit": "Starter Kit: Neural Link",
"quests.tutorial.slay_boss.name": "Trial by Fire",
"quests.tutorial.slay_boss.desc": "Prove your combat capabilities by neutralizing the Tutorial Boss unit.",
"quests.tutorial.slay_boss.obj1": "Defeat the Tutorial Boss",
"_comment_Recipes": "", "_comment_Recipes": "",
"recipes.category.original.alloys": "Alloys", "recipes.category.original.alloys": "Alloys",
"recipes.category.original.circuits": "Circuits", "recipes.category.original.circuits": "Circuits",
@ -233,5 +237,7 @@
"category.tabs.original.dungeons": "Dungeons", "category.tabs.original.dungeons": "Dungeons",
"category.tabs.original.inventory": "Inventory", "category.tabs.original.inventory": "Inventory",
"category.tabs.original.shop": "Shop", "category.tabs.original.shop": "Shop",
"category.tabs.original.skills" : "Skills" "category.tabs.original.skills": "Skills",
"category.tabs.original.quests": "Quests"
} }

View File

@ -3,7 +3,7 @@
"id": "original:tutorial/tutorial_boss_hostile", "id": "original:tutorial/tutorial_boss_hostile",
"displayName": "enemies.original.tutorial.tutorial_boss_hostile", "displayName": "enemies.original.tutorial.tutorial_boss_hostile",
"stats": { "stats": {
"health": 90, "health": 40,
"defense": 1.3, "defense": 1.3,
"damage": 4, "damage": 4,
"critical.chance": 0.3, "critical.chance": 0.3,

View File

@ -12,7 +12,7 @@
"loot": [ "loot": [
{ {
"id": "original:alloy_steel", "id": "original:alloy_steel",
"chance": 0.4, "chance": 0.8,
"count": { "count": {
"min": 1, "min": 1,
"max": 2 "max": 2

View File

@ -3,7 +3,10 @@
"id": "original:tutorial/tutorial_enemy_room", "id": "original:tutorial/tutorial_enemy_room",
"displayName": "rooms.original.tutorial.tutorial_enemy_room.name", "displayName": "rooms.original.tutorial.tutorial_enemy_room.name",
"description": "rooms.original.tutorial.tutorial_enemy_room.desc", "description": "rooms.original.tutorial.tutorial_enemy_room.desc",
"hostiles": ["original:tutorial/tutorial_hostile"], "hostiles": [
"original:tutorial/tutorial_hostile",
"original:tutorial/tutorial_hostile"
],
"gainXp": 3, "gainXp": 3,
"credits": 30, "credits": 30,
"loot": [], "loot": [],

View File

@ -1,18 +1,15 @@
{ {
"plating": { "weapons": {
"id": "original:basic_personal_weapon", "id": "original:basic_personal_weapon",
"displayName": "items.materials.original.personal.weapon.basic_personal_weapon", "displayName": "items.materials.original.personal.weapon.basic_personal_weapon",
"description": "items.materials.original.personal.weapon.basic_personal_weapon.desc", "description": "items.materials.original.personal.weapon.basic_personal_weapon.desc",
"texture": "original/assets/textures/equipment/personal/weapon/basic_weapon.png", "texture": "original/assets/textures/equipment/personal/weapon/basic_weapon.png",
"stats": { "stats": {
"health": 15, "attack.base": 20
"resistance.base": 0.125,
"defence.rating": 0.9,
"reflect.chance": 0.0125
}, },
"meta": { "meta": {
"rarity": "common", "rarity": "common",
"equipmentSlot": "original:personal_suit", "equipmentSlot": "original:personal_weapons",
"storeCategory": "original:personal", "storeCategory": "original:personal",
"dungeon": "ground" "dungeon": "ground"
} }

View File

@ -0,0 +1,55 @@
{
"quest": {
"id": "original:tutorial/starter_kit",
"displayName": "quests.original.tutorial.starter_kit",
"description": "Welcome, Commander. Your neural link is active. Initial equipment has been authorized.",
"category": "STORY",
"minLevel": 1,
"objectives": [
{
"type": "LOGIN",
"requiredAmount": 1,
"currentAmount": 0,
"description": "Initialize system uplink"
}
],
"rewards": {
"xp": 50,
"credits": 500,
"items": [
{
"id": "original:basic_personal_accessory",
"count": 1
},
{
"id": "original:basic_personal_backpack",
"count": 1
},
{
"id": "original:basic_personal_boots",
"count": 1
},
{
"id": "original:basic_personal_gloves",
"count": 1
},
{
"id": "original:basic_personal_suit",
"count": 1
},
{
"id": "original:basic_personal_weapon",
"count": 1
}
]
},
"meta": {
"autoAccept": true,
"autoComplete": false,
"priority": 100
}
}
}

View File

@ -0,0 +1,30 @@
{
"quest": {
"id": "original:tutorial/slay_tutorial_boss",
"displayName": "quests.tutorial.slay_boss.name",
"description": "quests.tutorial.slay_boss.desc",
"category": "STORY",
"meta": {
"autoAccept": true,
"priority": 10
},
"objectives": [
{
"type": "KILL_ENEMY",
"targetId": "original:tutorial/tutorial_boss_hostile",
"requiredAmount": 1,
"description": "quests.tutorial.slay_boss.obj1"
}
],
"rewards": {
"credits": 500,
"xp": 1000,
"items": [
{
"id": "original:materials/data_core",
"count": 1
}
]
}
}
}

View File

@ -32,7 +32,7 @@ sequelize.initDatabase = async () => {
await sequelize.query("PRAGMA foreign_keys = OFF;"); await sequelize.query("PRAGMA foreign_keys = OFF;");
} }
await sequelize.sync({ alter: true }); await sequelize.sync();
if (isLocal) { if (isLocal) {
await sequelize.query("PRAGMA foreign_keys = ON;"); await sequelize.query("PRAGMA foreign_keys = ON;");

View File

@ -0,0 +1,125 @@
const DatapackLoader = require("./DatapackLoader");
class CombatService {
initializeBattle(player, hostiles) {
const equipmentStats = this.calculateEquipmentStats(player.equipment);
const maxHp = 100 + (equipmentStats.health || 0);
const atk = 25 + (equipmentStats.attack || 0);
const battle = {
player: {
id: player.id,
name: player.username || "Commander",
hp: maxHp,
maxHp: maxHp,
atk: atk,
stats: equipmentStats,
},
enemies: hostiles.map((h, index) => ({
...h,
instanceId: `mob_${index}`,
id: h.id,
name: h.displayName || h.name || `Hostile ${index + 1}`,
hp: h.stats?.health || 50,
maxHp: h.stats?.health || 50,
atk: h.stats?.attack || 10,
isDead: false,
})),
turnOrder: [],
currentTurnIndex: 0,
turnStartTime: Date.now(),
isOver: false,
};
battle.turnOrder = ["player", ...battle.enemies.map((e) => e.instanceId)];
return battle;
}
calculateEquipmentStats(equipment) {
const totals = {
health: 0,
attack: 0,
defence: 0,
resistance: 0,
};
if (!equipment) return totals;
Object.values(equipment).forEach((itemId) => {
if (!itemId) return;
const itemData = DatapackLoader.getItem(itemId);
if (itemData && itemData.stats) {
Object.entries(itemData.stats).forEach(([key, value]) => {
const lowerKey = key.toLowerCase();
if (lowerKey.includes("health") || lowerKey === "hp") {
totals.health += value;
} else if (
lowerKey.includes("attack") ||
lowerKey.includes("damage") ||
lowerKey.includes("atk")
) {
totals.attack += value;
} else if (
lowerKey.includes("defence") ||
lowerKey.includes("armor") ||
lowerKey.includes("defense")
) {
totals.defence += value;
} else if (lowerKey.includes("resistance")) {
totals.resistance += value;
}
});
}
});
return totals;
}
handleAttack(battle, targetInstanceId) {
const attackerId = battle.turnOrder[battle.currentTurnIndex];
const log = [];
if (attackerId === "player") {
const target = battle.enemies.find(
(e) => e.instanceId === targetInstanceId,
);
if (target && !target.isDead) {
const damage = battle.player.atk;
target.hp -= damage;
log.push(`Player dealt ${damage} damage to ${target.name}`);
if (target.hp <= 0) {
target.hp = 0;
target.isDead = true;
log.push(`${target.name} destroyed!`);
}
}
} else {
const enemy = battle.enemies.find((e) => e.instanceId === attackerId);
if (enemy && !enemy.isDead) {
const playerDef = battle.player.stats.defence || 0;
const finalDamage = Math.max(
1,
Math.round(enemy.atk - playerDef * 0.5),
);
battle.player.hp -= finalDamage;
log.push(`${enemy.name} deals ${finalDamage} damage to Player`);
if (battle.player.hp <= 0) {
battle.player.hp = 0;
battle.isOver = true;
}
}
}
return log;
}
}
module.exports = new CombatService();

View File

@ -11,6 +11,7 @@ class DatapackLoader {
dungeons: new Map(), dungeons: new Map(),
enemies: new Map(), enemies: new Map(),
rooms: new Map(), rooms: new Map(),
quests: new Map(),
languages: new Map(), languages: new Map(),
manifest: {}, manifest: {},
}; };
@ -70,9 +71,10 @@ class DatapackLoader {
}); });
console.log( console.log(
`🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`, `🚀 Registry Ready: ${this.registry.items.size} Items, ${this.registry.dungeons.size} Dungeons, ${this.registry.quests.size} Quests, ${this.registry.languages.size} Langs, ${manifestCount} Manifests`,
); );
} }
getRecipe(id) { getRecipe(id) {
return this.registry.recipes.get(id); return this.registry.recipes.get(id);
} }
@ -89,6 +91,7 @@ class DatapackLoader {
); );
return Array.from(categories); return Array.from(categories);
} }
loadLanguages(langPath) { loadLanguages(langPath) {
try { try {
const files = fs.readdirSync(langPath).filter((f) => f.endsWith(".json")); const files = fs.readdirSync(langPath).filter((f) => f.endsWith(".json"));
@ -156,7 +159,6 @@ class DatapackLoader {
case "weapons": case "weapons":
data.type = typeKey; data.type = typeKey;
this.registry.items.set(fullId, data); this.registry.items.set(fullId, data);
this.registry.items.set(data.id, data);
break; break;
case "recipe": case "recipe":
const recipeId = json.craft?.id || data.id; const recipeId = json.craft?.id || data.id;
@ -164,19 +166,18 @@ class DatapackLoader {
break; break;
case "skills": case "skills":
this.registry.skills.set(fullId, data); this.registry.skills.set(fullId, data);
this.registry.skills.set(data.id, data);
break; break;
case "dungeon": case "dungeon":
this.registry.dungeons.set(fullId, data); this.registry.dungeons.set(fullId, data);
this.registry.dungeons.set(data.id, data);
break; break;
case "hostile": case "hostile":
this.registry.enemies.set(fullId, data); this.registry.enemies.set(fullId, data);
this.registry.enemies.set(data.id, data);
break; break;
case "rooms": case "rooms":
this.registry.rooms.set(fullId, data); this.registry.rooms.set(fullId, data);
this.registry.rooms.set(data.id, data); break;
case "quest":
this.registry.quests.set(fullId, data);
break; break;
} }
} catch (err) { } catch (err) {
@ -187,15 +188,29 @@ class DatapackLoader {
getItem(id) { getItem(id) {
return this.registry.items.get(id); return this.registry.items.get(id);
} }
getEnemy(id) { getEnemy(id) {
return this.registry.enemies.get(id); return this.registry.enemies.get(id);
} }
getDungeon(id) { getDungeon(id) {
return this.registry.dungeons.get(id); return this.registry.dungeons.get(id);
} }
getRoom(id) { getRoom(id) {
return this.registry.rooms.get(id); return this.registry.rooms.get(id);
} }
getQuest(id) {
return this.registry.quests.get(id);
}
getAutoStartQuests() {
return Array.from(this.registry.quests.values()).filter(
(q) => q.meta?.autoAccept,
);
}
getRecipes() { getRecipes() {
return Array.from(this.registry.recipes.values()); return Array.from(this.registry.recipes.values());
} }
@ -208,6 +223,7 @@ class DatapackLoader {
dungeons: Array.from(this.registry.dungeons.values()), dungeons: Array.from(this.registry.dungeons.values()),
enemies: Array.from(this.registry.enemies.values()), enemies: Array.from(this.registry.enemies.values()),
rooms: Array.from(this.registry.rooms.values()), rooms: Array.from(this.registry.rooms.values()),
quests: Array.from(this.registry.quests.values()),
languages: Object.fromEntries(this.registry.languages), languages: Object.fromEntries(this.registry.languages),
manifest: this.registry.manifest, manifest: this.registry.manifest,
}; };

View File

@ -1,23 +1,49 @@
const DatapackLoader = require("./DatapackLoader"); const DatapackLoader = require("./DatapackLoader");
const CombatService = require("./CombatService");
const QuestsManager = require("./QuestsManager");
const { Player } = require("../models");
class DungeonManager { class DungeonManager {
constructor() { constructor() {
this.activeSessions = new Map(); this.activeSessions = new Map();
} }
startDungeon(playerId, dungeonId) { async startDungeon(playerId, dungeonId) {
const dungeon = DatapackLoader.getDungeon(dungeonId); const dungeon = DatapackLoader.getDungeon(dungeonId);
if (!dungeon || !dungeon.rooms?.length) return null; if (!dungeon || !dungeon.rooms?.length) return null;
const player = await Player.findByPk(playerId);
if (!player) return null;
const session = { const session = {
playerId,
dungeonId, dungeonId,
currentRoomIndex: 0, currentRoomIndex: 0,
isFinished: false, isFinished: false,
currentEnemyHp: undefined, battle: null,
rewards: { xp: 0, credits: 0, items: [] }, rewards: { xp: 0, credits: 0, items: [] },
}; };
this.activeSessions.set(playerId, session); this.activeSessions.set(playerId, session);
return this.getCurrentRoomData(playerId); return this.initRoom(playerId, player);
}
async initRoom(playerId, playerInstance = null) {
const session = this.activeSessions.get(playerId);
if (!session) return null;
const roomData = this.getCurrentRoomData(playerId);
const player = playerInstance || (await Player.findByPk(playerId));
if (roomData.hostiles.length > 0) {
session.battle = CombatService.initializeBattle(
player,
roomData.hostiles,
);
} else {
session.battle = null;
}
return { ...roomData, battle: session.battle };
} }
getCurrentRoomData(playerId) { getCurrentRoomData(playerId) {
@ -28,8 +54,6 @@ class DungeonManager {
const roomRef = dungeon.rooms[session.currentRoomIndex]; const roomRef = dungeon.rooms[session.currentRoomIndex];
const rawRoom = DatapackLoader.getRoom(roomRef.id); const rawRoom = DatapackLoader.getRoom(roomRef.id);
if (!rawRoom) return null;
const hostiles = (rawRoom.hostiles || []) const hostiles = (rawRoom.hostiles || [])
.map((hId) => DatapackLoader.getEnemy(hId)) .map((hId) => DatapackLoader.getEnemy(hId))
.filter(Boolean); .filter(Boolean);
@ -42,89 +66,141 @@ class DungeonManager {
}; };
} }
processCombatStep(playerId, enemyId) { processCombatAction(playerId, targetInstanceId, socket = null) {
const session = this.activeSessions.get(playerId); const session = this.activeSessions.get(playerId);
if (!session || session.isFinished) return null; if (!session || !session.battle || session.battle.isOver) return null;
const enemy = DatapackLoader.getEnemy(enemyId); const battle = session.battle;
if (!enemy) return null; const log = CombatService.handleAttack(battle, targetInstanceId);
if (session.currentEnemyHp === undefined) { const allEnemiesDead = battle.enemies.every((e) => e.isDead);
session.currentEnemyHp = enemy.stats?.health || 100; const playerDead = battle.player.hp <= 0;
}
const damage = Math.floor(Math.random() * 10) + 20;
session.currentEnemyHp -= damage;
const isDefeated = session.currentEnemyHp <= 0;
let lootDropped = [];
if (isDefeated) {
if (enemy.loot) {
lootDropped = this._generateLoot(enemy.loot);
session.rewards.items.push(...lootDropped);
}
session.currentEnemyHp = undefined;
}
if (playerDead) {
battle.isOver = true;
battle.player.hp = 0;
return { return {
damageDealt: damage, battle,
enemyHp: Math.max(0, session.currentEnemyHp || 0), log: [...log, "CRITICAL_FAILURE: Mission terminated."],
targetDefeated: isDefeated, status: "defeat",
loot: lootDropped,
}; };
} }
moveToNextRoom(playerId) { if (allEnemiesDead) {
battle.isOver = true;
const roomData = this.getCurrentRoomData(playerId);
const roomConfig = roomData.config;
session.rewards.xp += roomConfig.gainXp || 0;
session.rewards.credits += roomConfig.credits || 0;
if (roomConfig.loot && Array.isArray(roomConfig.loot)) {
roomConfig.loot.forEach((l) => {
if (Math.random() <= (l.chance || 1)) {
let finalCount = 1;
if (typeof l.count === "object") {
finalCount =
Math.floor(Math.random() * (l.count.max - l.count.min + 1)) +
l.count.min;
} else if (typeof l.count === "number") {
finalCount = l.count;
}
const existingItem = session.rewards.items.find(
(i) => i.id === l.id,
);
if (existingItem) {
existingItem.count += finalCount;
} else {
session.rewards.items.push({ id: l.id, count: finalCount });
}
}
});
}
battle.enemies.forEach((enemy) => {
session.rewards.xp += enemy.gainXp || 0;
session.rewards.credits += enemy.credits || 0;
QuestsManager.trackProgress(
playerId,
"KILL_ENEMY",
enemy.id,
1,
socket,
);
if (enemy.loot && Array.isArray(enemy.loot)) {
enemy.loot.forEach((l) => {
if (Math.random() <= (l.chance || 1)) {
let finalCount = 1;
if (typeof l.count === "object") {
finalCount =
Math.floor(Math.random() * (l.count.max - l.count.min + 1)) +
l.count.min;
} else if (typeof l.count === "number") {
finalCount = l.count;
}
const existingItem = session.rewards.items.find(
(i) => i.id === l.id,
);
if (existingItem) {
existingItem.count += finalCount;
} else {
session.rewards.items.push({ id: l.id, count: finalCount });
}
}
});
}
});
return { battle, log, status: "victory" };
}
return this._nextTurn(session, log);
}
_nextTurn(session, lastLog = []) {
const battle = session.battle;
battle.currentTurnIndex =
(battle.currentTurnIndex + 1) % battle.turnOrder.length;
const currentEntityId = battle.turnOrder[battle.currentTurnIndex];
if (currentEntityId !== "player") {
const enemy = battle.enemies.find(
(e) => e.instanceId === currentEntityId,
);
if (!enemy || enemy.isDead) return this._nextTurn(session, lastLog);
const enemyLog = CombatService.handleAttack(battle, null);
if (battle.player.hp <= 0) {
battle.isOver = true;
return { battle, log: [...lastLog, ...enemyLog], status: "defeat" };
}
return this._nextTurn(session, [...lastLog, ...enemyLog]);
}
return { battle, log: lastLog };
}
async moveToNextRoom(playerId) {
const session = this.activeSessions.get(playerId); const session = this.activeSessions.get(playerId);
if (!session || session.isFinished) return null; if (!session || session.isFinished) return null;
const dungeon = DatapackLoader.getDungeon(session.dungeonId); const dungeon = DatapackLoader.getDungeon(session.dungeonId);
const roomRef = dungeon.rooms[session.currentRoomIndex];
const rawRoom = DatapackLoader.getRoom(roomRef.id);
if (rawRoom) {
if (rawRoom.hostiles) {
rawRoom.hostiles.forEach((hId) => {
const enemy = DatapackLoader.getEnemy(hId);
if (enemy) {
session.rewards.xp += enemy.gainXp || 0;
session.rewards.credits += enemy.credits || 0;
}
});
}
session.rewards.xp += rawRoom.gainXp || 0;
session.rewards.credits += rawRoom.credits || 0;
if (rawRoom.loot) {
session.rewards.items.push(...this._generateLoot(rawRoom.loot));
}
}
if (session.currentRoomIndex < dungeon.rooms.length - 1) { if (session.currentRoomIndex < dungeon.rooms.length - 1) {
session.currentRoomIndex++; session.currentRoomIndex++;
return this.getCurrentRoomData(playerId); return this.initRoom(playerId);
} }
session.isFinished = true; session.isFinished = true;
return { status: "completed", rewards: session.rewards }; return { status: "completed", rewards: session.rewards };
} }
_generateLoot(lootTable) {
const dropped = [];
lootTable.forEach((entry) => {
if (Math.random() <= (entry.chance || 1.0)) {
const count =
typeof entry.count === "object"
? Math.floor(
Math.random() * (entry.count.max - entry.count.min + 1),
) + entry.count.min
: entry.count || 1;
dropped.push({ id: entry.id, count });
}
});
return dropped;
}
leaveDungeon(playerId) { leaveDungeon(playerId) {
this.activeSessions.delete(playerId); this.activeSessions.delete(playerId);
} }

View File

@ -0,0 +1,163 @@
const DatapackLoader = require("./DatapackLoader");
const { PlayerQuest, Player, Inventory, sequelize } = require("../models");
class QuestsManager {
async onPlayerLogin(playerId, socket = null) {
try {
await this.checkAutoQuests(playerId, socket);
await this.trackProgress(playerId, "LOGIN", null, 1, socket);
} catch (err) {
console.error(err);
}
}
async checkAutoQuests(playerId, socket = null) {
const allQuests = Array.from(DatapackLoader.registry.quests);
const player = await Player.findByPk(playerId);
for (const quest of allQuests) {
if (quest.meta?.autoAccept && player.level >= (quest.minLevel || 0)) {
const [pq, created] = await PlayerQuest.findOrCreate({
where: { playerId, questId: quest.id },
defaults: {
status: "active",
progress: quest.objectives.map((obj) => ({
...obj,
currentAmount: 0,
})),
},
});
if (created && socket) {
socket.emit("quest:new", {
id: pq.questId,
status: pq.status,
objectives: pq.progress,
});
}
}
}
}
async trackProgress(playerId, type, targetId, amount = 1, socket = null) {
try {
const activeQuests = await PlayerQuest.findAll({
where: { playerId, status: "active" },
});
for (const pq of activeQuests) {
const staticData = DatapackLoader.getQuest(pq.questId);
if (!staticData) continue;
let isChanged = false;
const currentProgress = pq.progress;
const updatedProgress = currentProgress.map((obj) => {
if (
obj.type === type &&
(obj.targetId === targetId || type === "LOGIN")
) {
if (obj.currentAmount < obj.requiredAmount) {
obj.currentAmount = Math.min(
obj.currentAmount + amount,
obj.requiredAmount,
);
isChanged = true;
}
}
return obj;
});
if (isChanged) {
const isReady = updatedProgress.every(
(obj) => obj.currentAmount >= obj.requiredAmount,
);
await pq.update({
progress: updatedProgress,
status: isReady ? "ready" : "active",
});
if (socket) {
socket.emit("quest:update", {
id: pq.questId,
status: isReady ? "ready" : "active",
objectives: updatedProgress,
});
}
if (isReady && staticData.meta?.autoComplete) {
await this.claimRewards(playerId, pq.questId);
}
}
}
} catch (err) {
console.error(err);
}
}
async claimRewards(playerId, questId) {
const t = await sequelize.transaction();
try {
const pq = await PlayerQuest.findOne({
where: { playerId, questId, status: "ready" },
transaction: t,
lock: t.LOCK.UPDATE,
});
if (!pq) {
await t.rollback();
throw new Error("QUEST_NOT_READY_OR_CLAIMED");
}
const staticData = DatapackLoader.getQuest(questId);
const player = await Player.findByPk(playerId, { transaction: t });
const rewards = staticData.rewards;
await pq.update({ status: "completed" }, { transaction: t });
if (rewards.credits) {
await player.increment("credits", {
by: rewards.credits,
transaction: t,
});
}
if (rewards.xp) {
await player.increment("experience", {
by: rewards.xp,
transaction: t,
});
}
if (rewards.items?.length > 0) {
for (const item of rewards.items) {
const [invItem] = await Inventory.findOrCreate({
where: { playerId, itemId: item.id },
defaults: { quantity: 0 },
transaction: t,
});
await invItem.increment("quantity", {
by: item.count,
transaction: t,
});
}
}
await t.commit();
const updatedPlayer = await player.reload();
return {
success: true,
rewards,
newTotalCredits: updatedPlayer.credits,
};
} catch (err) {
if (t) await t.rollback();
throw err;
}
}
}
module.exports = new QuestsManager();

View File

@ -0,0 +1,41 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/db");
const PlayerQuest = sequelize.define(
"PlayerQuest",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
playerId: {
type: DataTypes.STRING,
allowNull: false,
},
questId: {
type: DataTypes.STRING,
allowNull: false,
},
status: {
type: DataTypes.ENUM("active", "ready", "completed"),
defaultValue: "active",
},
progress: {
type: DataTypes.JSON,
defaultValue: [],
},
},
{
timestamps: true,
tableName: "player_quests",
indexes: [
{
unique: true,
fields: ["playerId", "questId"],
},
],
},
);
module.exports = PlayerQuest;

View File

@ -4,9 +4,14 @@ const Inventory = require("./Inventory");
const setupAssociations = require("./associations"); const setupAssociations = require("./associations");
const Notification = require("./Notification"); const Notification = require("./Notification");
const Friend = require("./Friend.js"); const Friend = require("./Friend.js");
const PlayerQuest = require("./PlayerQuest.js");
Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" }); Player.hasMany(Inventory, { foreignKey: "playerId", as: "inventory" });
Inventory.belongsTo(Player, { foreignKey: "playerId" }); Inventory.belongsTo(Player, { foreignKey: "playerId" });
Player.hasMany(PlayerQuest, { foreignKey: "playerId", as: "quests" });
PlayerQuest.belongsTo(Player, { foreignKey: "playerId" });
setupAssociations(); setupAssociations();
module.exports = { module.exports = {
@ -15,4 +20,5 @@ module.exports = {
Inventory, Inventory,
Notification, Notification,
Friend, Friend,
PlayerQuest,
}; };

View File

@ -2,6 +2,7 @@ const { Op } = require("sequelize");
const Player = require("../../models/Player"); const Player = require("../../models/Player");
const sessionManager = require("../../game/SessionManager"); const sessionManager = require("../../game/SessionManager");
const economyService = require("../../game/EconomyService.js"); const economyService = require("../../game/EconomyService.js");
const QuestsManager = require("../../game/QuestsManager");
module.exports = async (io, socket) => { module.exports = async (io, socket) => {
const userId = socket.user?.id; const userId = socket.user?.id;
@ -26,6 +27,9 @@ module.exports = async (io, socket) => {
} }
} }
await QuestsManager.onPlayerLogin(userId, socket);
await player.reload();
const offlineCredits = await economyService.handleOfflineEarnings(player); const offlineCredits = await economyService.handleOfflineEarnings(player);
socket.playerId = userId; socket.playerId = userId;
@ -34,9 +38,9 @@ module.exports = async (io, socket) => {
const playerRaw = player.get({ plain: true }); const playerRaw = player.get({ plain: true });
sessionManager.addPlayer(socket.id, playerRaw); sessionManager.addPlayer(socket.id, playerRaw);
const onlinePlayersData = sessionManager.getAllOnline(); const onlinePlayersData = sessionManager.getAllOnline();
const onlineUsernames = onlinePlayersData.map((p) => p.username); const onlineUsernames = onlinePlayersData.map((p) => p.username);
const onlineIds = onlinePlayersData.map((p) => p.id); const onlineIds = onlinePlayersData.map((p) => p.id);
const offlinePlayersModels = await Player.findAll({ const offlinePlayersModels = await Player.findAll({
@ -64,6 +68,7 @@ module.exports = async (io, socket) => {
socket.broadcast.emit("player:joined", { username: playerRaw.username }); socket.broadcast.emit("player:joined", { username: playerRaw.username });
socket.join(`user_${socket.user.id}`); socket.join(`user_${socket.user.id}`);
socket.on("player:get_dashboard", async () => { socket.on("player:get_dashboard", async () => {
try { try {
const p = await Player.findByPk(userId); const p = await Player.findByPk(userId);

View File

@ -18,36 +18,49 @@ module.exports = (io, socket) => {
return socket.emit("error", { message: "Insufficient energy" }); return socket.emit("error", { message: "Insufficient energy" });
await player.decrement("energy", { by: energyCost }); await player.decrement("energy", { by: energyCost });
const firstRoom = dungeonManager.startDungeon(userId, dungeonId); const startData = await dungeonManager.startDungeon(userId, dungeonId);
socket.emit("dungeon:started", { socket.emit("dungeon:started", {
dungeonId: dungeon.id, dungeonId: dungeon.id,
room: firstRoom.config, room: startData.config,
hostiles: firstRoom.hostiles, hostiles: startData.hostiles,
roomIndex: firstRoom.roomIndex, battle: startData.battle,
totalRooms: firstRoom.totalRooms, roomIndex: startData.roomIndex,
totalRooms: startData.totalRooms,
remainingEnergy: player.energy - energyCost, remainingEnergy: player.energy - energyCost,
}); });
} catch (err) { } catch (err) {
socket.emit("error", { message: "Critical deployment failure" }); socket.emit("error", { message: "Deployment failure" });
} }
}); });
socket.on("dungeon:combat_step", async ({ enemyId }) => { socket.on("dungeon:combat_action", async ({ targetInstanceId }) => {
const result = dungeonManager.processCombatStep(userId, enemyId); try {
if (!userId) return;
const result = dungeonManager.processCombatAction(
userId,
targetInstanceId,
);
if (!result) return; if (!result) return;
socket.emit("dungeon:combat_result", { socket.emit("dungeon:battle_update", {
...result, battle: result.battle,
message: result.targetDefeated log: result.log,
? "Enemy eliminated!" status: result.status,
: `Strike successful. Dealt ${result.damageDealt} damage.`,
}); });
if (result.status === "defeat") {
dungeonManager.leaveDungeon(userId);
socket.emit("dungeon:failed", { message: "Neural link severed." });
}
} catch (err) {
socket.emit("error", { message: "Synchronization error" });
}
}); });
socket.on("dungeon:next_room", async () => { socket.on("dungeon:next_room", async () => {
try { try {
const nextRoom = dungeonManager.moveToNextRoom(userId); if (!userId) return;
const nextRoom = await dungeonManager.moveToNextRoom(userId);
if (!nextRoom) if (!nextRoom)
return socket.emit("error", { message: "Navigation error" }); return socket.emit("error", { message: "Navigation error" });
@ -57,7 +70,7 @@ module.exports = (io, socket) => {
socket.emit("dungeon:room_update", nextRoom); socket.emit("dungeon:room_update", nextRoom);
} }
} catch (err) { } catch (err) {
socket.emit("error", { message: "Navigation system error" }); socket.emit("error", { message: "Navigation error" });
} }
}); });
@ -90,13 +103,16 @@ async function finalizeDungeon(socket, sessionRewards) {
await invItem.increment("quantity", { by: totalCount }); await invItem.increment("quantity", { by: totalCount });
} }
// Оновлюємо масив для фронтенда, щоб не було дублікатів у списку
sessionRewards.items = Object.entries(consolidated).map( sessionRewards.items = Object.entries(consolidated).map(
([id, count]) => ({ id, count }), ([id, count]) => ({ id, count }),
); );
} }
console.log("FINAL REWARDS SAVED:", sessionRewards);
socket.emit("dungeon:completed", { rewards: sessionRewards }); socket.emit("dungeon:completed", { rewards: sessionRewards });
} catch (err) { } catch (err) {
console.error(err);
socket.emit("error", { message: "Failed to save rewards" }); socket.emit("error", { message: "Failed to save rewards" });
} finally { } finally {
dungeonManager.leaveDungeon(userId); dungeonManager.leaveDungeon(userId);

View File

@ -0,0 +1,89 @@
const questsManager = require("../../game/QuestsManager");
const DatapackLoader = require("../../game/DatapackLoader");
const { PlayerQuest } = require("../../models");
module.exports = (io, socket) => {
const playerId = socket.user?.id;
socket.on("quest:get_list", async () => {
if (!playerId) return;
try {
const autoQuests = DatapackLoader.getAutoStartQuests();
const existingQuests = await PlayerQuest.findAll({
where: { playerId },
attributes: ["questId"],
raw: true,
});
const existingIds = existingQuests.map((q) => q.questId);
const missingQuests = autoQuests.filter(
(aq) => !existingIds.includes(aq.id),
);
if (missingQuests.length > 0) {
const toCreate = missingQuests.map((aq) => ({
playerId,
questId: aq.id,
status: "active",
progress: aq.objectives.map((obj) => ({ ...obj, currentAmount: 0 })),
}));
await PlayerQuest.bulkCreate(toCreate, { ignoreDuplicates: true });
}
const all = await PlayerQuest.findAll({
where: { playerId },
order: [["updatedAt", "DESC"]],
});
socket.emit(
"quest:list_data",
all.map((q) => ({
id: q.questId,
status: q.status,
objectives: q.progress,
rewards: DatapackLoader.getQuest(q.questId)?.rewards,
})),
);
} catch (err) {
console.error("Quest sync error:", err);
}
});
socket.on("quest:claim_reward", async ({ questId }) => {
try {
const result = await questsManager.claimRewards(playerId, questId);
socket.emit("player:credits_update", {
totalCredits: result.newTotalCredits,
});
socket.emit("quest:reward_claimed", {
questId,
rewards: result.rewards,
});
const all = await PlayerQuest.findAll({
where: { playerId },
order: [["updatedAt", "DESC"]],
});
socket.emit(
"quest:list_data",
all.map((q) => ({
id: q.questId,
status: q.status,
objectives: q.progress,
rewards: DatapackLoader.getQuest(q.questId)?.rewards,
})),
);
} catch (err) {
const msg =
err.message === "QUEST_NOT_READY_OR_CLAIMED"
? "Reward already claimed or objective not met."
: "Failed to claim reward.";
socket.emit("error", { message: msg });
}
});
};

View File

@ -7,6 +7,7 @@ const dungeonHandler = require("./handlers/dungeonHandler");
const chatHandler = require("./handlers/chatHandler"); const chatHandler = require("./handlers/chatHandler");
const notificationHandler = require("./handlers/notificationHandler"); const notificationHandler = require("./handlers/notificationHandler");
const socialHandler = require("./handlers/socialHandler"); const socialHandler = require("./handlers/socialHandler");
const questsHandler = require("./handlers/questsHandler");
const initSockets = (io) => { const initSockets = (io) => {
io.use(socketAuth); io.use(socketAuth);
@ -20,6 +21,7 @@ const initSockets = (io) => {
chatHandler(io, socket); chatHandler(io, socket);
socialHandler(io, socket); socialHandler(io, socket);
notificationHandler(io, socket); notificationHandler(io, socket);
questsHandler(io, socket);
}); });
}; };