/
alexware
/
InnerQuest
Обзор
Документация
Войти
/
alexware
/
InnerQuest
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/app.js
3 033 строки
134 KB
alexeyk222
Изменить систему хранения файлов проекта
31 дек 2025, 01:39
31 дек 2025, 01:39
49a670a
Код
Авторство
О чём код?
/* InnerQuest: City of Spheres Vanilla JS prototype (no frameworks). Entry: index.html; assets: styles/main.css, src/app.js Notes: - No external libraries, no network. - Uses localStorage for best score and autosave of current run. */ /* ------------------------------ Data (from your JSON + minimal additions) ------------------------------ */ const DATA = (() => { // District level packs (based on uploaded JSON) const OASIS = { district: "oasis", name: "Оазис", theme: "health", philosophy: "Тело как союзник, а не инструмент", levels: [ { level_id: "oasis_1", name: "Базовый сигнал", sessions_required: [1,2], binary_tree_id: "oasis_body_check", task: { type:"timer", prompt:"Сканирование тела: закрой глаза. Где тяжесть? Где тепло? Просто замечай.", duration:30, guidance:"Дыши спокойно. Голова → шея → плечи → грудь → живот → ноги." }, rewards: { stability_points:5, effort:1, cards:["skill_breath_anchor"], fog_reduction:20 } }, { level_id: "oasis_2", name: "Границы усталости", sessions_required: [3,5], binary_tree_id: "oasis_rest_vs_pause", task: { type:"reflection", prompt:"Назови ОДИН ранний признак усталости (до того, как совсем плохо).", examples:["Зевота","Раздражение","Тяжесть в глазах"], min_words:5 }, rewards: { stability_points:7, effort:1, cards:["relic_energy_barometer"], unlocks:["early_warning_system"] } }, { level_id: "oasis_3", name: "Микроритуалы восстановления", sessions_required: [6,10], binary_tree_id: "oasis_recovery_rituals", task: { type:"choice", prompt:"Выбери ритуал восстановления (5 минут) и сделай его сейчас:", requires_completion:true, options:[ { id:"movement", text:"Движение: 2 мин потянуться + покрутиться", duration:120 }, { id:"silence", text:"Тишина: 3 мин без экранов, смотреть в окно", duration:180 }, { id:"water", text:"Вода: умыть лицо, выпить стакан медленно", duration:180 }, ]}, rewards: { stability_points:10, effort:2, cards:["contract_7_days_pause"] } }, { level_id: "oasis_4", name: "Инфраструктура здоровья", sessions_required: [11,999], fork:true, paths:[ { path_id:"sleep_path", name:"Фокус: Сон", task:{ type:"time_commitment", prompt:"Выбери время отбоя (одно и то же ±30 мин). Поставь напоминание за час.", input_type:"time" }, reward_card:"companion_body_gardener_sleep" }, { path_id:"movement_path", name:"Фокус: Движение", task:{ type:"habit_anchor", prompt:"Выбери: зарядка/прогулка/танец. Привяжи к существующей привычке.", example:"После утреннего кофе — 10 мин зарядка" }, reward_card:"companion_body_gardener_movement" }, { path_id:"food_path", name:"Фокус: Питание", task:{ type:"choice", prompt:"Реши: завтракаешь всегда ИЛИ никогда. Середины не бывает.", options:["Всегда завтракаю","Никогда не завтракаю"] }, reward_card:"companion_body_gardener_food" }, ], rewards: { stability_points:15, effort:3 } }, ], boss: { boss_id:"burnout_loop", name:"Перегрев", trigger:{ type:"pattern", condition:"sessions_without_rest", threshold:3 }, effects:{ penalty:-3, fog_spread:["citadel","garden"], blocks_positive_replies:true }, defeat_conditions:[ { type:"series", action:"micro_rituals", count:3 }, { type:"full_session", district:"oasis", focus:"rest" }, { type:"card", card_id:"skill_water_slowly" }, ] } }; const CITADEL = { district:"citadel", name:"Цитадель", theme:"work", philosophy:"Достаточно хорошо лучше, чем идеально никогда", levels:[ { level_id:"citadel_1", name:"Объём vs Качество", sessions_required:[1,3], binary_tree_id:"citadel_volume_quality", task:{ type:"choice", prompt:"Выбери 1 задачу на сегодня и упрости её: убери 20% требований или сократи объём", placeholder:"Например: «Не 20 слайдов, а тезисы на 1 страницу»", validation:"min_length:10" }, rewards:{ stability_points:5, effort:1, cards:["skill_coffee_support"], fog_reduction:20 } }, { level_id:"citadel_2", name:"Граница «достаточно»", sessions_required:[4,7], binary_tree_id:"citadel_80_percent", task:{ type:"reflection", prompt:"Опиши задачу, которую сдашь на 80% вместо 100%. Что произойдёт?", min_words:30, timer:120 }, rewards:{ stability_points:8, effort:2, cards:["relic_lens_focus"], unlocks:["path_choice"] } }, { level_id:"citadel_3", name:"Планирование волнами", sessions_required:[8,12], fork:true, paths:[ { path_id:"smith_boundaries", name:"Путь Кузнеца Границ", description:"Учишься резать лишнее и говорить «нет»", task:{ type:"checklist", prompt:"Назови 3 вещи, которые делаешь «потому что надо». Выбери 1 для отказа.", items:3 }, reward_card:"companion_smith" }, { path_id:"architect_rhythm", name:"Путь Архитектора Ритма", description:"Учишься планировать волнами с пиками и спадами", task:{ type:"weekly_plan", prompt:"Построй недельный ритм: где пик, где спад, где пауза?", template:["Пн","Вт","Ср","Чт","Пт","Сб","Вс"] }, reward_card:"companion_architect" }, ], rewards:{ stability_points:12, effort:3, cards:["contract_deadline_boundary"] } }, { level_id:"citadel_4", name:"Делегирование и «нет»", sessions_required:[13,999], binary_tree_id:"citadel_delegation", task:{ type:"delegation", prompt:"Найди 1 задачу для делегирования/отказа/упрощения. Кому? Или какая фраза «нет»?", options:["Делегировать","Отказаться","Упростить"] }, rewards:{ stability_points:15, effort:3, skill_unlocked:"soft_no" } }, ], boss:{ boss_id:"accountant_deadlines", name:"Счётовод Дедлайнов", trigger:{ type:"pattern", condition:"tasks_not_done_perfectionism", threshold:3 }, effects:{ penalty:-5, blocks:["simplify","80_percent"], fog_increase:30 }, defeat_conditions:[ { type:"series", action:"done_good_enough", count:3 }, { type:"card", card_id:"contract_deadline_boundary" }, ] } }; const ARSENAL = { district:"arsenal", name:"Арсенал", theme:"finance", philosophy:"Ясность важнее количества", levels:[ { level_id:"arsenal_1", name:"Одна ясная цифра", sessions_required:[1,2], binary_tree_id:"arsenal_one_number", task:{ type:"number_input", prompt:"Посмотри реальную цифру: остаток/долг/расход. Напиши одно число.", validation:"is_number", guidance:"Открой банк-приложение. Одна цифра — это опора." }, rewards:{ stability_points:5, effort:1, cards:["skill_anchor_point"], fog_reduction:20 } }, { level_id:"arsenal_2", name:"Карта ресурсов", sessions_required:[3,6], binary_tree_id:"arsenal_resource_map", task:{ type:"top_three", prompt:"Выпиши 3 самые большие статьи расходов. Выбери 1 для уменьшения на 10–20%.", items:3, requires_choice:true }, rewards:{ stability_points:7, effort:2, cards:["relic_accounting_seal"], unlocks:["resource_visualization"] } }, { level_id:"arsenal_3", name:"Один шаг порядка", sessions_required:[7,10], binary_tree_id:"arsenal_control_step", task:{ type:"choice", prompt:"Выбери ОДНО действие для порядка:", options:[ { id:"autopay", text:"Автоплатёж", followup:"На что? Настрой прямо сейчас (2–5 минут)" }, { id:"saving", text:"Накопление", followup:"Сколько в месяц? Автопереводом в день зарплаты" }, { id:"debt_payment", text:"Погашение долга", followup:"Фиксированная сумма в месяц. Какая?" } ]}, rewards:{ stability_points:10, effort:2, cards:["contract_clear_number"] } }, { level_id:"arsenal_4", name:"Достаточность vs жадность", sessions_required:[11,999], binary_tree_id:"arsenal_enough", task:{ type:"calculation", prompt:"Посчитай «сумму спокойствия»: базовые расходы + 20% запас", calculation:"sum * 1.2", fields:[ {name:"rent",label:"Аренда"},{name:"food",label:"Еда"},{name:"bills",label:"Счета"},{name:"other",label:"Другое"} ]}, rewards:{ stability_points:15, effort:3, cards:["companion_keeper_enough"] } }, ], boss:{ boss_id:"collector_anxiety", name:"Коллектор Тревоги", trigger:{ type:"pattern", condition:"sessions_without_numbers", threshold:3 }, effects:{ penalty:-4, anxiety_multiplier:1.5, blocks_rational_replies:true }, defeat_conditions:[ { type:"concrete_numbers", count:3 }, { type:"contract_completion", contract:"contract_clear_number" }, { type:"full_inventory", category:"financial" }, ] } }; const FORUM = { district:"forum", name:"Форум", theme:"relationships", philosophy:"Связь важнее правоты", unlock_requirement:{ stability_points:50 }, levels:[ { level_id:"forum_1", name:"Инвентарь связей", sessions_required:[1,3], binary_tree_id:"forum_connection_inventory", task:{ type:"people_list", prompt:"Назови 3 человек, с кем ты «собой». Когда контактировал последний раз?", fields:[{name:"person1", last_contact:"date"},{name:"person2", last_contact:"date"},{name:"person3", last_contact:"date"}], action:"Если >1 месяца — напиши одному короткое «Привет, как ты?»" }, rewards:{ stability_points:5, effort:1, cards:["skill_connection_anchor"], fog_reduction:20 } }, { level_id:"forum_2", name:"Мягкие границы", sessions_required:[4,7], binary_tree_id:"forum_soft_boundaries", task:{ type:"honest_phrase", prompt:"Вспомни, где сказал «да» вместо «нет». Напиши честную фразу, начинающуюся с «Мне сейчас трудно…»", template:"Мне сейчас трудно…", min_words:10 }, rewards:{ stability_points:8, effort:2, cards:["relic_boundary_compass"], new_replies:["soft_boundary","honest_no"] } }, { level_id:"forum_3", name:"Запрос и предложение", sessions_required:[8,11], binary_tree_id:"forum_ask_give", task:{ type:"action_commitment", prompt:"Выбери близкого человека. Попроси что-то маленькое (совет/5 минут/вещь) без извинений.", validation:"person + request", example:"«Маша, можешь помочь с выбором?» — достаточно." }, rewards:{ stability_points:10, effort:2, cards:["contract_reciprocity_bridge"] } }, { level_id:"forum_4", name:"Ремонт разрывов", sessions_required:[12,999], binary_tree_id:"forum_repair_breaks", fork:true, paths:[ { path_id:"restore", name:"Восстановить связь", task:{ type:"letter", prompt:"Напиши сообщение (пока не отправляй). Формула: «Я помню [хорошее]. Мне жаль [конфликт]. Хочу [что хочешь].»", template:true } }, { path_id:"let_go", name:"Отпустить", task:{ type:"ritual", prompt:"Напиши письмо (не отправляй). Ритуал закрытия: порви/удали/спрячь. Цель — завершить.", ritual_type:"closure" } }, ], rewards:{ stability_points:15, effort:3, cards:["companion_line_cartographer"] } }, ], boss:{ boss_id:"commentator_comparison", name:"Комментатор Сравнения", trigger:{ type:"pattern", condition:"comparison_thoughts", threshold:3 }, effects:{ penalty:-3, blocks_positive_achievements:true, districts_affected:["forum","garden"] }, defeat_conditions:[ { type:"skill_activation", skill:"compare_with_self" }, { type:"companion_session", companion:"line_cartographer" }, { type:"reflection_complete", theme:"own_uniqueness" }, ] } }; const GARDEN = { district:"garden", name:"Сад", theme:"personal", philosophy:"Тихая инфраструктура вместо рывков", levels:[ { level_id:"garden_1", name:"Что питает?", sessions_required:[1,3], binary_tree_id:"garden_what_feeds", task:{ type:"reflection", prompt:"Напиши 2 абзаца: «Что даёт мне энергию?» (не обязательно продуктивное).", min_words:50, guidance:"Игра/танец/рисование/чтение… то, от чего ощущаешь «да, это моё»." }, rewards:{ stability_points:5, effort:1, cards:["skill_two_paragraphs"], lore_chance:0.1 } }, { level_id:"garden_2", name:"Микропривычки", sessions_required:[4,8], binary_tree_id:"garden_micro_habits", task:{ type:"habit_creation", prompt:"Выбери привычку на 2 минуты. Привяжи к существующему действию.", template:"После [X] я делаю [новое]", example:"После утреннего кофе я пишу 1 предложение", duration:120 }, rewards:{ stability_points:7, effort:2, cards:["relic_anchor_ritual"], unlocks:["streak_tracking"] } }, { level_id:"garden_3", name:"Обучение vs перформанс", sessions_required:[9,13], binary_tree_id:"garden_learning_performance", task:{ type:"experiment", prompt:"Выбери что-то новое (не важное). Сделай ПЛОХО нарочно. Цель: опыт, не результат.", examples:["Нарисуй кривой круг","Напиши корявый абзац","Станцуй неловко"], reflection:"Что случилось? Мир рухнул?" }, rewards:{ stability_points:10, effort:2, cards:["contract_novice_path"] } }, { level_id:"garden_4", name:"Система устойчивости", sessions_required:[14,999], binary_tree_id:"garden_resilience_system", task:{ type:"system_of_three", prompt:"Построй «Систему 3»:", fields:[ {name:"anchor", label:"Якорь: одна привычка (каждый день)"}, {name:"connection", label:"Связь: один человек (регулярный контакт)"}, {name:"place", label:"Опора: место/ритуал (где возвращаешься к себе)"}, ] }, rewards:{ stability_points:15, effort:3, cards:["relic_legendary_quiet_infrastructure"], unlocks:["act_v_finale"] } }, ], boss:{ boss_id:"archivist_noise", name:"Архивариус Шума", trigger:{ type:"milestone", condition:"all_districts_completed", act:5 }, effects:{ penalty:-5, all_districts_fog:true, combines_all_hazards:true, blocks_most_cards:true }, defeat_conditions:[ { type:"series_with_system", actions:["anchor","connection","place"], count:3 }, { type:"card", card_id:"relic_legendary_quiet_infrastructure" }, { type:"endure", sessions:3, description:"Не победить, а пережить" }, ], finale:true, dialogue:{ archivist:"Ты не можешь меня победить. Я — хаос мира. Я вечен.", aira:"Не нужно побеждать. Нужно построить то, что шум переживает.", victory:"Шум не ушёл. Но ты теперь устойчив. Это и есть победа." } } }; // Boss list (from uploaded bosses.json; kept as canonical references for names/acts) const BOSSES = [ { boss_id:"ping_whisper", name:"Шепот Пинга", act:1, district:"all", trigger:{ type:"act_progression", act:1, sessions_in_act:5 }, effects:{ distraction_increase:30, focus_penalty:-2, hazard_spawn:"notification_spam" }, defeat_conditions:[ {type:"skill_usage", count:5}, {type:"focus_maintained", sessions:3} ], dialogue:{ appearance:"Пинг... пинг... пинг... Внимание рассеивается...", defeat:"Тишина. Шёпот утих. Ты научился не реагировать." } }, { boss_id:"accountant_deadlines", name:"Счётовод Дедлайнов", act:2, district:"citadel", trigger:{ type:"pattern", condition:"tasks_not_done_perfectionism", threshold:3 }, effects:{ penalty:-5, blocks:["simplify_option","80_percent_option"], fog_increase:30, districts_affected:["citadel"] }, defeat_conditions:[ {type:"series", action:"done_good_enough", count:3}, {type:"card_activation", card_id:"contract_deadline_boundary"} ], dialogue:{ appearance:"100% или провал. Третьего не дано.", defeat:"Счётовод исчез. Достаточно хорошо — это и есть хорошо." } }, { boss_id:"collector_anxiety", name:"Коллектор Тревоги", act:3, district:"arsenal", trigger:{ type:"pattern", condition:"sessions_without_numbers", threshold:3 }, effects:{ penalty:-4, anxiety_multiplier:1.5, blocks_rational_replies:true, districts_affected:["arsenal","citadel"] }, defeat_conditions:[ {type:"concrete_numbers", count:3}, {type:"contract_completion", contract:"contract_clear_number"}, {type:"full_inventory", category:"financial"} ], dialogue:{ appearance:"Ты не знаешь... Ты не уверен... Всё может рухнуть...", defeat:"Цифры развеяли туман. Коллектор растворился в ясности." } }, { boss_id:"commentator_comparison", name:"Комментатор Сравнения", act:4, district:"forum", trigger:{ type:"thought_pattern", pattern:"comparison", threshold:3 }, effects:{ penalty:-3, blocks_positive_achievements:true, hazard:"comparison", districts_affected:["forum","garden"] }, defeat_conditions:[ {type:"skill_activation", skill:"compare_with_self"}, {type:"companion_session", companion:"line_cartographer"}, {type:"reflection_complete", theme:"own_uniqueness"} ], dialogue:{ appearance:"Смотри, как у них хорошо. А у тебя... недостаточно.", defeat:"Комментатор затих. Твой путь — твой." } }, { boss_id:"archivist_noise", name:"Архивариус Шума", act:5, district:"all", trigger:{ type:"milestone", condition:"all_districts_level_3_plus", acts_completed:4 }, effects:{ penalty:-5, all_districts_fog:true, combines_hazards:["ping_whisper","burnout","comparison","anxiety"], blocks_most_cards:true }, defeat_conditions:[ { type:"endurance", sessions_with_system:3, system:["anchor_habit","regular_connection","safe_place"] }, { type:"card_legendary", card:"relic_legendary_quiet_infrastructure" }, { type:"philosophical_acceptance", understanding:"Шум не исчезает, но инфраструктура держит" }, ], dialogue:{ appearance:"Ты не можешь меня победить. Я — хаос мира. Я вечен.", aira_response:"Не нужно побеждать. Нужно построить то, что шум переживает.", victory:"Шум не ушёл. Но ты теперь устойчив. Это и есть победа.", unlocks:"guru_mode" } }, ]; // Binary trees (from your uploaded binary_trees.json + missing trees added) const TREES = { /* ------- From uploaded binary_trees.json (subset shown in conversation) ------- */ citadel_volume_quality: { tree_id:"citadel_volume_quality", district:"citadel", root:{ node_id:"root", type:"choice", text:"Работа давит. Но как? СЛИШКОМ МНОГО или СЛИШКОМ ИДЕАЛЬНО?", options:[ {text:"Слишком много", next:"too_much"}, {text:"Слишком идеально", next:"too_perfect"}, {text:"Оба разом", next:"both"} ] }, nodes:{ too_much:{ node_id:"too_much", type:"choice", text:"Что можно убрать? Не «сделать быстрее» — УБРАТЬ.", options:[ {text:"Не знаю", next:"list_all"}, {text:"Знаю", next:"name_one"} ] }, too_perfect:{ node_id:"too_perfect", type:"scale", text:"На скольки процентах качества ты обычно останавливаешься?", min:0, max:100, next:"quality_followup" }, both:{ node_id:"both", type:"choice", text:"Классика. Много + идеально = выгорание. Режем объём или снижаем планку?", options:[ {text:"Режем объём", next:"too_much"}, {text:"Снижаем планку", next:"too_perfect"} ] }, list_all:{ node_id:"list_all", type:"task_trigger", text:"Тогда список. Всё, что на тебе. Вычеркни 1 самое неважное.", task_type:"checklist" }, name_one:{ node_id:"name_one", type:"task_trigger", text:"Назови одну вещь. Конкретно. От чего откажешься?", task_type:"text_input" }, quality_followup:{ node_id:"quality_followup", type:"reflection", text:"А если 80%? Что случится?", leads_to:"task" }, } }, oasis_body_check: { tree_id:"oasis_body_check", district:"oasis", root:{ node_id:"root", type:"scale", text:"Как тело прямо сейчас? Оцени от 1 до 10.", min:1, max:10, branches:{ "1-3":"pain", "4-6":"fatigue", "7-10":"normal" } }, nodes:{ pain:{ node_id:"pain", type:"choice", text:"Где болит/тянет/неприятно? (Если это острое, необычное или пугает — лучше обратиться к специалисту.)", options:[ {text:"Голова", next:"head_pain"}, {text:"Спина/Шея", next:"back_pain"}, {text:"Живот/Грудь", next:"core_pain"} ] }, fatigue:{ node_id:"fatigue", type:"choice", text:"Это усталость сегодняшняя или накопленная?", options:[ {text:"Сегодняшняя", next:"sleep_check"}, {text:"Накопленная (дни/недели)", next:"chronic_fatigue"} ] }, normal:{ node_id:"normal", type:"choice", text:"Это стабильно или скачками?", options:[ {text:"Стабильно", next:"maintain"}, {text:"Скачками", next:"unstable"} ] }, head_pain:{ node_id:"head_pain", type:"task_trigger", text:"5 минут: закрой глаза и мягко помассируй виски круговыми движениями.", task_type:"timer", duration:300 }, back_pain:{ node_id:"back_pain", type:"task_trigger", text:"60 секунд: встань, расправь плечи, 10 кругов плечами.", task_type:"timer", duration:60 }, core_pain:{ node_id:"core_pain", type:"task_trigger", text:"30 секунд: заметь дыхание. Если есть тревога — сделай 5 спокойных вдохов.", task_type:"timer", duration:30 }, sleep_check:{ node_id:"sleep_check", type:"choice", text:"Сколько спал последнюю ночь?", options:[ {text:"< 6 часов", next:"sleep_debt"}, {text:"6–8 часов", next:"quality_check"}, {text:"> 8 часов", next:"oversleep"} ] }, chronic_fatigue:{ node_id:"chronic_fatigue", type:"choice", text:"Когда последний раз был полноценный отдых?", options:[ {text:"Давно", next:"burnout_warning"}, {text:"Недавно", next:"energy_drain"} ] }, burnout_warning:{ node_id:"burnout_warning", type:"task_trigger", text:"СТОП-сигнал. Запланируй сегодня реальную паузу 10 минут без экранов.", task_type:"timer", duration:600, urgent:true }, maintain:{ node_id:"maintain", type:"task_trigger", text:"Запиши 1–2 «якоря», которые поддерживают состояние (сон/еда/движение/ритм).", task_type:"text_input" }, unstable:{ node_id:"unstable", type:"task_trigger", text:"Назови 1 триггер «хуже» и 1 триггер «лучше».", task_type:"text_input" }, sleep_debt:{ node_id:"sleep_debt", type:"task_trigger", text:"Микрошаг: выбери время отбоя сегодня (±30 мин) и запиши его.", task_type:"text_input" }, quality_check:{ node_id:"quality_check", type:"task_trigger", text:"Микрошаг: что сегодня забрало энергию? Назови 1 фактор.", task_type:"text_input" }, oversleep:{ node_id:"oversleep", type:"task_trigger", text:"Микрошаг: 60 секунд без экрана, глоток воды, 3 глубоких вдоха.", task_type:"timer", duration:60 }, energy_drain:{ node_id:"energy_drain", type:"task_trigger", text:"Микрошаг: выбери квартал, который «съедает» ресурс. Это будет следующая цель.", task_type:"district_choice" }, } }, arsenal_one_number: { tree_id:"arsenal_one_number", district:"arsenal", root:{ node_id:"root", type:"open_or_choice", text:"Деньги. Назови ОДНО число: остаток / долг / цель / расход.", allows_input:true, fallback_options:[ {text:"Не знаю / страшно смотреть", next:"fear_of_numbers"}, {text:"Много разных чисел", next:"too_many"} ] }, nodes:{ fear_of_numbers:{ node_id:"fear_of_numbers", type:"task_trigger", text:"Неопределённость страшнее правды. Открой банк и посмотри 1 цифру.", task_type:"look_at_balance" }, too_many:{ node_id:"too_many", type:"task_trigger", text:"Выбери ОДНУ — самую тревожную. Запиши её.", task_type:"pick_most_anxious" }, } }, forum_connection_inventory: { tree_id:"forum_connection_inventory", district:"forum", root:{ node_id:"root", type:"open_or_choice", text:"С кем ты чувствуешь себя собой? Без роли. Без маски.", allows_input:true, fallback_options:[ {text:"Не знаю / никого", next:"loneliness"}, {text:"Раньше были, сейчас нет", next:"lost_connections"} ] }, nodes:{ loneliness:{ node_id:"loneliness", type:"choice", text:"Тогда иначе: хочешь близости или нужна пауза?", options:[ {text:"Хочу близости", next:"build_bridges"}, {text:"Нужна пауза", next:"planned_pause"} ] }, lost_connections:{ node_id:"lost_connections", type:"choice", text:"Что случилось?", options:[ {text:"Переезд", next:"new_place"}, {text:"Конфликт", next:"repair"}, {text:"Постепенное отдаление", next:"drift"} ] }, drift:{ node_id:"drift", type:"task_trigger", text:"Напиши одному короткое «Привет, как ты?»", task_type:"reach_out" }, build_bridges:{ node_id:"build_bridges", type:"task_trigger", text:"Назови 1 человека, кому можно написать «Привет».", task_type:"text_input" }, planned_pause:{ node_id:"planned_pause", type:"task_trigger", text:"Отметь: когда ты вернёшься к людям (дата/день).", task_type:"text_input" }, new_place:{ node_id:"new_place", type:"task_trigger", text:"Назови 1 место/сообщество, где можно встретить «своих».", task_type:"text_input" }, repair:{ node_id:"repair", type:"task_trigger", text:"Назови конфликт: с кем и что болит? Одной фразой.", task_type:"text_input" }, } }, garden_what_feeds: { tree_id:"garden_what_feeds", district:"garden", root:{ node_id:"root", type:"open_or_choice", text:"От чего ты чувствуешь «да, это МОЁ»? (Не чужое одобрение.)", allows_input:true, fallback_options:[ {text:"Не знаю", next:"childhood"}, {text:"Раньше знал, сейчас забыл", next:"what_changed"} ] }, nodes:{ childhood:{ node_id:"childhood", type:"task_trigger", text:"Вспомни 2–3 занятия из детства «просто так». Что можно вернуть на 2 минуты?", task_type:"reflection" }, what_changed:{ node_id:"what_changed", type:"choice", text:"Что изменилось?", options:[ {text:"Нет времени", next:"time_priority"}, {text:"Стыдно", next:"shame_block"}, {text:"Не кажется важным", next:"value_shift"} ] }, time_priority:{ node_id:"time_priority", type:"task_trigger", text:"Микрошаг: найди 2 минуты в календаре завтра.", task_type:"text_input" }, shame_block:{ node_id:"shame_block", type:"task_trigger", text:"Микрошаг: сделай «плохо» и незаметно. Только для себя.", task_type:"text_input" }, value_shift:{ node_id:"value_shift", type:"task_trigger", text:"Микрошаг: допиши фразу «Для меня важно…»", task_type:"text_input" }, } }, /* ------- Missing trees (minimal but consistent with the system) ------- */ oasis_rest_vs_pause: mkSimpleChoiceTree("oasis_rest_vs_pause", "oasis", "Ты на паузе или на отдыхе?", ["Пауза (на 2–10 минут)", "Отдых (нужен глубже)", "Не знаю"], ["pause", "rest", "unknown"], { pause: { type:"task_trigger", text:"Поставь таймер на 2 минуты: тишина без экранов.", task_type:"timer", duration:120 }, rest: { type:"task_trigger", text:"Назови 1 действие восстановления на сегодня (маленькое).", task_type:"text_input" }, unknown: { type:"task_trigger", text:"Назови 1 симптом усталости (слово/фраза).", task_type:"text_input" }, } ), oasis_recovery_rituals: mkSimpleChoiceTree("oasis_recovery_rituals", "oasis", "Что быстрее вернёт тебя в тело прямо сейчас?", ["Движение", "Тишина", "Вода"], ["move","silence","water"], { move: { type:"task_trigger", text:"2 минуты: потянись и сделай 10 кругов плечами.", task_type:"timer", duration:120 }, silence: { type:"task_trigger", text:"3 минуты: смотреть в окно, не трогая телефон.", task_type:"timer", duration:180 }, water: { type:"task_trigger", text:"3 минуты: выпить стакан воды медленно.", task_type:"timer", duration:180 }, } ), citadel_80_percent: mkSimpleChoiceTree("citadel_80_percent", "citadel", "Если бы задача была на 80%, что бы ты точно выкинул?", ["Сократить объём", "Снизить требования", "Сделать черновик"], ["scope","requirements","draft"], { scope: { type:"task_trigger", text:"Назови 1 часть объёма, которую уберёшь.", task_type:"text_input" }, requirements: { type:"task_trigger", text:"Назови 1 требование, которое ослабишь.", task_type:"text_input" }, draft: { type:"task_trigger", text:"Сделай черновик: 5 пунктов. Прямо сейчас.", task_type:"checklist", items:5 }, } ), citadel_delegation: mkSimpleChoiceTree("citadel_delegation", "citadel", "Что сейчас разумнее: делегировать, отказаться или упростить?", ["Делегировать", "Отказаться", "Упростить"], ["deleg","decline","simplify"], { deleg: { type:"task_trigger", text:"Кому делегируешь? Напиши имя и 1 фразу запроса.", task_type:"text_input" }, decline:{ type:"task_trigger", text:"Напиши мягкое «нет» в 1–2 предложениях.", task_type:"text_input" }, simplify:{ type:"task_trigger", text:"Сократи задачу на 20%: что выкинешь?", task_type:"text_input" }, } ), arsenal_resource_map: mkSimpleChoiceTree("arsenal_resource_map", "arsenal", "Туман денег чаще про…", ["Неясные цифры", "Нехватку", "Долг", "Хватает, но тревожно"], ["unclear","lack","debt","anxious"], { unclear:{ type:"task_trigger", text:"Открой банк и посмотри 1 цифру расходов за месяц.", task_type:"number_input" }, lack:{ type:"task_trigger", text:"Назови 1 базовую статью, где можно срезать 10% (без героизма).", task_type:"text_input" }, debt:{ type:"task_trigger", text:"Запиши сумму долга и минимальный платёж (если есть).", task_type:"number_input" }, anxious:{ type:"task_trigger", text:"Назови «сумму спокойствия» (оценка) — любое число.", task_type:"number_input" }, } ), arsenal_control_step: mkSimpleChoiceTree("arsenal_control_step", "arsenal", "Один шаг порядка — какой сегодня реалистичнее?", ["Автоплатёж", "Автосбережение", "Погашение долга"], ["autopay","autosave","paydebt"], { autopay:{ type:"task_trigger", text:"На что включишь автоплатёж? Напиши 1 пункт.", task_type:"text_input" }, autosave:{ type:"task_trigger", text:"Сколько откладываешь в месяц? Напиши число.", task_type:"number_input" }, paydebt:{ type:"task_trigger", text:"Сколько в месяц отдашь на долг? Напиши число.", task_type:"number_input" }, } ), arsenal_enough: mkSimpleChoiceTree("arsenal_enough", "arsenal", "Сейчас тревога про…", ["Надо больше денег", "Надо больше ясности", "Надо больше контроля"], ["more","clarity","control"], { more:{ type:"task_trigger", text:"Назови 1 источник дохода/роста (идея, не план).", task_type:"text_input" }, clarity:{ type:"task_trigger", text:"Назови 1 цифру расходов за месяц.", task_type:"number_input" }, control:{ type:"task_trigger", text:"Выбери 1 регулярное правило: автоперевод/лимит/учёт.", task_type:"text_input" }, } ), forum_soft_boundaries: mkSimpleChoiceTree("forum_soft_boundaries", "forum", "Граница сейчас нужна где?", ["Времени", "Эмоций", "Ожиданий"], ["time","emotion","expect"], { time:{ type:"task_trigger", text:"Напиши фразу: «Я смогу после …» (конкретное время/дата).", task_type:"text_input" }, emotion:{ type:"task_trigger", text:"Напиши фразу: «Мне сейчас трудно, я вернусь к разговору позже».", task_type:"text_input" }, expect:{ type:"task_trigger", text:"Напиши фразу: «Я не обещаю, но могу…» (1 конкретное действие).", task_type:"text_input" }, } ), forum_ask_give: mkSimpleChoiceTree("forum_ask_give", "forum", "Проще сегодня: попросить или предложить?", ["Попросить", "Предложить", "Просто написать «привет»"], ["ask","give","hi"], { ask:{ type:"task_trigger", text:"Кому и о чём маленький запрос? 1–2 предложения.", task_type:"text_input" }, give:{ type:"task_trigger", text:"Кому и что маленькое предложение помощи? 1–2 предложения.", task_type:"text_input" }, hi:{ type:"task_trigger", text:"Напиши одно «Привет, как ты?» (можно без отправки).", task_type:"text_input" }, } ), forum_repair_breaks: mkSimpleChoiceTree("forum_repair_breaks", "forum", "Разрыв лечим или отпускаем?", ["Восстановить", "Отпустить", "Пока не знаю"], ["restore","letgo","unknown"], { restore:{ type:"task_trigger", text:"Напиши черновик сообщения по формуле: хорошее → жаль → хочу.", task_type:"letter" }, letgo:{ type:"task_trigger", text:"Напиши письмо без отправки: скажи всё, что не сказал.", task_type:"ritual" }, unknown:{ type:"task_trigger", text:"Назови 1 чувство, которое тут самое сильное.", task_type:"text_input" }, } ), garden_micro_habits: mkSimpleChoiceTree("garden_micro_habits", "garden", "Что легче превратить в микропривычку на 2 минуты?", ["Тело", "Ум", "Творчество"], ["body","mind","create"], { body:{ type:"task_trigger", text:"Выбери 2-мин действие: растяжка/прогулка/вода. Напиши одно.", task_type:"text_input" }, mind:{ type:"task_trigger", text:"2 минуты: выпиши 3 мысли и одну переформулируй мягче.", task_type:"checklist", items:4 }, create:{ type:"task_trigger", text:"2 минуты: набросай «плохой» рисунок/абзац. Назови что.", task_type:"text_input" }, } ), garden_learning_performance: mkSimpleChoiceTree("garden_learning_performance", "garden", "Выбирай «плохой эксперимент»:", ["Плохой рисунок", "Плохой абзац", "Неловкий танец"], ["draw","write","dance"], { draw:{ type:"task_trigger", text:"30–60 секунд: нарисуй кривой круг. Готово.", task_type:"timer", duration:60 }, write:{ type:"task_trigger", text:"60 секунд: напиши корявый абзац. Готово.", task_type:"timer", duration:60 }, dance:{ type:"task_trigger", text:"30 секунд: неловко подвигайся под музыку/тишину. Готово.", task_type:"timer", duration:30 }, } ), garden_resilience_system: mkSimpleChoiceTree("garden_resilience_system", "garden", "Система 3 строится из…", ["Якоря", "Связи", "Опоры-места"], ["anchor","connection","place"], { anchor:{ type:"task_trigger", text:"Назови 1 ежедневный якорь на 2 минуты.", task_type:"text_input" }, connection:{ type:"task_trigger", text:"Назови 1 человека для регулярного контакта.", task_type:"text_input" }, place:{ type:"task_trigger", text:"Назови 1 место/ритуал, где возвращаешься к себе.", task_type:"text_input" }, } ), }; // Cards database (from uploaded cards_database.json + minimal referenced cards) const CARD_TYPES = { skill:{ activation:"instant", duration_type:"seconds" }, relic:{ activation:"passive", duration_type:"sessions" }, contract:{ activation:"quest", duration_type:"sessions_or_deadline" }, lore:{ activation:"codex", duration_type:"permanent" }, companion:{ activation:"passive", duration_type:"sessions" }, hazard:{ activation:"automatic", duration_type:"until_removed" }, }; const CARDS = [ // from uploaded cards_database.json (subset) { card_id:"skill_coffee_support", name:"Кофейная опора", type:"skill", rarity:"common", district:"citadel", effect:{ stability_points:5, fog_reduction:{ district:"citadel", amount:1 } }, effort_cost:1, duration:60, activation:"instant", description:"Найди маленькую опору в работе. +5 устойчивости, снимает 1 туман." }, { card_id:"relic_lens_focus", name:"Линза Фокуса", type:"relic", rarity:"rare", district:"citadel", effect:{ passive:"first_microstep_bonus", bonus:2, cap:"once_per_session" }, effort_cost:3, duration_sessions:3, description:"Первый микрошаг в сессии даёт +2 устойчивости (1 раз/сессию)." }, { card_id:"contract_deadline_boundary", name:"Граница дедлайна", type:"contract", rarity:"epic", district:"citadel", condition:{ type:"choice_pattern", choice:"reduce_scope", count:2 }, reward:{ card:"companion_smith" }, effort_cost:6, deadline_sessions:10, description:"2 раза выбери «сократить объём». Награда: Кузнец Границ." }, { card_id:"skill_breath_anchor", name:"Якорь дыхания", type:"skill", rarity:"common", district:"oasis", effect:{ stability_points:4, stress_reduction:15 }, effort_cost:1, duration:30, activation:"guided", description:"5 спокойных вдохов. +4 устойчивости." }, { card_id:"relic_energy_barometer", name:"Барометр энергии", type:"relic", rarity:"common", district:"oasis", effect:{ passive:"early_warning", shows_fatigue_level:true }, effort_cost:2, duration_sessions:5, description:"Пассивно показывает усталость. Раннее предупреждение." }, { card_id:"skill_water_slowly", name:"Стакан воды без спешки", type:"skill", rarity:"common", district:"oasis", effect:{ stability_points:3, hazard_prevention:{ hazard:"focus_breakdown", reduction:0.3 } }, effort_cost:1, duration:180, description:"+3 устойчивости, снижает шанс «срыва фокуса» в следующей сессии." }, { card_id:"companion_line_cartographer", name:"Картограф Линий", type:"companion", rarity:"epic", district:"forum", effect:{ new_replies:["soft_boundary","clarifying_question"], passive_bonus:1 }, effort_cost:8, duration_sessions:5, description:"Открывает реплики «мягкая граница» и «уточняющий вопрос». +1 к связям." }, { card_id:"companion_smith", name:"Кузнец Границ", type:"companion", rarity:"epic", district:"citadel", effect:{ contract_cost_reduction:1, new_replies:["cut_excess","soft_no"] }, effort_cost:7, duration_sessions:5, description:"Снижает стоимость Contracts на 1 Effort. Учит «нет» с заботой." }, { card_id:"skill_two_paragraphs", name:"Два абзаца в дневник", type:"skill", rarity:"common", district:"garden", effect:{ stability_points:5, lore_unlock_chance:0.1 }, effort_cost:1, duration:300, description:"+5 устойчивости, может открыть Lore." }, { card_id:"relic_legendary_quiet_infrastructure", name:"Тихая инфраструктура", type:"relic", rarity:"legendary", district:"garden", effect:{ passive:"hazard_to_lore", frequency:"once_per_week" }, effort_cost:15, description:"Преобразует один Hazard в Lore. Финальная карта." }, { card_id:"hazard_burnout", name:"Перегрев", type:"hazard", rarity:"common", effect:{ penalty:-2, persists_until:"oasis_skill_used" }, trigger:{ type:"pattern", condition:"no_rest", threshold:3 }, description:"-2 к наградам, пока не сыгран навык Оазиса." }, { card_id:"hazard_comparison", name:"Сравнение", type:"hazard", rarity:"rare", district:"forum", effect:{ blocks_positive_replies:true, penalty:-2 }, trigger:{ type:"thought_pattern", pattern:"comparison", threshold:3 }, removal:{ skill:"compare_with_self" }, description:"Блокирует позитивные реплики. Снимается навыком «сверка с собой»." }, // --- minimal additions for referenced ids --- { card_id:"skill_anchor_point", name:"Якорная точка", type:"skill", rarity:"common", district:"arsenal", effect:{ stability_points:4, flags:["numbers_clarity"] }, effort_cost:1, duration:30, description:"Одна цифра без оценки. +4 устойчивости." }, { card_id:"relic_accounting_seal", name:"Печать учёта", type:"relic", rarity:"rare", district:"arsenal", effect:{ passive:"effort_bonus_on_number_tasks", bonus:1, cap:"once_per_session" }, effort_cost:3, duration_sessions:5, description:"Если в сессии была конкретная цифра — +1 Effort (1 раз/сессию)." }, { card_id:"contract_clear_number", name:"Контракт ясной цифры", type:"contract", rarity:"epic", district:"arsenal", condition:{ type:"numbers", count:3 }, reward:{ card:"companion_keeper_enough" }, effort_cost:6, deadline_sessions:12, description:"Назови 3 конкретных числа в разных сессиях. Награда: Хранитель Достаточности." }, { card_id:"companion_keeper_enough", name:"Хранитель Достаточности", type:"companion", rarity:"epic", district:"arsenal", effect:{ passive_bonus:2, new_replies:["enough_check","calm_numbers"] }, effort_cost:7, duration_sessions:6, description:"+2 устойчивости за завершённые микрошаги. Учит «достаточно»." }, { card_id:"skill_connection_anchor", name:"Якорь связи", type:"skill", rarity:"common", district:"forum", effect:{ stability_points:4, flags:["connection_ping"] }, effort_cost:1, duration:60, description:"+4 устойчивости. Напоминает: связь — это инфраструктура." }, { card_id:"relic_boundary_compass", name:"Компас границ", type:"relic", rarity:"rare", district:"forum", effect:{ passive:"reduce_hazard_comparison", reduction:0.25 }, effort_cost:3, duration_sessions:5, description:"Снижает шанс «Сравнения»." }, { card_id:"contract_reciprocity_bridge", name:"Контракт взаимности", type:"contract", rarity:"epic", district:"forum", condition:{ type:"ask_or_give", count:2 }, reward:{ card:"companion_line_cartographer" }, effort_cost:6, deadline_sessions:12, description:"2 раза: «попросить» или «предложить». Награда: Картограф Линий." }, { card_id:"contract_7_days_pause", name:"Контракт паузы 7 дней", type:"contract", rarity:"rare", district:"oasis", condition:{ type:"micro_rest", count:3 }, reward:{ effort:2 }, effort_cost:4, deadline_sessions:14, description:"3 микропауз(ы) в разных сессиях. Награда: +2 Effort." }, { card_id:"relic_anchor_ritual", name:"Ритуал якоря", type:"relic", rarity:"common", district:"garden", effect:{ passive:"streak_bonus", bonus:1 }, effort_cost:2, duration_sessions:7, description:"+1 устойчивости за сессию при серии (streak)." }, { card_id:"contract_novice_path", name:"Контракт новичка", type:"contract", rarity:"rare", district:"garden", condition:{ type:"bad_experiments", count:2 }, reward:{ stability_points:6 }, effort_cost:4, deadline_sessions:12, description:"2 «плохих эксперимента». Награда: +6 устойчивости." }, { card_id:"companion_architect", name:"Архитектор Ритма", type:"companion", rarity:"epic", district:"citadel", effect:{ passive_bonus:2, new_replies:["plan_wave","protect_recovery"] }, effort_cost:7, duration_sessions:5, description:"Делает «волны» привычкой. +2 устойчивости за планирование." }, { card_id:"skill_compare_with_self", name:"Сверка с собой", type:"skill", rarity:"rare", district:"forum", effect:{ stability_points:6, removes_hazard:"hazard_comparison", flags:["compare_with_self"] }, effort_cost:3, duration:60, description:"Снимает «Сравнение». +6 устойчивости." }, { card_id:"hazard_notification_spam", name:"Спам уведомлений", type:"hazard", rarity:"common", effect:{ penalty:-1 }, description:"Рассеивает фокус. -1 к награде, пока не сыгран любой Skill." } ]; const DISTRICTS = { oasis:OASIS, citadel:CITADEL, arsenal:ARSENAL, forum:FORUM, garden:GARDEN }; return { DISTRICTS, TREES, CARDS, CARD_TYPES, BOSSES }; })(); function mkSimpleChoiceTree(tree_id, district, question, labels, keys, terminalMap){ const options = labels.map((text, i) => ({ text, next: keys[i] })); const nodes = {}; for (const k of keys){ const t = terminalMap[k]; nodes[k] = { node_id:k, ...t }; } return { tree_id, district, root: { node_id:"root", type:"choice", text:question, options }, nodes }; } /* ------------------------------ Storage Keys ------------------------------ */ const LS = { BEST: "IQ_BEST_SCORE", RUN: "IQ_AUTOSAVE_RUN_V1", }; /* ------------------------------ Assets (renamed, no spaces) ------------------------------ */ const ASSETS = { aira: "assets/img/aira.jpg", city_map: "assets/img/city_map.jpg", mist_overlay: "assets/img/mist_overlay.jpg", noise_cloud: "assets/img/noise_cloud.jpg", boss_noise_archivist: "assets/img/boss_noise_archivist.jpg", citadel_library: "assets/img/district_citadel_library.jpg", arsenal_treasury: "assets/img/district_arsenal_treasury.jpg", forum_plaza: "assets/img/district_forum_plaza.jpg", garden_twilight: "assets/img/district_garden_twilight.jpg", oasis_garden: "assets/img/district_oasis_garden.jpg", card_coffee: "assets/img/card_coffee.jpg", card_lens: "assets/img/card_lens.jpg", npc_cartographer: "assets/img/npc_cartographer.jpg", }; const DISTRICT_ART = { oasis: ASSETS.oasis_garden, citadel: ASSETS.citadel_library, arsenal: ASSETS.arsenal_treasury, forum: ASSETS.forum_plaza, garden: ASSETS.garden_twilight, }; // Key cards get explicit illustration; others fallback by district / type. const CARD_ART = { skill_coffee_support: ASSETS.card_coffee, relic_lens_focus: ASSETS.card_lens, companion_line_cartographer: ASSETS.npc_cartographer, }; /* ------------------------------ State ------------------------------ */ const DEFAULT_RUN = () => ({ run_id: String(Date.now()), act: 1, sessions_total: 0, sessions_in_act: 0, stability: 0, effort: 0, fog: { oasis: 100, citadel: 100, arsenal: 100, forum: 100, garden: 100 }, district_sessions: { oasis:0, citadel:0, arsenal:0, forum:0, garden:0 }, completed_levels: { oasis:[], citadel:[], arsenal:[], forum:[], garden:[] }, cards_owned: [], // ids hazards_active: [], // hazard card ids relics_active: [], // relic card ids companions_active: [], // companion card ids contracts: {}, // contract_id -> {progress, done} streak: 0, last_session_date: null, boss_active: null, // boss_id boss_progress: {}, // counters flags: {}, // misc markers (e.g. compare_with_self, numbers_named) log: [], guru_unlocked: false }); let state = null; /* ------------------------------ DOM ------------------------------ */ const dom = { screenStart: document.getElementById("screenStart"), screenGame: document.getElementById("screenGame"), screenResult: document.getElementById("screenResult"), btnStartNew: document.getElementById("btnStartNew"), btnContinue: document.getElementById("btnContinue"), btnNewRun: document.getElementById("btnNewRun"), btnOpenPack: document.getElementById("btnOpenPack"), uiAct: document.getElementById("uiAct"), uiSessions: document.getElementById("uiSessions"), uiStability: document.getElementById("uiStability"), uiEffort: document.getElementById("uiEffort"), uiFogAll: document.getElementById("uiFogAll"), uiContextLine: document.getElementById("uiContextLine"), chatLog: document.getElementById("chatLog"), questionPane: document.getElementById("questionPane"), taskPane: document.getElementById("taskPane"), shelfList: document.getElementById("shelfList"), uiContractsLine: document.getElementById("uiContractsLine"), uiHazardsLine: document.getElementById("uiHazardsLine"), toast: document.getElementById("toast"), modalHost: document.getElementById("modalHost"), sessionFade: document.getElementById("sessionFade"), sessionFadeLine: document.getElementById("sessionFadeLine"), bossBadge: document.getElementById("bossBadge"), freeInput: document.getElementById("freeInput"), btnSendFree: document.getElementById("btnSendFree"), uiScore: document.getElementById("uiScore"), uiBest: document.getElementById("uiBest"), btnPlayAgain: document.getElementById("btnPlayAgain"), btnBackToStart: document.getElementById("btnBackToStart"), }; /* ------------------------------ Init ------------------------------ */ init(); function init(){ const saved = safeJsonParse(localStorage.getItem(LS.RUN)); if (saved && saved.run_id){ dom.btnContinue.style.display = "inline-flex"; } dom.btnStartNew.addEventListener("click", () => startNewRun()); dom.btnContinue.addEventListener("click", () => { const loaded = safeJsonParse(localStorage.getItem(LS.RUN)); if (!loaded) return startNewRun(); state = loaded; enterGame(); }); dom.btnNewRun.addEventListener("click", () => confirmNewRun()); dom.btnOpenPack.addEventListener("click", () => openPack()); // District clicks document.querySelectorAll(".district").forEach(btn => { btn.addEventListener("click", () => { const id = btn.dataset.district; tryStartSession(id); }); }); dom.btnPlayAgain.addEventListener("click", () => startNewRun()); dom.btnBackToStart.addEventListener("click", () => showScreen("start")); dom.btnSendFree.addEventListener("click", () => sendGuruQuestion()); dom.freeInput.addEventListener("keydown", (e) => { if (e.key === "Enter") sendGuruQuestion(); }); showScreen("start"); } /* ------------------------------ Screens ------------------------------ */ function showScreen(which){ dom.screenStart.classList.remove("screen--active"); dom.screenGame.classList.remove("screen--active"); dom.screenResult.classList.remove("screen--active"); if (which === "start") dom.screenStart.classList.add("screen--active"); if (which === "game") dom.screenGame.classList.add("screen--active"); if (which === "result") dom.screenResult.classList.add("screen--active"); } function startNewRun(){ state = DEFAULT_RUN(); // Starter cards: small baseline kit grantCard("skill_breath_anchor"); grantCard("skill_coffee_support"); grantCard("skill_anchor_point"); grantCard("skill_connection_anchor"); grantCard("skill_two_paragraphs"); // Also grant compare skill (for Act4) as rare “drop” chance later, but keep it obtainable now via pack too. autosave(); enterGame(true); } function confirmNewRun(){ showModal({ title: "Новый забег?", text: "Текущий прогресс будет заменён автосейвом нового забега.", actions: [ { text:"Отмена", kind:"ghost", onClick: closeModal }, { text:"Начать заново", kind:"primary", onClick: () => { closeModal(); startNewRun(); } }, ] }); } function enterGame(fromFresh=false){ showScreen("game"); renderAll(); dom.chatLog.innerHTML = ""; dom.taskPane.style.display = "none"; dom.questionPane.innerHTML = ""; if (fromFresh){ airaSay([ "С возвращением в Город Сфер.", "Туман — это не враг. Это просто UI мира.", "Выбери квартал, и мы быстро найдём следующий микрошаг." ].join("\n")); } else { airaSay("Я здесь. Выбери квартал — или открой слот карт, если хочется усилиться."); } // Boss badge (if any) updateBossBadge(); // Guru controls dom.freeInput.disabled = !state.guru_unlocked; dom.btnSendFree.disabled = !state.guru_unlocked; } /* ------------------------------ Rendering ------------------------------ */ function renderAll(){ dom.uiAct.textContent = String(state.act); dom.uiSessions.textContent = String(state.sessions_total); dom.uiStability.textContent = String(state.stability); dom.uiEffort.textContent = String(state.effort); const avgFog = Math.round( (state.fog.oasis + state.fog.citadel + state.fog.arsenal + state.fog.forum + state.fog.garden) / 5 ); dom.uiFogAll.textContent = `${avgFog}%`; renderDistricts(); renderShelf(); renderMetaLines(); const packCost = packCostNow(); dom.btnOpenPack.textContent = `Открыть слот (−${packCost} Effort)`; dom.btnOpenPack.disabled = state.effort < packCost; } function renderDistricts(){ for (const did of Object.keys(DATA.DISTRICTS)){ const fog = clamp(state.fog[did], 0, 100); const lvl = getDistrictLevelIndex(did) + 1; const tagLvl = document.getElementById(`tagLvl_${did}`); const tagFog = document.getElementById(`tagFog_${did}`); const mist = document.getElementById(`mist_${did}`); tagLvl.textContent = `Lv ${lvl}`; tagFog.textContent = `Fog ${fog}%`; mist.style.opacity = String(mapFogToOpacity(fog)); // lock Forum until stability_points >= 50 const btn = document.querySelector(`.district[data-district="${did}"]`); if (did === "forum"){ const ok = state.stability >= (DATA.DISTRICTS.forum.unlock_requirement?.stability_points ?? 0); btn.disabled = !ok; btn.title = ok ? "" : "Форум откроется при 50 Stability"; } else { btn.disabled = false; btn.title = ""; } } } function renderShelf(){ const cards = state.cards_owned .map(id => getCard(id)) .filter(Boolean) .sort((a,b) => rankType(a.type) - rankType(b.type)); dom.shelfList.innerHTML = ""; if (cards.length === 0){ dom.shelfList.innerHTML = `<div class="muted small">Пока пусто. Но туман щедр к тем, кто делает микрошаги.</div>`; return; } for (const c of cards){ const el = document.createElement("div"); el.className = "card"; const rarityBadge = `badge badge--${c.rarity || "common"}`; const typeBadge = `badge`; const cost = (typeof c.effort_cost === "number") ? `Effort ${c.effort_cost}` : "—"; el.innerHTML = ` <div class="card__top"> <div> <div class="card__name">${escapeHtml(c.name)}</div> <div class="card__sub">${labelType(c.type)} · ${labelDistrict(c.district)} · ${cost}</div> </div> <div style="display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end;"> <span class="${rarityBadge}">${(c.rarity || "common").toUpperCase()}</span> <span class="${typeBadge}">${labelType(c.type)}</span> </div> </div> <div class="card__desc">${escapeHtml(c.description || "")}</div> <div class="card__art"></div> <div class="card__actions"></div> `; // Card detail (click anywhere except buttons) el.addEventListener("click", (ev) => { if (ev.target.closest("button")) return; showCardModal(c.card_id); }); const artDiv = el.querySelector(".card__art"); const art = getCardArt(c.card_id); if(art){ artDiv.style.backgroundImage = `url('${art}')`; } else { artDiv.style.display = "none"; } const actions = el.querySelector(".card__actions"); if (c.type === "skill"){ const btn = mkBtn("Разыграть", "soft", () => playSkill(c.card_id)); btn.disabled = (state.effort < (c.effort_cost || 0)) || (state.flags._skillUsedThisSession && state.flags._skillUsedThisSession[c.card_id]); actions.appendChild(btn); } if (c.type === "contract"){ const p = state.contracts[c.card_id]?.progress ?? 0; const done = !!state.contracts[c.card_id]?.done; actions.appendChild(mkBadge(done ? "Выполнено" : `Прогресс: ${p}`)); } if (c.type === "relic"){ const active = state.relics_active.includes(c.card_id); const btn = mkBtn(active ? "Активна" : "Надеть", active ? "ghost" : "soft", () => toggleRelic(c.card_id)); actions.appendChild(btn); } if (c.type === "companion"){ const active = state.companions_active.includes(c.card_id); const btn = mkBtn(active ? "С вами" : "Позвать", active ? "ghost" : "soft", () => toggleCompanion(c.card_id)); actions.appendChild(btn); } if (c.type === "hazard"){ const active = state.hazards_active.includes(c.card_id); actions.appendChild(mkBadge(active ? "Активен" : "Не активен")); } dom.shelfList.appendChild(el); } } function renderMetaLines(){ const activeContracts = Object.entries(state.contracts).filter(([,v]) => v && !v.done).map(([id,v]) => { const c = getCard(id); if (!c) return null; return `${c.name}(${v.progress || 0})`; }).filter(Boolean); dom.uiContractsLine.textContent = activeContracts.length ? `Contracts: ${activeContracts.join(" · ")}` : "Contracts: —"; const hazards = state.hazards_active.map(id => getCard(id)?.name).filter(Boolean); dom.uiHazardsLine.textContent = hazards.length ? `Hazards: ${hazards.join(" · ")}` : "Hazards: —"; } /* ------------------------------ Sessions ------------------------------ */ function tryStartSession(districtId){ // locking already applied in UI, but keep it safe: if (districtId === "forum" && state.stability < (DATA.DISTRICTS.forum.unlock_requirement?.stability_points ?? 0)){ toast("Форум откроется при 50 Stability."); return; } startSession(districtId); } function startSession(districtId){ clearQuestion(); hideTask(); // per-session flags state.flags._skillUsedThisSession = {}; state.flags._microstepDoneThisSession = false; state.flags._numberNamedThisSession = false; const district = DATA.DISTRICTS[districtId]; const level = pickCurrentLevel(districtId); dom.uiContextLine.textContent = `${district.name} · ${level.name}`; // Boss trigger check before starting dialogue (Act progression boss) maybeTriggerBossByAct(); airaSay(introLine(districtId, level)); runTreeDialogue(districtId, level); } function pickCurrentLevel(districtId){ const d = DATA.DISTRICTS[districtId]; const done = new Set(state.completed_levels[districtId] || []); const sessions = state.district_sessions[districtId] || 0; // Find first not completed whose min sessions reached for (const lvl of d.levels){ const min = (lvl.sessions_required?.[0] ?? 1); if (!done.has(lvl.level_id) && sessions + 1 >= min){ return lvl; } } // If all completed, keep last level as repeatable loop return d.levels[d.levels.length - 1]; } function runTreeDialogue(districtId, level){ // Choose tree id: forks use internal pre-choices (we’ll ask a fork selection question here) if (level.fork){ const forkQ = { text: `${level.name}. Выбирай путь:`, options: level.paths.map(p => ({ text: p.name, path_id: p.path_id })) }; renderChoiceQuestion(forkQ.text, forkQ.options.map(o => o.text), (choiceText) => { const picked = level.paths.find(p => p.name === choiceText) || level.paths[0]; youSay(choiceText); airaSay(picked.description ? `${picked.description}\n${airaToneHint(districtId)}` : airaToneHint(districtId)); // Create a synthetic “task” directly from fork path, with rewards + reward_card const task = Object.assign({}, picked.task); task._districtId = districtId; task._levelId = level.level_id; task._levelName = level.name; task._forkPath = picked.path_id; task._rewardCard = picked.reward_card || null; // show task immediately showTask(task, level, { forkPath: picked.path_id }); }); return; } const tree = DATA.TREES[level.binary_tree_id]; if (!tree){ airaSay("Я вижу туман, но не вижу дерева вопросов. Сделаем микрошаг напрямую."); const task = Object.assign({}, level.task, { _districtId:districtId, _levelId:level.level_id, _levelName:level.name }); showTask(task, level, {}); return; } // Walk tree state machine const ctx = { districtId, level, tree, nodeId: tree.root.node_id, answers: [] }; presentNode(ctx, tree.root); } function presentNode(ctx, node){ if (!node) return; if (node.type === "scale"){ renderScaleQuestion(node.text, node.min ?? 1, node.max ?? 10, (value) => { youSay(String(value)); ctx.answers.push({ node_id: node.node_id, value }); // branch by ranges if present let nextId = node.next; if (node.branches){ nextId = pickBranchByRange(node.branches, value); } if (!nextId){ // fallback: directly to task const task = Object.assign({}, ctx.level.task, { _districtId:ctx.districtId, _levelId:ctx.level.level_id, _levelName:ctx.level.name }); showTask(task, ctx.level, { answers: ctx.answers }); return; } const next = ctx.tree.nodes[nextId]; presentNode(ctx, next); }); return; } if (node.type === "choice"){ renderChoiceQuestion(node.text, node.options.map(o => o.text), (choiceText) => { youSay(choiceText); ctx.answers.push({ node_id: node.node_id, value: choiceText }); const opt = node.options.find(o => o.text === choiceText); const next = ctx.tree.nodes[opt.next]; presentNode(ctx, next); }); return; } if (node.type === "open_or_choice"){ renderOpenOrChoice(node.text, node.fallback_options || [], (payload) => { // payload: {mode:"input", value:"..."} or {mode:"choice", value:"..."} youSay(payload.value); ctx.answers.push({ node_id: node.node_id, value: payload.value }); if (payload.mode === "input"){ // interpret number naming for arsenal / number_input tasks if (looksLikeNumber(payload.value)){ state.flags._numberNamedThisSession = true; bumpBossCounter("concrete_numbers", 1); bumpPatternCounter("sessions_without_numbers", -999); // “we did numbers” } // proceed to level task const task = Object.assign({}, ctx.level.task, { _districtId:ctx.districtId, _levelId:ctx.level.level_id, _levelName:ctx.level.name }); showTask(task, ctx.level, { answers: ctx.answers }); } else { // choice fallback route const opt = node.fallback_options.find(o => o.text === payload.value); const next = ctx.tree.nodes[opt.next]; presentNode(ctx, next); } }); return; } if (node.type === "reflection"){ airaSay(node.text); // reflection leads to the level task (or node-defined) const task = Object.assign({}, ctx.level.task, { _districtId:ctx.districtId, _levelId:ctx.level.level_id, _levelName:ctx.level.name }); showTask(task, ctx.level, { answers: ctx.answers, reflectionSeed: node.text }); return; } if (node.type === "task_trigger"){ airaSay(node.text); const task = taskFromNode(node, ctx.level, ctx.districtId); showTask(task, ctx.level, { answers: ctx.answers, triggeredBy: node.node_id }); return; } // fallback const fallbackTask = Object.assign({}, ctx.level.task, { _districtId:ctx.districtId, _levelId:ctx.level.level_id, _levelName:ctx.level.name }); showTask(fallbackTask, ctx.level, { answers: ctx.answers }); } function taskFromNode(node, level, districtId){ // Node can specify task_type with duration etc. We map it onto our generic task renderer. const base = Object.assign({}, level.task); const t = Object.assign({}, base); // Override by node if (node.task_type === "timer"){ t.type = "timer"; t.prompt = node.text || base.prompt; t.duration = node.duration ?? base.duration ?? 60; } else if (node.task_type === "checklist"){ t.type = "checklist"; t.prompt = node.text || base.prompt; t.items = base.items ?? 3; } else if (node.task_type === "text_input" || node.task_type === "reflection" || node.task_type === "reach_out"){ t.type = "text_input"; t.prompt = node.text || base.prompt; } else if (node.task_type === "look_at_balance" || node.task_type === "pick_most_anxious"){ t.type = "number_input"; t.prompt = node.text || base.prompt; } else if (node.task_type === "district_choice"){ t.type = "district_choice"; t.prompt = node.text || base.prompt; t.options = (node.options || [ { id:"oasis", label:"🌿 Оазис", hint:"Тело/сон/движение" }, { id:"citadel", label:"🏛️ Цитадель", hint:"Работа/учёба/дедлайны" }, { id:"arsenal", label:"⚔️ Арсенал", hint:"Деньги/ресурсы" }, { id:"forum", label:"🤝 Форум", hint:"Отношения/границы" }, { id:"garden", label:"🌱 Сад", hint:"Рост/привычки" }, ]); } t._districtId = districtId; t._levelId = level.level_id; t._levelName = level.name; t._fromNode = node.node_id; return t; } /* ------------------------------ Task UI ------------------------------ */ let activeTimer = null; function showTask(task, level, meta){ dom.taskPane.style.display = "block"; dom.taskPane.innerHTML = ""; const duration = task.duration ? `${task.duration}s` : (task.timer ? `${task.timer}s` : ""); const title = `${level.name} · Микрошаг`; const head = document.createElement("div"); head.className = "taskTitle"; head.innerHTML = ` <div> <h3>${escapeHtml(title)}</h3> <div class="taskMeta">${escapeHtml(task.prompt || "")}</div> </div> <div class="taskMeta">${duration ? `⏱ ${duration}` : ""}</div> `; const body = document.createElement("div"); body.className = "taskBody"; if (task.guidance){ const g = document.createElement("div"); g.className = "muted small"; g.textContent = task.guidance; body.appendChild(g); } const renderer = TASK_RENDERERS[task.type] || TASK_RENDERERS.text_input; renderer({ task, level, body, meta }); const actions = document.createElement("div"); actions.className = "taskActions"; const btnDone = mkBtn("Завершить микрошаг", "primary", () => finishTask(task, level, meta)); const btnCancel = mkBtn("Отложить", "ghost", () => { hideTask(); toast("Ок. Туман подождёт. Но недолго."); }); actions.appendChild(btnDone); actions.appendChild(btnCancel); dom.taskPane.appendChild(head); dom.taskPane.appendChild(body); dom.taskPane.appendChild(actions); // Auto: stop previous timer if (activeTimer){ clearInterval(activeTimer); activeTimer = null; } } function hideTask(){ dom.taskPane.style.display = "none"; dom.taskPane.innerHTML = ""; if (activeTimer){ clearInterval(activeTimer); activeTimer = null; } } const TASK_RENDERERS = { timer({task, body}){ const wrap = document.createElement("div"); wrap.className = "muted small"; const duration = Number(task.duration || 60); let remaining = duration; const line = document.createElement("div"); line.textContent = `Таймер: ${remaining}s`; wrap.appendChild(line); const row = document.createElement("div"); row.className = "choiceRow"; const btnStart = mkBtn("Старт", "soft", () => { btnStart.disabled = true; const startedAt = Date.now(); activeTimer = setInterval(() => { const elapsed = Math.floor((Date.now() - startedAt) / 1000); remaining = clamp(duration - elapsed, 0, duration); line.textContent = `Таймер: ${remaining}s`; if (remaining <= 0){ clearInterval(activeTimer); activeTimer = null; btnStart.disabled = false; toast("Готово. Даже туман уважает таймер."); } }, 250); }); const btnSkip = mkBtn("Сделал(а)", "ghost", () => { if (activeTimer){ clearInterval(activeTimer); activeTimer = null; } toast("Ок. Засчитываю как выполнено."); }); row.appendChild(btnStart); row.appendChild(btnSkip); body.appendChild(wrap); body.appendChild(row); }, reflection({task, body}){ const ta = document.createElement("textarea"); ta.placeholder = "Пиши коротко. Главное — конкретно."; body.appendChild(ta); const info = document.createElement("div"); info.className = "muted small"; info.textContent = `Минимум слов: ${task.min_words || 10}`; body.appendChild(info); ta.addEventListener("input", () => { const wc = wordCount(ta.value); info.textContent = `Слов: ${wc} / минимум ${task.min_words || 10}`; }); task._getPayload = () => ({ text: ta.value.trim() }); }, text_input({task, body}){ const input = document.createElement("input"); input.className = "input"; input.placeholder = task.placeholder || "Коротко, одним-двумя предложениями."; body.appendChild(input); task._getPayload = () => ({ text: input.value.trim() }); }, number_input({task, body}){ const input = document.createElement("input"); input.className = "input"; input.inputMode = "numeric"; input.placeholder = "Например: 12000"; body.appendChild(input); const note = document.createElement("div"); note.className = "muted small"; note.textContent = "Можно приблизительно. Цель — ясность, не бухгалтерия."; body.appendChild(note); task._getPayload = () => ({ number_raw: input.value.trim() }); }, district_choice({task, body}){ const picked = { id: null }; const note = document.createElement("div"); note.className = "muted small"; note.textContent = "Подсказка: выбери там, где больше всего утечка внимания/сил."; body.appendChild(note); const grid = document.createElement("div"); grid.className = "districtGrid"; const opts = Array.isArray(task.options) ? task.options : []; opts.forEach(opt => { const d = DISTRICTS[opt.id]; const btn = document.createElement("button"); btn.className = `district district--${opt.id}`; btn.innerHTML = ` <div class="district__title">${escapeHtml(opt.label || (d?.icon+" "+d?.name) || opt.id)}</div> <div class="district__meta">${d ? `Уровень ${getDistrictLevel(opt.id)} • Туман ${Math.round(getDistrictFog(opt.id)*100)}%` : ""}</div> <div class="district__mist" aria-hidden="true"></div> `; btn.addEventListener("click", () => { picked.id = opt.id; [...grid.children].forEach(x => x.classList.remove("district--picked")); btn.classList.add("district--picked"); }); grid.appendChild(btn); }); body.appendChild(grid); task._getPayload = () => ({ district_id: picked.id }); }, checklist({task, body}){ const n = Number(task.items || 3); const list = document.createElement("div"); list.style.display = "flex"; list.style.flexDirection = "column"; list.style.gap = "8px"; const inputs = []; for (let i=0;i<n;i++){ const inp = document.createElement("input"); inp.className = "input"; inp.placeholder = `Пункт ${i+1}`; inputs.push(inp); list.appendChild(inp); } body.appendChild(list); task._getPayload = () => ({ items: inputs.map(x => x.value.trim()).filter(Boolean) }); }, top_three({task, body}){ // basically checklist 3 + one choice to reduce const list = document.createElement("div"); list.style.display = "flex"; list.style.flexDirection = "column"; list.style.gap = "8px"; const inputs = []; for (let i=0;i<3;i++){ const inp = document.createElement("input"); inp.className = "input"; inp.placeholder = `Статья ${i+1} (например: еда/аренда/подписки)`; inputs.push(inp); list.appendChild(inp); } body.appendChild(list); const select = document.createElement("select"); select.className = "input"; select.innerHTML = `<option value="">Выбери 1 для уменьшения на 10–20%</option>`; body.appendChild(select); inputs.forEach((inp, idx) => { inp.addEventListener("input", () => { const vals = inputs.map(x => x.value.trim()); select.innerHTML = `<option value="">Выбери 1 для уменьшения на 10–20%</option>` + vals.map((v,i)=> v ? `<option value="${escapeAttr(v)}">${escapeHtml(v)}</option>` : "").join(""); }); }); task._getPayload = () => ({ items: inputs.map(x => x.value.trim()).filter(Boolean), chosen: select.value || null }); }, choice({task, body}){ const opts = task.options || []; const row = document.createElement("div"); row.className = "choiceRow"; let picked = null; opts.forEach(o => { const text = typeof o === "string" ? o : o.text; const btn = mkBtn(text, "soft", () => { picked = text; toast(`Выбрано: ${text}`); }); row.appendChild(btn); }); body.appendChild(row); task._getPayload = () => ({ choice: picked }); }, people_list({task, body}){ const fields = task.fields || []; const wrap = document.createElement("div"); wrap.style.display = "flex"; wrap.style.flexDirection = "column"; wrap.style.gap = "10px"; const rows = []; fields.forEach((f, i) => { const row = document.createElement("div"); row.style.display = "grid"; row.style.gridTemplateColumns = "1fr 170px"; row.style.gap = "10px"; const name = document.createElement("input"); name.className = "input"; name.placeholder = `Человек ${i+1}`; const date = document.createElement("input"); date.className = "input"; date.type = "date"; row.appendChild(name); row.appendChild(date); wrap.appendChild(row); rows.push({ name, date }); }); const note = document.createElement("div"); note.className = "muted small"; note.textContent = task.action || ""; wrap.appendChild(note); body.appendChild(wrap); task._getPayload = () => ({ people: rows.map(r => ({ name: r.name.value.trim(), last: r.date.value })).filter(p => p.name) }); }, honest_phrase({task, body}){ const ta = document.createElement("textarea"); ta.placeholder = task.template || "Мне сейчас трудно…"; body.appendChild(ta); const info = document.createElement("div"); info.className = "muted small"; info.textContent = `Минимум слов: ${task.min_words || 10}`; body.appendChild(info); ta.addEventListener("input", () => info.textContent = `Слов: ${wordCount(ta.value)} / минимум ${task.min_words || 10}`); task._getPayload = () => ({ text: ta.value.trim() }); }, action_commitment({task, body}){ const ta = document.createElement("textarea"); ta.placeholder = task.example || "Кому и что попросишь/предложишь?"; body.appendChild(ta); const note = document.createElement("div"); note.className = "muted small"; note.textContent = "Можно не отправлять прямо сейчас — достаточно подготовить текст."; body.appendChild(note); task._getPayload = () => ({ text: ta.value.trim() }); }, letter({task, body}){ const ta = document.createElement("textarea"); ta.placeholder = "Формула: «Я помню … Мне жаль … Хочу …»"; body.appendChild(ta); task._getPayload = () => ({ text: ta.value.trim() }); }, ritual({task, body}){ const ta = document.createElement("textarea"); ta.placeholder = "Письмо без отправки. Скажи всё, что важно."; body.appendChild(ta); const note = document.createElement("div"); note.className = "muted small"; note.textContent = "Ритуал закрытия: порви/удали/спрячь. В игре это «закрывает петлю»."; body.appendChild(note); task._getPayload = () => ({ text: ta.value.trim() }); }, weekly_plan({task, body}){ const days = task.template || ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"]; const wrap = document.createElement("div"); wrap.style.display = "grid"; wrap.style.gridTemplateColumns = "1fr 1fr"; wrap.style.gap = "10px"; const map = {}; days.forEach(d => { const inp = document.createElement("input"); inp.className = "input"; inp.placeholder = `${d}: пик/спад/пауза (1 слово)`; map[d] = inp; wrap.appendChild(inp); }); body.appendChild(wrap); task._getPayload = () => { const out = {}; for (const d of days) out[d] = map[d].value.trim(); return { plan: out }; }; }, delegation({task, body}){ const row = document.createElement("div"); row.className = "choiceRow"; let picked = null; (task.options || []).forEach(opt => { const btn = mkBtn(opt, "soft", () => { picked = opt; toast(`Выбрано: ${opt}`); }); row.appendChild(btn); }); body.appendChild(row); const ta = document.createElement("textarea"); ta.placeholder = "1–2 предложения: кому/как/что именно."; body.appendChild(ta); task._getPayload = () => ({ mode: picked, text: ta.value.trim() }); }, habit_creation({task, body}){ const input = document.createElement("input"); input.className = "input"; input.placeholder = task.template || "После [X] я делаю [новое]"; body.appendChild(input); const note = document.createElement("div"); note.className = "muted small"; note.textContent = `Пример: ${task.example || "После кофе — 1 предложение"}`; body.appendChild(note); task._getPayload = () => ({ habit: input.value.trim() }); }, system_of_three({task, body}){ const fields = task.fields || []; const wrap = document.createElement("div"); wrap.style.display = "flex"; wrap.style.flexDirection = "column"; wrap.style.gap = "10px"; const map = {}; for (const f of fields){ const inp = document.createElement("input"); inp.className = "input"; inp.placeholder = f.label || f.name; map[f.name] = inp; wrap.appendChild(inp); } body.appendChild(wrap); task._getPayload = () => ({ anchor: map.anchor?.value.trim() || "", connection: map.connection?.value.trim() || "", place: map.place?.value.trim() || "", }); }, calculation({task, body}){ const fields = task.fields || []; const wrap = document.createElement("div"); wrap.style.display = "flex"; wrap.style.flexDirection = "column"; wrap.style.gap = "10px"; const map = {}; for (const f of fields){ const row = document.createElement("div"); row.style.display = "grid"; row.style.gridTemplateColumns = "1fr 160px"; row.style.gap = "10px"; const label = document.createElement("div"); label.className = "muted small"; label.textContent = f.label || f.name; const inp = document.createElement("input"); inp.className = "input"; inp.inputMode = "numeric"; inp.placeholder = "0"; map[f.name] = inp; row.appendChild(label); row.appendChild(inp); wrap.appendChild(row); } const out = document.createElement("div"); out.className = "muted small"; out.textContent = "Итог: —"; wrap.appendChild(out); function recalc(){ const sum = fields.reduce((acc,f)=> acc + toNumber(map[f.name].value), 0); const res = Math.round(sum * 1.2); out.textContent = `Итог (×1.2): ${res}`; } fields.forEach(f => map[f.name].addEventListener("input", recalc)); recalc(); body.appendChild(wrap); task._getPayload = () => { const sum = fields.reduce((acc,f)=> acc + toNumber(map[f.name].value), 0); const res = sum * 1.2; return { sum, result: res }; }; }, experiment({task, body}){ const note = document.createElement("div"); note.className = "muted small"; note.textContent = "Цель: опыт. Не результат. Мир не валится от «плохо» — он валится от «никогда»."; body.appendChild(note); const list = document.createElement("div"); list.className = "choiceRow"; let picked = null; (task.examples || ["Плохой рисунок","Плохой абзац","Неловкий танец"]).forEach(e => { const btn = mkBtn(e, "soft", () => { picked = e; toast(`Эксперимент: ${e}`); }); list.appendChild(btn); }); body.appendChild(list); const ta = document.createElement("textarea"); ta.placeholder = task.reflection || "Что произошло? (1–2 предложения)"; body.appendChild(ta); task._getPayload = () => ({ choice: picked, reflection: ta.value.trim() }); }, }; function finishTask(task, level, meta){ const payload = (typeof task._getPayload === "function") ? task._getPayload() : {}; const validation = validateTask(task, payload); if (!validation.ok){ toast(validation.msg); return; } // Special: district-choice microstep chains into an immediate next session if (task.type === "district_choice" && payload.district_id){ state.flags._autostartDistrict = payload.district_id; } // Mark microstep done state.flags._microstepDoneThisSession = true; // Update session counters state.sessions_total += 1; state.sessions_in_act += 1; state.district_sessions[task._districtId] = (state.district_sessions[task._districtId] || 0) + 1; // Patterns tracking updatePatternsFromTask(task, payload); // Rewards with hazard/boss penalties const reward = computeReward(task._districtId, level, payload); state.stability += reward.stability_points; state.effort += reward.effort; // Fog reduction (percentage on district) if (typeof reward.fog_reduction === "number"){ state.fog[task._districtId] = clamp(state.fog[task._districtId] - reward.fog_reduction, 0, 100); } // Card grants (reward.cards || []).forEach(grantCard); // Fork reward card if (task._rewardCard) grantCard(task._rewardCard); // Mark level completed if it was a “first completion” if (!state.completed_levels[task._districtId].includes(task._levelId)){ state.completed_levels[task._districtId].push(task._levelId); } // Contracts progress from actions progressContractsFromTask(task, payload); // Boss progress updates bossProgressFromTask(task, payload, reward); // Hazards updates (spawn/removal) updateHazardsFromTask(task, payload); // Post: check boss defeat, act progression const bossDefeated = maybeDefeatBoss(); // Check Act gating + spawn next bosses by pattern maybeTriggerBossByPattern(); // Final boss trigger after milestone maybeTriggerFinalBoss(); // Autosave autosave(); // End-of-session modal hideTask(); clearQuestion(); showSessionModal({ task, level, payload, reward, bossDefeated }); renderAll(); // Victory condition if (state.act === 6){ endRun(); } } /* ------------------------------ Rewards / Hazards / Bosses ------------------------------ */ function computeReward(districtId, level, payload){ const base = Object.assign({ stability_points:0, effort:0 }, level.rewards || {}); const out = { stability_points: base.stability_points || 0, effort: base.effort || 0, fog_reduction: base.fog_reduction || 0, cards: Array.isArray(base.cards) ? [...base.cards] : [] }; // Passive relic bonuses out.stability_points += passiveStabilityBonus(districtId); // “First microstep bonus” (relic_lens_focus) if (!state.flags._firstMicrostepBonusApplied && state.relics_active.includes("relic_lens_focus")){ out.stability_points += 2; state.flags._firstMicrostepBonusApplied = true; } // Numbers clarity relic if (state.relics_active.includes("relic_accounting_seal") && state.flags._numberNamedThisSession){ if (!state.flags._accountingSealUsed){ out.effort += 1; state.flags._accountingSealUsed = true; } } // Hazards penalties const hazardPenalty = sumHazardPenalty(); out.stability_points = Math.max(0, out.stability_points + hazardPenalty); // Boss penalty (if active) if (state.boss_active){ const boss = getBoss(state.boss_active); const p = boss?.effects?.penalty; if (typeof p === "number"){ out.stability_points = Math.max(0, out.stability_points + p); } } // Extra: add random lore chance on some rewards if (level.rewards?.lore_chance){ if (Math.random() < level.rewards.lore_chance){ out.cards.push("lore_city_fragment"); } } return out; } function sumHazardPenalty(){ let total = 0; for (const hid of state.hazards_active){ const h = getCard(hid); if (!h) continue; const p = h.effect?.penalty; if (typeof p === "number") total += p; } return total; } function passiveStabilityBonus(districtId){ let bonus = 0; for (const cid of state.companions_active){ const c = getCard(cid); if (!c) continue; const b = c.effect?.passive_bonus; if (typeof b === "number") bonus += b; } return bonus; } function updatePatternsFromTask(task, payload){ // For Act2: perfectionism avoidance if (task._districtId === "citadel"){ // If player provided simplification text, count as “done_good_enough” if (task.type === "choice" || task.type === "delegation" || task.type === "reflection" || task.type === "text_input"){ bumpBossCounter("done_good_enough", 1); } } // For Act3: “numbers named” if (task.type === "number_input" || state.flags._numberNamedThisSession){ bumpBossCounter("concrete_numbers", 1); } // For Act4: comparison thoughts (we simulate via hazard activations) if (state.hazards_active.includes("hazard_comparison")){ bumpPatternCounter("comparison_thoughts", 1); } // For Burnout: if district not oasis and no rest if (task._districtId !== "oasis"){ bumpPatternCounter("sessions_without_rest", 1); } else { bumpPatternCounter("sessions_without_rest", -999); } // For “sessions without numbers” if (task.type === "number_input" || state.flags._numberNamedThisSession){ bumpPatternCounter("sessions_without_numbers", -999); } else { bumpPatternCounter("sessions_without_numbers", 1); } } function updateHazardsFromTask(task, payload){ // Burnout hazard if 3 sessions without rest const restCounter = state.flags._patterns?.sessions_without_rest || 0; if (restCounter >= 3 && !state.hazards_active.includes("hazard_burnout")){ state.hazards_active.push("hazard_burnout"); toast("Hazard: Перегрев активирован."); } // Remove burnout if any Oasis skill played if (state.hazards_active.includes("hazard_burnout") && state.flags._oasisSkillPlayed){ state.hazards_active = state.hazards_active.filter(x => x !== "hazard_burnout"); toast("Перегрев снят: навык Оазиса сыгран."); state.flags._oasisSkillPlayed = false; } // Notification spam from Act1 boss if (state.boss_active === "ping_whisper"){ if (!state.hazards_active.includes("hazard_notification_spam")){ state.hazards_active.push("hazard_notification_spam"); } // removed by any skill usage in session if (state.flags._anySkillPlayedThisSession){ state.hazards_active = state.hazards_active.filter(x => x !== "hazard_notification_spam"); } } // Comparison hazard: may appear if comparison pattern high const comp = state.flags._patterns?.comparison_thoughts || 0; if (comp >= 3 && !state.hazards_active.includes("hazard_comparison")){ // relic_boundary_compass reduces chance const reduction = state.relics_active.includes("relic_boundary_compass") ? 0.25 : 0; if (Math.random() > reduction){ state.hazards_active.push("hazard_comparison"); toast("Hazard: Сравнение активировано."); } } } function progressContractsFromTask(task, payload){ // Ensure contracts tracked when owned for (const cid of state.cards_owned){ const c = getCard(cid); if (!c || c.type !== "contract") continue; if (!state.contracts[cid]) state.contracts[cid] = { progress: 0, done: false }; } // Increase specific contract progress by action types for (const [cid, st] of Object.entries(state.contracts)){ if (!st || st.done) continue; const c = getCard(cid); if (!c) continue; // contract_deadline_boundary: choice reduce scope (Citadel simplification) if (cid === "contract_deadline_boundary"){ if (task._districtId === "citadel"){ st.progress += 1; } if (st.progress >= 2){ st.done = true; grantCard(c.reward?.card || "companion_smith"); toast("Контракт выполнен: Граница дедлайна."); } } // contract_clear_number: 3 concrete numbers across sessions if (cid === "contract_clear_number"){ if (task.type === "number_input" || state.flags._numberNamedThisSession){ st.progress += 1; } if (st.progress >= 3){ st.done = true; grantCard(c.reward?.card || "companion_keeper_enough"); toast("Контракт выполнен: ясная цифра."); } } // contract_reciprocity_bridge: ask/give in forum if (cid === "contract_reciprocity_bridge"){ if (task._districtId === "forum"){ st.progress += 1; } if (st.progress >= 2){ st.done = true; grantCard(c.reward?.card || "companion_line_cartographer"); toast("Контракт выполнен: взаимность."); } } // contract_7_days_pause: micro rest (oasis task completion) if (cid === "contract_7_days_pause"){ if (task._districtId === "oasis"){ st.progress += 1; } if (st.progress >= 3){ st.done = true; state.effort += 2; toast("Контракт выполнен: пауза. +2 Effort."); } } // contract_novice_path: experiments if (cid === "contract_novice_path"){ if (task.type === "experiment"){ st.progress += 1; } if (st.progress >= 2){ st.done = true; state.stability += 6; toast("Контракт выполнен: новичок. +6 Stability."); } } } } function bossProgressFromTask(task, payload, reward){ // For Act1 boss: focus maintained if microstep done if (state.flags._microstepDoneThisSession){ bumpBossCounter("focus_maintained", 1); } } function maybeTriggerBossByAct(){ // Trigger Act 1 boss after 5 sessions in act if (state.boss_active) return; const boss = DATA.BOSSES.find(b => b.act === state.act && b.trigger?.type === "act_progression"); if (!boss) return; const need = boss.trigger.sessions_in_act || 999; if (state.sessions_in_act >= need){ startBoss(boss.boss_id); } } function maybeTriggerBossByPattern(){ if (state.boss_active) return; const boss = DATA.BOSSES.find(b => b.act === state.act && b.trigger?.type === "pattern"); if (!boss) return; const cond = boss.trigger.condition; const thr = boss.trigger.threshold || 999; const v = state.flags._patterns?.[cond] || 0; if (v >= thr){ startBoss(boss.boss_id); } } function maybeTriggerFinalBoss(){ if (state.boss_active) return; if (state.act !== 5) return; // Milestone: all districts level 3+ OR completed level3 in all const ok = ["oasis","citadel","arsenal","forum","garden"].every(d => getDistrictLevelIndex(d) >= 2); if (ok){ startBoss("archivist_noise"); } } function startBoss(bossId){ state.boss_active = bossId; state.boss_progress = state.boss_progress || {}; const boss = getBoss(bossId); updateBossBadge(); airaSay([ `⚠️ Появился босс: ${boss.name}`, boss.dialogue?.appearance || "Туман сгущается. Держим сигнал." ].join("\n")); showModal({ title: `Босс: ${boss.name}`, text: describeBossGoal(boss), actions: [{ text:"Понял(а)", kind:"primary", onClick: closeModal }] }); autosave(); } function updateBossBadge(){ if (!state?.boss_active){ dom.bossBadge.style.display = "none"; dom.bossBadge.textContent = ""; return; } const b = getBoss(state.boss_active); dom.bossBadge.style.display = "block"; dom.bossBadge.textContent = `Босс активен: ${b.name}`; } function maybeDefeatBoss(){ if (!state.boss_active) return false; const boss = getBoss(state.boss_active); if (!boss) return false; const ok = bossDefeatCheck(boss); if (!ok) return false; airaSay(boss.dialogue?.defeat || "Босс отступает. Сигнал восстановлен."); // Move to next act state.boss_active = null; state.boss_progress = {}; state.sessions_in_act = 0; state.act += 1; if (boss.boss_id === "archivist_noise"){ state.guru_unlocked = true; state.act = 6; // victory marker } updateBossBadge(); autosave(); toast(`Акт завершён. Теперь акт ${state.act}.`); return true; } function bossDefeatCheck(boss){ // Interpret defeat conditions in a simplified, prototype-friendly way. const conds = boss.defeat_conditions || []; for (const c of conds){ if (c.type === "skill_usage"){ const v = state.boss_progress.skill_usage || 0; if (v < c.count) return false; } if (c.type === "focus_maintained"){ const v = state.boss_progress.focus_maintained || 0; if (v < c.sessions) return false; } if (c.type === "series" && c.action === "done_good_enough"){ const v = state.boss_progress.done_good_enough || 0; if (v < c.count) return false; } if (c.type === "card_activation" && c.card_id){ const v = state.boss_progress[`activated_${c.card_id}`] || 0; if (v < 1) return false; } if (c.type === "concrete_numbers"){ const v = state.boss_progress.concrete_numbers || 0; if (v < c.count) return false; } if (c.type === "contract_completion"){ const st = state.contracts[c.contract]; if (!st || !st.done) return false; } if (c.type === "full_inventory" && c.category === "financial"){ const fin = state.cards_owned.map(getCard).filter(x => x && x.district === "arsenal").length; if (fin < 3) return false; } if (c.type === "skill_activation" && c.skill){ const flag = state.flags[c.skill] === true || state.boss_progress[`skill_${c.skill}`] >= 1; if (!flag) return false; } if (c.type === "companion_session" && c.companion){ const ok = state.companions_active.includes("companion_line_cartographer"); if (!ok) return false; } if (c.type === "reflection_complete" && c.theme === "own_uniqueness"){ const v = state.boss_progress.reflection_own_uniqueness || 0; if (v < 1) return false; } } return true; } function describeBossGoal(boss){ if (boss.boss_id === "ping_whisper"){ return "Цель: разыграть Skills 5 раз и завершить 3 сессии без срыва (просто делай микрошаги)."; } if (boss.boss_id === "accountant_deadlines"){ return "Цель: 3 раза сделать «достаточно хорошо» (сокращение объёма/80%) и активировать Contract «Граница дедлайна» (если есть)."; } if (boss.boss_id === "collector_anxiety"){ return "Цель: назвать 3 конкретных числа в разных сессиях и выполнить Contract ясной цифры."; } if (boss.boss_id === "commentator_comparison"){ return "Цель: разыграть «Сверка с собой», побыть с Картографом Линий и сделать 1 рефлексию про собственную уникальность."; } if (boss.boss_id === "archivist_noise"){ return "Цель: пережить 3 сессии, сохраняя «Систему 3» (якорь+связь+место) и иметь легендарную «Тихую инфраструктуру»."; } return "Цель: делай микрошаги и используй карты. Сигнал победит шум."; } /* ------------------------------ Cards: play / equip / pack ------------------------------ */ function playSkill(cardId){ const c = getCard(cardId); if (!c || c.type !== "skill") return; const cost = c.effort_cost || 0; if (state.effort < cost){ toast("Не хватает Effort."); return; } state.effort -= cost; // Skill usage counters (boss) bumpBossCounter("skill_usage", 1); state.flags._anySkillPlayedThisSession = true; // Special: remove notification spam hazard if (state.hazards_active.includes("hazard_notification_spam")){ state.hazards_active = state.hazards_active.filter(x => x !== "hazard_notification_spam"); } // Oasis skill used -> remove burnout hazard if (c.district === "oasis"){ state.flags._oasisSkillPlayed = true; } // Compare with self if (c.card_id === "skill_compare_with_self"){ state.flags.compare_with_self = true; bumpBossCounter("skill_compare_with_self", 1); // remove comparison hazard state.hazards_active = state.hazards_active.filter(x => x !== "hazard_comparison"); bumpBossCounter("reflection_own_uniqueness", 1); } // Apply effect if (typeof c.effect?.stability_points === "number"){ state.stability += c.effect.stability_points; } const fr = c.effect?.fog_reduction; if (fr && fr.district && typeof fr.amount === "number"){ // Here: fog amount is treated as percent points state.fog[fr.district] = clamp(state.fog[fr.district] - (fr.amount * 5), 0, 100); } if (c.effect?.removes_hazard){ state.hazards_active = state.hazards_active.filter(x => x !== c.effect.removes_hazard); } if (c.effect?.flags){ for (const f of c.effect.flags) state.flags[f] = true; } // mark used this session state.flags._skillUsedThisSession[cardId] = true; autosave(); renderAll(); airaSay(`🃏 Skill сыгран: ${c.name}`); } function toggleRelic(cardId){ const c = getCard(cardId); if (!c || c.type !== "relic") return; const active = state.relics_active.includes(cardId); if (active){ state.relics_active = state.relics_active.filter(x => x !== cardId); toast(`Relic снят: ${c.name}`); } else { state.relics_active.push(cardId); toast(`Relic надет: ${c.name}`); } autosave(); renderAll(); } function toggleCompanion(cardId){ const c = getCard(cardId); if (!c || c.type !== "companion") return; const active = state.companions_active.includes(cardId); if (active){ state.companions_active = state.companions_active.filter(x => x !== cardId); toast(`Companion ушёл: ${c.name}`); } else { state.companions_active.push(cardId); toast(`Companion с вами: ${c.name}`); } autosave(); renderAll(); } function openPack(){ const cost = packCostNow(); if (state.effort < cost){ toast("Не хватает Effort."); return; } state.effort -= cost; const gained = drawCardFromPool(); if (gained){ grantCard(gained.card_id); toast(`Открыт слот: ${gained.name}`); } else { toast("Туман сегодня жадный. Попробуй позже."); } autosave(); renderAll(); } function packCostNow(){ // mild scaling: early cheap, later a bit higher if (state.sessions_total < 6) return 2; if (state.sessions_total < 12) return 3; return 4; } function drawCardFromPool(){ // Eligible: not owned, not hazard, and “reasonable” rarity distribution. const owned = new Set(state.cards_owned); const pool = DATA.CARDS.filter(c => !owned.has(c.card_id) && c.type !== "hazard"); // Bias by act: later acts unlock higher rarity a bit more const act = state.act; const weights = pool.map(c => { let w = 1; if (c.rarity === "common") w = 5; if (c.rarity === "rare") w = (act >= 3) ? 4 : 2; if (c.rarity === "epic") w = (act >= 4) ? 2 : 0.6; if (c.rarity === "legendary") w = (act >= 5) ? 0.8 : 0.2; // Prefer district cards where fog is high if (c.district && typeof state.fog[c.district] === "number"){ w *= 1 + (state.fog[c.district] / 140); } return w; }); return weightedChoice(pool, weights); } function grantCard(cardId){ if (!cardId) return; if (state.cards_owned.includes(cardId)) return; // Special: alias for compare skill in data if (cardId === "compare_with_self") cardId = "skill_compare_with_self"; const c = getCard(cardId); if (!c){ // Allow placeholder lore if (cardId === "lore_city_fragment"){ state.cards_owned.push(cardId); return; } return; } state.cards_owned.push(cardId); // Auto-track contracts if (c.type === "contract"){ if (!state.contracts[cardId]) state.contracts[cardId] = { progress: 0, done: false }; } } function getCard(cardId){ if (cardId === "lore_city_fragment"){ return { card_id:"lore_city_fragment", name:"Осколок Лора", type:"lore", rarity:"common", district:"all", effort_cost:0, description:"Короткая легенда: «Когда туман тоньше, слышно, как город дышит.»" }; } return DATA.CARDS.find(c => c.card_id === cardId) || null; } function getBoss(bossId){ return DATA.BOSSES.find(b => b.boss_id === bossId) || null; } /* ------------------------------ Questions UI ------------------------------ */ function clearQuestion(){ dom.questionPane.innerHTML = ""; } function renderChoiceQuestion(text, options, onPick){ clearQuestion(); const t = document.createElement("div"); t.className = "qTitle"; t.textContent = text; const row = document.createElement("div"); row.className = "choiceRow"; options.forEach(opt => { const b = mkBtn(opt, "soft", () => onPick(opt)); row.appendChild(b); }); dom.questionPane.appendChild(t); dom.questionPane.appendChild(row); scrollChatBottom(); } function renderScaleQuestion(text, min, max, onPick){ clearQuestion(); const t = document.createElement("div"); t.className = "qTitle"; t.textContent = text; const row = document.createElement("div"); row.className = "scaleRow"; const range = document.createElement("input"); range.type = "range"; range.min = String(min); range.max = String(max); range.value = String(Math.round((min+max)/2)); const v = document.createElement("div"); v.className = "scaleValue"; v.textContent = range.value; range.addEventListener("input", () => v.textContent = range.value); const btn = mkBtn("Ок", "primary", () => onPick(Number(range.value))); row.appendChild(range); row.appendChild(v); row.appendChild(btn); dom.questionPane.appendChild(t); dom.questionPane.appendChild(row); scrollChatBottom(); } function renderOpenOrChoice(text, fallbackOptions, onSubmit){ clearQuestion(); const t = document.createElement("div"); t.className = "qTitle"; t.textContent = text; const input = document.createElement("input"); input.className = "input"; input.placeholder = "Можно просто число или короткую фразу."; const rowA = document.createElement("div"); rowA.className = "choiceRow"; const btnSend = mkBtn("Ответить", "primary", () => { const val = input.value.trim(); if (!val){ toast("Напиши коротко (хотя бы одно слово/число)."); return; } onSubmit({ mode:"input", value: val }); }); rowA.appendChild(btnSend); const rowB = document.createElement("div"); rowB.className = "choiceRow"; (fallbackOptions || []).forEach(o => { const b = mkBtn(o.text, "soft", () => onSubmit({ mode:"choice", value:o.text })); rowB.appendChild(b); }); dom.questionPane.appendChild(t); dom.questionPane.appendChild(input); dom.questionPane.appendChild(rowA); if (fallbackOptions && fallbackOptions.length) dom.questionPane.appendChild(rowB); input.addEventListener("keydown", (e) => { if (e.key === "Enter") btnSend.click(); }); scrollChatBottom(); } /* ------------------------------ Chat helpers ------------------------------ */ function airaSay(text){ addMsg("aira", "Айра", text); } function youSay(text){ addMsg("you", "Вы", text); } function addMsg(who, name, text){ const msg = document.createElement("div"); msg.className = `msg msg--${who}`; const meta = document.createElement("div"); meta.className = "msg__meta"; meta.textContent = name; const body = document.createElement("div"); body.className = "msg__text"; body.textContent = text; msg.appendChild(meta); msg.appendChild(body); dom.chatLog.appendChild(msg); scrollChatBottom(); } function scrollChatBottom(){ dom.chatLog.scrollTop = dom.chatLog.scrollHeight; } /* ------------------------------ Session modal ------------------------------ */ function showSessionModal({ task, level, payload, reward, bossDefeated }){ const loot = []; if (reward.stability_points) loot.push(`+${reward.stability_points} Stability`); if (reward.effort) loot.push(`+${reward.effort} Effort`); if (reward.fog_reduction) loot.push(`−${reward.fog_reduction}% Fog (${labelDistrict(task._districtId)})`); if (reward.cards && reward.cards.length){ const names = reward.cards.map(id => getCard(id)?.name || id).join(", "); loot.push(`Карты: ${names}`); } if (task._rewardCard){ loot.push(`Карты: ${getCard(task._rewardCard)?.name || task._rewardCard}`); } const bossLine = state.boss_active ? `Босс активен: ${getBoss(state.boss_active)?.name || state.boss_active}` : (bossDefeated ? "Босс побеждён. Акт продвинут." : "—"); showModal({ title: "Сессия завершена", text: [ `Квартал: ${labelDistrict(task._districtId)} · ${level.name}`, `Босс: ${bossLine}`, ].join("\n"), loot, actions: [ { text:"Продолжить", kind:"primary", onClick: () => { closeModal(); const nextDistrict = state.flags._autostartDistrict || null; if (nextDistrict){ // Clean flag before starting (avoids loops if something throws) delete state.flags._autostartDistrict; const line = `Город дышит. Шум отступает. Следующий шаг — ${labelDistrict(nextDistrict)}.`; showSessionFade(line); // let fade appear, then auto-start the next district session setTimeout(() => { startSession(nextDistrict); }, 680); return; } renderAll(); } }, { text:"Открыть слот", kind:"soft", onClick: () => { closeModal(); openPack(); } }, ] }); } /* ------------------------------ Guru Mode (simple offline) ------------------------------ */ function sendGuruQuestion(){ if (!state.guru_unlocked) return; const q = dom.freeInput.value.trim(); if (!q) return; dom.freeInput.value = ""; youSay(q); // Rule-based “AI-ish” response: short, actionable, non-clinical const reply = guruReply(q); airaSay(reply); } function guruReply(q){ const s = q.toLowerCase(); const hints = []; if (s.includes("устал") || s.includes("сон") || s.includes("тело")){ hints.push("Если ресурс низкий — сначала Оазис: один микроритуал, потом решения."); } if (s.includes("день") || s.includes("дедлайн") || s.includes("работ")){ hints.push("Цитадель: выбери один критерий «достаточно». Остальное — туман, не обязательство."); } if (s.includes("деньг") || s.includes("расход") || s.includes("долг")){ hints.push("Арсенал: назови одну цифру. Она не обвиняет, она проясняет."); } if (s.includes("один") || s.includes("люд") || s.includes("отнош")){ hints.push("Форум: связь строится ритмом. Один «привет» сегодня — это уже инфраструктура."); } if (s.includes("хочу") || s.includes("цель") || s.includes("развит")){ hints.push("Сад: рост — это 2 минуты каждый день, а не героический рывок раз в месяц."); } const base = [ "Я отвечу коротко и в сторону действия.", "Сузим: это больше про ресурс, про ясность или про связь?", "Если сомневаешься — выбери один микрошаг на 2 минуты и сделай его сейчас." ]; const tail = hints.length ? `\n\nПодсказка по сигналу:\n- ${hints.join("\n- ")}` : ""; return base.join("\n") + tail; } /* ------------------------------ End Run ------------------------------ */ function endRun(){ const score = state.stability; const best = Math.max(score, Number(localStorage.getItem(LS.BEST) || "0")); localStorage.setItem(LS.BEST, String(best)); // Clear autosave run on victory (optional) localStorage.removeItem(LS.RUN); dom.uiScore.textContent = String(score); dom.uiBest.textContent = String(best); showScreen("result"); } /* ------------------------------ Utilities ------------------------------ */ function autosave(){ try{ localStorage.setItem(LS.RUN, JSON.stringify(state)); } catch {} } function safeJsonParse(s){ try{ return JSON.parse(s); } catch { return null; } } function toast(text){ dom.toast.textContent = text; dom.toast.style.display = "block"; clearTimeout(dom.toast._t); dom.toast._t = setTimeout(() => dom.toast.style.display = "none", 2200); } function showSessionFade(text){ if(!dom.sessionFade || !dom.sessionFadeLine) return; dom.sessionFadeLine.textContent = text || "Город дышит. Шум отступает. До следующего шага."; dom.sessionFade.style.display = "flex"; // allow CSS animation to run; hide after it finishes clearTimeout(dom.sessionFade._t); dom.sessionFade._t = setTimeout(() => { dom.sessionFade.style.display = "none"; }, 1100); } function showModal({ title, text, loot, actions }){ dom.modalHost.style.display = "flex"; dom.modalHost.innerHTML = ""; const modal = document.createElement("div"); modal.className = "modal"; const h = document.createElement("h3"); h.textContent = title; const p = document.createElement("div"); p.className = "muted small"; p.style.whiteSpace = "pre-wrap"; p.textContent = text || ""; modal.appendChild(h); modal.appendChild(p); if (Array.isArray(loot) && loot.length){ const lootRow = document.createElement("div"); lootRow.className = "modal__loot"; loot.forEach(x => { const lp = document.createElement("div"); lp.className = "lootPill"; lp.textContent = x; lootRow.appendChild(lp); }); modal.appendChild(lootRow); } const row = document.createElement("div"); row.className = "modal__row"; (actions || []).forEach(a => { const kind = a.kind || "soft"; const b = mkBtn(a.text, kind, a.onClick); row.appendChild(b); }); modal.appendChild(row); dom.modalHost.appendChild(modal); dom.modalHost.addEventListener("click", (e) => { if (e.target === dom.modalHost) closeModal(); }, { once:true }); } function closeModal(){ dom.modalHost.style.display = "none"; dom.modalHost.innerHTML = ""; } function showCardModal(card_id){ const card = getCard(card_id); if(!card) return; const art = getCardArt(card_id); const lines = []; lines.push(`${card.type.toUpperCase()} • ${labelRarity(card.rarity)}${card.district ? ` • ${labelDistrict(card.district)}` : ""}`); if(card.description) lines.push("", card.description); // action menu mirrors shelf buttons, but gives a clear, single place to interact. const actions = []; if(card.type === "skill"){ actions.push({ text: `Сыграть (-${card.effort_cost || 0} Effort)`, kind:"primary", onClick: () => { closeModal(); playSkill(card_id); } }); } if(card.type === "relic"){ const active = state.relics_active.includes(card_id); actions.push({ text: active ? "Отключить" : "Включить", kind: active ? "ghost" : "primary", onClick: () => { closeModal(); toggleRelic(card_id); } }); } if(card.type === "companion"){ const active = state.companions_active.includes(card_id); actions.push({ text: active ? "Отпустить" : "Призвать", kind: active ? "ghost" : "primary", onClick: () => { closeModal(); toggleCompanion(card_id); } }); } if(card.type === "contract"){ const status = state.contracts[card_id]; const done = status && status.done; actions.push({ text: done ? "Закрыто" : "Посмотреть прогресс", kind:"primary", onClick: () => { closeModal(); toast(done ? "Контракт закрыт" : "Прогресс контракта показан в статусе сверху"); renderAll(); } }); } actions.push({ text: "Закрыть", kind:"ghost", onClick: closeModal }); showModal({ title: card.name, text: lines.join("\n"), loot: null, actions }); // Inject image block if provided (showModal already supports loot list as text; extend gracefully). if(art){ const modal = dom.modalHost.querySelector(".modal"); if(modal){ const artDiv = document.createElement("div"); artDiv.className = "modal__art"; artDiv.style.backgroundImage = `url('${art}')`; modal.insertBefore(artDiv, modal.children[1]); } } } function mkBtn(text, kind, onClick){ const b = document.createElement("button"); b.className = `btn ${kind === "primary" ? "btn--primary" : kind === "ghost" ? "btn--ghost" : "btn--soft"}`; b.textContent = text; b.addEventListener("click", onClick); return b; } function mkBadge(text){ const s = document.createElement("span"); s.className = "badge"; s.textContent = text; return s; } function clamp(x, a, b){ return Math.max(a, Math.min(b, x)); } function mapFogToOpacity(fog){ // 0% fog => 0 opacity, 100% fog => ~0.85 return clamp(0.10 + (fog/100)*0.75, 0, 0.85); } function labelDistrict(did){ const map = { oasis:"Оазис", citadel:"Цитадель", arsenal:"Арсенал", forum:"Форум", garden:"Сад", all:"Все" }; return map[did] || did; } function labelType(t){ const map = { skill:"Skill", relic:"Relic", contract:"Contract", lore:"Lore", companion:"Companion", hazard:"Hazard" }; return map[t] || t; } function labelRarity(r){ const map = { common:"Common", rare:"Rare", epic:"Epic", legendary:"Legendary" }; return map[r] || (r ? String(r) : "Common"); } function getCardArt(card_id){ const byId = { // Key “storybook” cards "skill_coffee_support": ASSETS.card_coffee, "relic_lens_focus": ASSETS.card_lens, "companion_line_cartographer": ASSETS.npc_cartographer, }; if (byId[card_id]) return byId[card_id]; const c = getCard(card_id); if (!c) return null; // Hazards & bosses – show noise imagery by default if (c.type === "hazard"){ return ASSETS.noise_cloud; } // District fallback (helps cards feel “placed” in the city) if (c.district && DISTRICT_ART[c.district]){ return DISTRICT_ART[c.district]; } // District fallback const byDistrict = { oasis: ASSETS.oasis_garden, garden: ASSETS.garden_twilight, forum: ASSETS.forum_plaza, arsenal: ASSETS.arsenal_treasury, citadel: ASSETS.citadel_library, all: ASSETS.city_map, }; if (c.district && byDistrict[c.district]) return byDistrict[c.district]; // Type fallback const byType = { skill: ASSETS.card_coffee, relic: ASSETS.card_lens, companion: ASSETS.npc_cartographer, contract: ASSETS.city_map, lore: ASSETS.citadel_library, }; return byType[c.type] || null; } function rankType(t){ const order = { hazard:0, skill:1, relic:2, contract:3, companion:4, lore:5 }; return order[t] ?? 99; } function escapeHtml(s){ return String(s).replaceAll("&","&").replaceAll("<","<").replaceAll(">",">"); } function escapeAttr(s){ return escapeHtml(s).replaceAll('"', """); } function toNumber(s){ const v = Number(String(s).replace(/\s+/g,"").replace(",",".")); return Number.isFinite(v) ? v : 0; } function looksLikeNumber(s){ const v = toNumber(s); return Number.isFinite(v) && String(s).trim().length > 0; } function wordCount(s){ const t = String(s || "").trim(); if (!t) return 0; return t.split(/\s+/).filter(Boolean).length; } function pickBranchByRange(branches, value){ // branches: { "1-3":"pain", "4-6":"fatigue", ... } const keys = Object.keys(branches); for (const k of keys){ const m = k.match(/^(\d+)\s*-\s*(\d+)$/); if (!m) continue; const a = Number(m[1]), b = Number(m[2]); if (value >= a && value <= b) return branches[k]; } return null; } function validateTask(task, payload){ if (task.type === "reflection"){ const min = task.min_words || 10; const wc = wordCount(payload.text || ""); if (wc < min) return { ok:false, msg:`Нужно чуть больше текста: ${wc}/${min} слов.` }; } if (task.type === "number_input"){ const raw = payload.number_raw || ""; if (!looksLikeNumber(raw)) return { ok:false, msg:"Нужно число (можно приблизительно)." }; state.flags._numberNamedThisSession = true; } if (task.type === "choice"){ if (!payload.choice) return { ok:false, msg:"Выбери один вариант." }; } if (task.type === "district_choice"){ if (!payload.district_id) return { ok:false, msg:"Выбери квартал." }; } if (task.type === "delegation"){ if (!payload.mode) return { ok:false, msg:"Сначала выбери режим (делегировать/отказаться/упростить)." }; if (!payload.text) return { ok:false, msg:"Добавь 1–2 предложения." }; } if (task.type === "top_three"){ if ((payload.items || []).length < 1) return { ok:false, msg:"Впиши хотя бы одну статью." }; if (task.requires_choice && !payload.chosen) return { ok:false, msg:"Выбери одну статью для уменьшения." }; } if (task.type === "people_list"){ if ((payload.people || []).length < 1) return { ok:false, msg:"Добавь хотя бы одного человека." }; } if (task.type === "habit_creation"){ if (!payload.habit) return { ok:false, msg:"Заполни формулу привычки." }; } if (task.type === "system_of_three"){ if (!(payload.anchor && payload.connection && payload.place)) return { ok:false, msg:"Заполни все 3 поля системы." }; // For final boss endurance: count system sessions bumpBossCounter("system_sessions", 1); } return { ok:true }; } function getDistrictLevelIndex(did){ const d = DATA.DISTRICTS[did]; const done = new Set(state.completed_levels[did] || []); let idx = 0; for (let i=0;i<d.levels.length;i++){ if (done.has(d.levels[i].level_id)) idx = i+1; } return clamp(idx, 0, d.levels.length); } function introLine(districtId, level){ const ph = DATA.DISTRICTS[districtId].philosophy || ""; const boss = state.boss_active ? `\n(Фоновый риск: ${getBoss(state.boss_active)?.name || "босс"})` : ""; return `Квартал: ${labelDistrict(districtId)}.\n${ph}\nУровень: ${level.name}.${boss}\n\nНачнём с пары точных вопросов.`; } function airaToneHint(districtId){ const map = { oasis:"Мягко и конкретно. Тело любит простоту.", citadel:"Достаточно хорошо — это стратегия, не компромисс.", arsenal:"Одна цифра. Без морали. Только факт.", forum:"Одна связь. Без идеальности. Только ритм.", garden:"2 минуты — это инфраструктура, а не «мало».", }; return map[districtId] || ""; } function bumpBossCounter(key, delta){ if (!state.boss_progress) state.boss_progress = {}; state.boss_progress[key] = (state.boss_progress[key] || 0) + delta; } function bumpPatternCounter(key, delta){ if (!state.flags._patterns) state.flags._patterns = {}; if (delta < 0){ state.flags._patterns[key] = 0; return; } state.flags._patterns[key] = (state.flags._patterns[key] || 0) + delta; } function weightedChoice(items, weights){ if (!items.length) return null; let sum = 0; for (const w of weights) sum += Math.max(0, w || 0); if (sum <= 0) return items[Math.floor(Math.random()*items.length)]; let r = Math.random() * sum; for (let i=0;i<items.length;i++){ r -= Math.max(0, weights[i] || 0); if (r <= 0) return items[i]; } return items[items.length - 1]; } /* ------------------------------ Boss hooks (from actions) ------------------------------ */ function bumpBossCounterFromSkillActivation(cardId){ const c = getCard(cardId); if (!c || c.type !== "skill") return; bumpBossCounter("skill_usage", 1); } function progressContractsFromSkill(cardId){ // placeholder hook if needed later } /* ------------------------------ Result helpers ------------------------------ */ function endRunIfNeeded(){ if (state.act >= 6) endRun(); } /* ------------------------------ Small placeholders for missing lore card ------------------------------ */ // The “Lore” system can be expanded later; kept minimal here.