new RPG system

This commit is contained in:
2026-06-05 14:57:15 +03:00
parent 6189a5fb74
commit 01b16dbeaa
29 changed files with 2395 additions and 311 deletions
+111 -16
View File
@@ -154,12 +154,63 @@ export function ensureMessageActionsLast(wrapper) {
if (actions) wrapper.appendChild(actions);
}
function renderNewArcRollButton(wrapper, choice) {
removeChoiceRows(wrapper);
const row = document.createElement('div');
row.className = 'choice-row choice-row-new-arc';
const panel = document.createElement('div');
panel.className = 'new-arc-roll';
const hdr = document.createElement('div');
hdr.className = 'new-arc-roll-header';
hdr.textContent = choice?.label || 'Начать новую арку';
panel.appendChild(hdr);
const hint = document.createElement('div');
hint.className = 'new-arc-roll-hint';
hint.textContent = 'Арка завершена. Можно продолжить эпилог или начать новую — кто ходит первым после инъекта рассказчика?';
panel.appendChild(hint);
const split = document.createElement('div');
split.className = 'new-arc-roll-split';
const mkHalf = (side, icon, title, subtitle, first) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `new-arc-half new-arc-half-${side}`;
btn.innerHTML = `<span class="new-arc-half-icon">${icon}</span><span class="new-arc-half-title">${title}</span><span class="new-arc-half-sub">${subtitle}</span>`;
btn.addEventListener('click', () => {
removeChoiceRows(wrapper);
sendMessage(choice?.label || 'Начать новую арку', {
isNarratorChoice: true,
newArcFirst: first,
});
});
return btn;
};
split.appendChild(mkHalf('user', '👤', 'Игрок', 'первый ход после инъекта', 'user'));
split.appendChild(mkHalf('character', '🤖', 'Персонаж', 'открывает новую арку', 'character'));
panel.appendChild(split);
row.appendChild(panel);
wrapper.appendChild(row);
ensureMessageActionsLast(wrapper);
}
export function renderChoices(wrapper, choices) {
if (!choices?.length || !wrapper) return;
removeChoiceRows(wrapper);
const plotChoices = choices.filter(c => c?.source === 'plot_beat');
const otherChoices = choices.filter(c => c?.source !== 'plot_beat');
const newArc = choices.find(c => c?.type === 'new_arc_roll');
if (newArc) {
renderNewArcRollButton(wrapper, newArc);
return;
}
const isPlot = c => c?.source === 'plot_beat' || c?.source === 'plot_step';
const plotChoices = choices.filter(isPlot);
const otherChoices = choices.filter(c => !isPlot(c));
const row = document.createElement('div');
row.className = 'choice-row';
@@ -167,7 +218,7 @@ export function renderChoices(wrapper, choices) {
const appendBtn = (container, c) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = c.source === 'plot_beat' ? 'choice-btn choice-btn-plot' : 'choice-btn';
btn.className = isPlot(c) ? 'choice-btn choice-btn-plot' : 'choice-btn';
const label = c.label || '';
btn.textContent = label;
if (c.beat_title) btn.title = c.beat_title;
@@ -251,10 +302,11 @@ function showNarratorActivityHint(wrapper, meta) {
if (meta.choices_count > 0) parts.push(`🔘 ${meta.choices_count} выборов`);
if (meta.status_quo) parts.push('🌍 status_quo');
if (meta.beats_replenished) parts.push(`📜 +${meta.beats_replenished} beats`);
if (meta.beat_mode === 'after_dice') parts.push('📜 beat (d20)');
if (meta.beat_mode === 'llm') parts.push('📜 beat (AI)');
if (meta.beat_mode === 'stuck_recovery') parts.push('📜 beat (recovery)');
if (meta.beat_mode === 'trigger') parts.push('📜 beat (keywords)');
if (meta.step_advanced) parts.push('📜 шаг +1');
if (meta.arc_completed) parts.push('🏁 арка завершена');
if (meta.new_arc_rolled) parts.push('📖 новая арка');
if (meta.story_step) parts.push(`📜 ${meta.story_step}`);
if (meta.rp_language) parts.push(`🌐 ${meta.rp_language}`);
if (meta.arc_pruned) parts.push(`🧹 ${meta.arc_pruned} beat`);
if (meta.facts_added) parts.push(`📌 +${meta.facts_added} фактов`);
}
@@ -299,13 +351,27 @@ function syncQuestActionButtons() {
if (failBtn) failBtn.disabled = !active;
}
export function updateQuestPanel(quests) {
export function updateQuestPanel(quests, storyArc = null) {
const list = document.getElementById('questList');
const actions = document.getElementById('questPanelActions');
const header = document.getElementById('questPanelHeader');
if (!list) return;
_questsCache = quests || [];
list.innerHTML = '';
if (header && storyArc) {
const steps = storyArc.steps || [];
const idx = Number(storyArc.current_step_index ?? 0);
const cur = steps.length ? Math.min(idx + 1, steps.length) : 0;
const title = storyArc.title || 'Арка';
const status = storyArc.status === 'completed' ? ' · завершена' : '';
header.textContent = `Арка: ${title} · шаг ${cur}/${steps.length || '?'}${status}`;
header.classList.remove('hidden');
} else if (header) {
header.textContent = '';
header.classList.add('hidden');
}
if (!_questsCache.length) {
_selectedQuestId = null;
syncQuestActionButtons();
@@ -577,7 +643,10 @@ export async function reloadChatFromServer(id) {
try {
const data = typeof m.content === 'string' ? JSON.parse(m.content) : m.content;
if (data?.text) renderNarratorMessage(data);
} catch { /* ignore bad narrator payload */ }
} catch {
const plain = (m.content || '').trim();
if (plain) renderNarratorMessage({ text: plain });
}
return;
}
addMessage(
@@ -694,8 +763,11 @@ async function consumeStream(res) {
data.assistant_message_id,
data.choices,
);
} else if (data.choices?.length && bubble) {
renderChoices(bubble.parentElement, data.choices);
} else if (data.choices?.length) {
const choiceHost = bubble?.parentElement
|| [...dom.messagesEl.querySelectorAll('.message.narrator')].pop()
|| [...dom.messagesEl.querySelectorAll('.message.assistant')].pop();
if (choiceHost) renderChoices(choiceHost, data.choices);
}
if (data.debug) renderDebugBlocks(bubble?.parentElement || dom.messagesEl, data.debug);
if (data.narrator_meta && bubble?.parentElement) {
@@ -703,7 +775,9 @@ async function consumeStream(res) {
}
if (data.affinity !== undefined) updateAffinityDisplay(data.affinity);
if (data.narrative_stats) updateStatsDisplay(data.narrative_stats);
if (data.quests?.length) updateQuestPanel(data.quests);
if (data.quests !== undefined) {
updateQuestPanel(data.quests, data.story_arc ?? null);
}
_pendingUserBubble = null;
@@ -822,21 +896,42 @@ export function clearMessages() {
}
}
export async function sendMessage(text, isNarratorChoice = false) {
export async function sendMessage(text, choiceOpts = false) {
if (typeof text !== 'string') text = dom.inputEl.value.trim();
if (!text || !sessionId) return;
let isNarratorChoice = false;
let newArcFirst = null;
if (typeof choiceOpts === 'boolean') {
isNarratorChoice = choiceOpts;
} else if (choiceOpts && typeof choiceOpts === 'object') {
isNarratorChoice = !!choiceOpts.isNarratorChoice;
newArcFirst = choiceOpts.newArcFirst || null;
}
dom.inputEl.value = '';
dom.inputEl.style.height = 'auto';
dom.sendBtn.disabled = true;
const userContent = isNarratorChoice ? `[Player chose: ${text}]` : text;
dom.messagesEl.querySelectorAll('.message.assistant').forEach(w => removeChoiceRows(w));
let userContent = text;
if (isNarratorChoice && newArcFirst) {
const who = newArcFirst === 'user' ? 'игрок' : 'персонаж';
userContent = `[Player chose: Начать новую арку — первый ход: ${who}]`;
} else if (isNarratorChoice) {
userContent = `[Player chose: ${text}]`;
}
dom.messagesEl.querySelectorAll('.message.assistant, .message.narrator').forEach(w => removeChoiceRows(w));
_pendingUserBubble = addMessage('user', userContent);
showTyping();
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, session_id: sessionId, is_narrator_choice: isNarratorChoice }),
body: JSON.stringify({
message: text,
session_id: sessionId,
is_narrator_choice: isNarratorChoice,
new_arc_first: newArcFirst,
}),
});
if (!res.ok) throw new Error('Ошибка сервера: ' + res.status);
removeTyping();
+1 -1
View File
@@ -80,7 +80,7 @@ async function bootstrapRpg(sid, personaId, genreValue, settings) {
const { updateAffinityDisplay } = await import('./chat.js');
updateAffinityDisplay(data.affinity);
}
if (data.quests) updateQuestPanel(data.quests);
if (data.quests) updateQuestPanel(data.quests, data.plot_arc ?? null);
if (data.plot_arc) {
const title = data.plot_arc.title || '';
const hint = data.plot_arc.next_beat_hint || '';
+1 -1
View File
@@ -225,7 +225,7 @@ export async function createNewChatFromWizard() {
await reloadChatFromServer(sid);
if (openingData?.quests?.length) {
updateQuestPanel(openingData.quests);
updateQuestPanel(openingData.quests, openingData.plot_arc ?? null);
}
if (openingData?.affinity !== undefined) {
updateAffinityDisplay(openingData.affinity);