first commit

This commit is contained in:
Alexej Wolff
2026-02-11 00:15:59 +01:00
commit cc003ffbd5
39 changed files with 12170 additions and 0 deletions
+499
View File
@@ -0,0 +1,499 @@
import { useState, useEffect, useRef } from "react";
import {
useParams,
Link,
useNavigate,
useSearchParams,
} from "react-router-dom";
import ReactMarkdown from "react-markdown";
import { useAuth } from "../contexts/AuthContext";
import {
getStory,
getSession,
saveSession as apiSaveSession,
getPlayerCharacter,
} from "../services/api";
import {
generateStoryResponse,
buildSystemPrompt,
sendMessage,
generateStorySummary,
extractKeyEvents,
} from "../services/deepseek";
import type {
Story,
GameSession,
ChatMessage,
PlayerCharacter,
} from "../types";
import "./GamePage.css";
function generateId(): string {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
// Примерный подсчёт токенов (1 токен ≈ 3 символа для русского)
function estimateTokens(messages: ChatMessage[]): number {
if (!messages || messages.length === 0) return 0;
const totalChars = messages.reduce((sum, msg) => sum + msg.content.length, 0);
return Math.round(totalChars / 3);
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
return tokens.toString();
}
// Пытаемся определить локацию из последних сообщений
function detectLocation(messages: ChatMessage[]): string {
if (!messages || messages.length === 0) return "Неизвестно";
// Берём последние 3 сообщения ассистента
const recentAssistant = messages
.filter((m) => m.role === "assistant")
.slice(-3)
.map((m) => m.content)
.join(" ");
// Паттерны для определения локации
const locationPatterns = [
/(?:находи(?:тесь|шься)|оказыва(?:етесь|ешься)|стои(?:те|шь))\s+(?:в|на|у)\s+([^.,!?]+)/i,
/(?:вошл[аи]?|входи(?:те|шь)|попада(?:ете|ешь))\s+(?:в|на)\s+([^.,!?]+)/i,
/(?:прибыл[аи]?|приход(?:ите|ишь)|добрал(?:ись|ась|ся))\s+(?:в|на|до)\s+([^.,!?]+)/i,
/(?:комнат[аеуы]|зал[аеуы]?|пещер[аеуы]|лес[ау|замок|двор(?:ец)?|тавер[нуыа]|город[ауе]?|деревн[яюией]|тронн[ыйая]|подземель[яеи])\s*([^.,!?]*)/i,
];
for (const pattern of locationPatterns) {
const match = recentAssistant.match(pattern);
if (match && match[1]) {
// Чистим и обрезаем результат
let location = match[1].trim();
if (location.length > 25) location = location.substring(0, 25) + "...";
return location;
}
}
// Простой поиск ключевых слов
const simpleLocations: [RegExp, string][] = [
[/тронн(?:ый|ого|ом)\s*зал/i, "Тронный зал"],
[/тавер[нуыа]/i, "Таверна"],
[/замок|замк[ауе]/i, "Замок"],
[/лес[ау]?/i, "Лес"],
[/пещер[аеуы]/i, "Пещера"],
[/город[ауе]?/i, "Город"],
[/деревн[яюией]/i, "Деревня"],
[/подземель[яеи]/i, "Подземелье"],
[/двор(?:ец|ц[ауе])/i, "Дворец"],
[/рын(?:ок|к[ауе])/i, "Рынок"],
[/храм[ауе]?/i, "Храм"],
[/библиотек/i, "Библиотека"],
[/казарм/i, "Казармы"],
];
for (const [pattern, name] of simpleLocations) {
if (pattern.test(recentAssistant)) {
return name;
}
}
return "Неизвестно";
}
export default function GamePage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [story, setStory] = useState<Story | null>(null);
const [session, setSession] = useState<GameSession | null>(null);
const [playerCharacter, setPlayerCharacter] =
useState<PlayerCharacter | null>(null);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const loadGame = async () => {
if (!id || !isAuthenticated) {
setIsInitialLoading(false);
return;
}
const foundStory = await getStory(id);
if (foundStory) {
const normalizedStory = {
...foundStory,
id: (foundStory as any)._id || foundStory.id,
};
setStory(normalizedStory);
let existingSession = await getSession(id);
console.log(
"[GamePage] Loaded session:",
existingSession?.messages?.length || 0,
"messages",
);
const characterId = searchParams.get("character");
// Загружаем персонажа
let character: PlayerCharacter | null = null;
if (characterId) {
character = await getPlayerCharacter(characterId);
setPlayerCharacter(character);
} else if (existingSession?.playerId) {
character = await getPlayerCharacter(existingSession.playerId);
setPlayerCharacter(character);
}
if (!existingSession) {
console.log("[GamePage] No existing session, creating new");
existingSession = {
storyId: id,
playerId: characterId || undefined,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else if (characterId && existingSession.playerId !== characterId) {
// Новый персонаж — новая сессия
console.log("[GamePage] New character selected, resetting session");
existingSession = {
storyId: id,
playerId: characterId,
messages: [],
currentState: {
location: "Неизвестно",
health: 100,
inventory: [],
questProgress: {},
},
};
} else {
console.log(
"[GamePage] Using existing session with",
existingSession.messages.length,
"messages",
);
}
setSession(existingSession);
// Начинаем историю если это новая сессия
if (existingSession.messages.length === 0 && character) {
startStory(normalizedStory, existingSession, character);
}
}
setIsInitialLoading(false);
};
loadGame();
}, [id, isAuthenticated, searchParams]);
// Обновляем историю при возврате на страницу (после редактирования)
useEffect(() => {
const handleFocus = async () => {
if (!id || !isAuthenticated || isInitialLoading) return;
const updatedStory = await getStory(id);
if (updatedStory) {
const normalizedStory = {
...updatedStory,
id: (updatedStory as any)._id || updatedStory.id,
};
setStory(normalizedStory);
console.log("[GamePage] История обновлена после возврата на страницу");
}
};
window.addEventListener("focus", handleFocus);
return () => window.removeEventListener("focus", handleFocus);
}, [id, isAuthenticated, isInitialLoading]);
useEffect(() => {
scrollToBottom();
}, [session?.messages]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
const startStory = async (
storyData: Story,
sessionData: GameSession,
character: PlayerCharacter,
) => {
// Если есть заготовленное первое сообщение, используем его
if (storyData.firstMessage && storyData.firstMessage.trim()) {
// Заменяем {user} на имя персонажа
const firstMessageContent = storyData.firstMessage.replace(
/\{user\}/gi,
character.name,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: firstMessageContent,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
return;
}
// Иначе генерируем через ИИ
setIsLoading(true);
setError(null);
try {
const systemPrompt = buildSystemPrompt(storyData, character);
const response = await sendMessage([
{ role: "system", content: systemPrompt },
{ role: "user", content: "Начни историю." },
]);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const updatedSession: GameSession = {
...sessionData,
messages: [assistantMessage],
};
await apiSaveSession(storyData.id, updatedSession);
setSession(updatedSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
} finally {
setIsLoading(false);
}
};
const handleSend = async () => {
if (!input.trim() || !story || !session || isLoading) return;
const userMessage: ChatMessage = {
id: generateId(),
role: "user",
content: input.trim(),
timestamp: new Date(),
};
const updatedMessages = [...session.messages, userMessage];
const tempSession = { ...session, messages: updatedMessages };
setSession(tempSession);
setInput("");
setIsLoading(true);
setError(null);
try {
// Передаём session для оптимизированного контекста
const response = await generateStoryResponse(
story,
session.messages,
input.trim(),
playerCharacter || undefined,
session,
);
const assistantMessage: ChatMessage = {
id: generateId(),
role: "assistant",
content: response,
timestamp: new Date(),
};
const allMessages = [...updatedMessages, assistantMessage];
// Обновляем ключевые события
const newKeyEvents = await extractKeyEvents(
response,
session.keyEvents || [],
);
// Генерируем сводку каждые 20 сообщений
let newSummary = session.storySummary;
if (allMessages.length % 20 === 0 && allMessages.length > 0) {
console.log("[GamePage] Generating story summary...");
newSummary = await generateStorySummary(
story,
allMessages,
session.storySummary,
);
}
const finalSession: GameSession = {
...session,
messages: allMessages,
keyEvents: newKeyEvents,
storySummary: newSummary,
};
const saved = await apiSaveSession(story.id, finalSession);
console.log(
"[GamePage] Session saved:",
saved,
"Messages:",
allMessages.length,
);
setSession(finalSession);
} catch (err) {
setError(err instanceof Error ? err.message : "Произошла ошибка");
// Откатываем сообщение пользователя при ошибке
setSession(session);
setInput(userMessage.content);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleQuickAction = (action: string) => {
setInput(action);
inputRef.current?.focus();
};
if (isInitialLoading) {
return (
<div className="game-page">
<div className="game-loading">
<p>Загрузка игры...</p>
</div>
</div>
);
}
if (!story) {
return (
<div className="game-page">
<div className="game-error">
<h2>История не найдена</h2>
<Link to="/" className="back-link">
Вернуться к списку
</Link>
</div>
</div>
);
}
return (
<div className="game-page">
<header className="game-header">
<Link to={`/story/${story.id}`} className="back-btn">
</Link>
<div className="header-info">
<h1>{story.title}</h1>
<span className="header-protagonist">
👤 {playerCharacter?.name || "Герой"}
</span>
</div>
<div className="header-stats">
<span className="stat-badge tokens">
🎟 {formatTokens(estimateTokens(session?.messages || []))}
</span>
<span className="stat-badge location">
📍 {detectLocation(session?.messages || [])}
</span>
</div>
</header>
<div className="game-content">
<div className="messages-container">
{session?.messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<div className="message-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
<span className="message-time">
{new Date(message.timestamp).toLocaleTimeString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
))}
{isLoading && (
<div className="message assistant loading">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
{error && (
<div className="error-message">
<span> {error}</span>
<button onClick={() => setError(null)}></button>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* RPG кнопки скрыты — раскомментировать при необходимости
<div className="quick-actions">
<button onClick={() => handleQuickAction("Осмотреться вокруг")}>
👀 Осмотреться
</button>
<button onClick={() => handleQuickAction("Проверить инвентарь")}>
🎒 Инвентарь
</button>
<button onClick={() => handleQuickAction("Поговорить с кем-нибудь")}>
💬 Говорить
</button>
<button onClick={() => handleQuickAction("Идти вперёд")}>
🚶 Идти
</button>
</div>
*/}
<div className="input-container">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Что ты хочешь сделать?..."
disabled={isLoading}
rows={2}
/>
<button
onClick={handleSend}
disabled={!input.trim() || isLoading}
className="send-btn"
>
{isLoading ? "⏳" : "➤"}
</button>
</div>
</div>
</div>
);
}