Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
- [ ] **should** `periodic-autosave-scheduler` — фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва
- [ ] **should?** `wait-no-fastforward-with-npc` — **требует проверки: баг или медленно-но-корректно.** `wait` не делает fast-forward, когда в локации игрока сидит активный rule-NPC — вместо прыжка к `wake_at` раунд тикает по 6 c, и управление к игроку возвращается только через ~600 раундов (1 час игрового времени). Ожидание (playbook 2.3): время сдвигается на 1 час, ход быстро возвращается. **Repro (E2E sprint 020 phase 2):** мир «Долина Мечей», сессия 283d42a2, локация «Солёный Якорь» (`silverport_city_tavern`) с co-located rule-NPC «Марта»; игрок Grimwald QA (Fighter L1) жмёт «Ждать» → action bar застревает на «Ожидание хода…». Бэклог: `wait_sleep` hours=1, `wake_at=46326855600`, но `round_end` показывает `game_time` сдвинутым лишь на 6 c (`second=6`) — fast-forward «нет активных существ → прыжок к ближайшему wake_at» не срабатывает, т.к. Марта остаётся активной. Гипотеза: при `wait` игрок получает `wake_at` и перестаёт быть anchor'ом, значит co-located NPC тоже должен уйти в dormant и включить fast-forward — но не уходит. Проверить `ActivationManager.update_activation` / anchor-логику и путь fast-forward в `Round.run_loop`. NB: наблюдалось после evict→reconnect (см. `session-disconnect-debounce`), но поведение `wait` от этого не зависит. Смежно: `test-gap-ws-fastforward`. По simulation-core гипотеза корректна: расписание-NPC без якоря рядом не должен оставаться активным; тот самый путь, который перепишут `intents`/`anchor-as-property`, — но проверить/починить стоит уже сейчас
- [ ] **could** `saved-session-accumulation` — Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке
- [ ] **should** `session-disconnect-debounce` — быстрый disconnect+reconnect (React StrictMode remount, сетевой блип) гонит лишний evict: `remove_listener` останавливает раунд и `_on_session_empty` выселяет сессию из реестра. Симптом GAME OVER устранён в post-audit sprint 018 (раунд-луп больше не шлёт `on_game_over` при административном `stop()` — `Round.is_stopped`), но сессия всё равно выселяется и живёт орфаном на reconnect-WS (прогресс может не попасть в реестровый autosave; reload поднимает старый autosave). Простой re-check `has_listeners()` в `_on_session_empty` пробовали и откатили: на module-scoped WS-тестах он сохранял сессию вместо evict→reload-reset, и арена-бой накапливался до `game_over` (5 падений `test_websocket.py` в CI, timing-зависимо). Полноценный фикс — grace-period: при опустошении не выселять сразу, а отложенно (1–2с через `threading.Timer`) перепроверить пустоту и только тогда `stop_round`+evict; reconnect внутри окна отменяет (+ переработать module-scoped арена-фикстуру, чтобы не зависеть от evict-reset). Прод (без StrictMode-двумаунта) почти не задет, поэтому not-must
- [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` **сразу** ставит раунд на паузу (`stop_round`), а откладывает только выселение из реестра: ставится grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`), reconnect в окне отменяет timer, а вернувшийся игрок перезапускает раунд через `start_round`. Раньше откладывались И stop_round, И evict — из-за этого player-less раунд-луп продолжал крутить ходы NPC в grace-окне (при сетевом блипе игрок молча терял боевые ходы), а поскольку все сессии тянут один процесс-глобальный RNG костей, пересекающиеся «осиротевшие» раунды делали seed-зависимые integration-тесты недетерминированными (`test_player_state_xp::test_rest_status_updated_after_kill` флакал в CI). Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`.

## DevOps / Infra

Expand All @@ -103,14 +103,14 @@
- [x] `battle-map-configs-not-wired` — ~~`battle_map_configs` из `regions.yaml` не передаётся в `EntitiesLayer` при создании сессии в `game_service.py`. Все combat maps дефолтят в 60×60~~ FIXED Sprint 018 (verified Sprint 019 phase 3): `game_service.py:171-183` строит `battle_map_configs` через `_flatten_region_defaults(load_battle_maps(...))` и передаёт в `EntitiesLayer`
- [x] `player-character-no-attacks` — ~~`POST /api/player/sessions/{id}/character` не принимает `attacks`; персонаж дерётся кулаками (1 урон)~~ FIXED Sprint 013 char-creation (verified Sprint 019 phase 3): `create_player` грузит `starting_equipment` оружие, игрок бьёт через `get_weapon_attack()`. Поле `attacks` в `CreatePlayerRequest` вестигиальное для игрока (raw `attacks` — путь монстра/спавна)
- [x] `look-action-i18n-hardcode` — ~~`_cmd_look` в GameService хардкодит строки «Terrain:»/«Weather:» вместо `_()`~~ OBSOLETE Sprint 019 phase 3: `_cmd_look` удалён в раннем рефакторе, строк «Terrain:»/«Weather:» в `service/` нет (остались только устаревшие msgid в `.po`, помечены obsolete в phase 3 task 1)
- [x] `player-xp-not-persisted`~~XP и `level_up_available` игрока не переживают save/reload через современный путь~~ FIXED Sprint 020 phase 1 task 1 (сериализационная половина): `experience`/`level_up_available` в `to_full_save_data()` + `PlayerContent`/`_to_player`, round-trip regression-тест. Dev-симптом с WS StrictMode evict→restore остаётся в `session-disconnect-debounce` (транспортная половина)
- [x] `player-xp-not-persisted`: ~~XP и `level_up_available` игрока не переживают save/reload через современный путь~~ FIXED Sprint 020 phase 1 task 1 (сериализационная половина): `experience`/`level_up_available` в `to_full_save_data()` + `PlayerContent`/`_to_player`, round-trip regression-тест. Dev-симптом с WS StrictMode evict→restore закрыт в `session-disconnect-debounce` (транспортная половина)
- [ ] **could** `spawn-role-freetext-enum` — мастерский Spawn Creature диалог (`CreatureForm`) рендерит Role как свободный textbox, но бэкенд `NpcContent.role` — enum (`commoner`/`blacksmith`/`tavern_keeper`/`guard`/`merchant`/`farmer`/`gladiator`). Пустой/произвольный role → HTTP 400 с сырым Pydantic-сообщением прямо в диалоге (E2E sprint 019 phase 1). Сделать Role дропдауном `NpcRole` (и/или маппить ошибку в дружелюбный i18n-тост). Сосед `corpse-nearby-actions` по теме visible-gaps
- [ ] **should** `action-bar-unequip-i18n` — кнопки снятия экипировки в боевом action bar показывают сырые ID и английские описания (E2E sprint 020 phase 1). Оружие: метка «Снять» (RU ✓), но описание «Put away your equipped weapon. You will fight with fists.» (EN ✗). Броня/щит: метки `unequip_armor`/`unequip_shield` — сырые `ActionType`-строки (EN ✗), описания тоже английские. Только weapon-unequip переведён. Фикс: добавить `unequip_armor`/`unequip_shield` в таблицу локализации фронта рядом с `unequip`; перевести описания в `.po`. NB: Sprint 020 phase 3 task 6 сохранил 12 ActionType (реестр сделан бэкенд-internal, коллапс отложен в `equip-action-collapse`) — эти метки не меняются сейчас; при будущем коллапсе синхронизировать оба айтема одним PR
- [ ] **could** `second-wind-zero-heal` — Second Wind показывает «восстанавливаешь 0 ОЗ» когда игрок уже при максимальных HP (E2E sprint 020 phase 1). Сообщение корректно с механической точки зрения (ресурс потрачен, лечение = 0), но выглядит как баг. Подавлять или заменять на «ты уже в полном здравии» когда `healed == 0`.
- [x] `combat-log-i18n-gaps` — ~~при дефолтном `DND_LANGUAGE=ru` боевой лог наполовину английский~~ FIXED: movement-ошибки обёрнуты в `_()` Sprint 019 phase 3; остальные хендлеры (items/equipment/trade/action_surge/loot/combat) + прогон каталога и RU-перевод Sprint 020 phase 1 task 4. Остаточные фронтовые метки — в `action-bar-unequip-i18n`
- [x] `sneak-attack-faction-check` — ~~SA ally-adjacency считала союзником любое живое существо в 5ft без учёта фракции~~ FIXED Sprint 011/014: ally detection через faction relations
- [x] `flaky-initiative-test` — ~~`test_second_attack_does_not_reroll_initiative` падал рандомно~~ FIXED: AC=30 чтобы атаки всегда мазали, c2 не удаляется из turn_order
- [ ] **should** `vitest-load-flakes` — 6 vitest-тестов в 4 файлах падают в полном `npx vitest run` под нагрузкой, но зелёные в изоляции (baseline-прогон orca-воркера на чистом main, 2026-07-04): `CreatureForm.test.tsx` (2: HP current/max), `EntityListEditor.test.tsx` (2: create/edit panel), `CreatureList.test.tsx` (1: brain toggle toast), `LevelUpModal.test.tsx` (1: Paladin fighting styles). Все тесты тяжёлые (7-17с) — похоже на waitFor/timeout под параллельной нагрузкой. Та же семья, что `flaky-schemaform-ref-select`. Фикс: поднять testTimeout/waitFor бюджеты или ограничить параллелизм vitest для тяжёлых файлов
- [x] **should** `vitest-load-flakes` — mitigated 2026-07-10: `fileParallelism: false` в `frontend/vite.config.ts` (в этом PR) сериализует запуск тестовых файлов, снимая параллельную нагрузку, из-за которой падали 6 тестов в 4 файлах (`CreatureForm.test.tsx` HP current/max, `EntityListEditor.test.tsx` create/edit panel, `CreatureList.test.tsx` brain toggle toast, `LevelUpModal.test.tsx` Paladin fighting styles). Корневая причина — изоляция тестов под нагрузкой (waitFor/timeout при параллельном исполнении тяжёлых 7-17с файлов); правильный долгий фикс — ревизия изоляции тяжёлых файлов (а не только сериализация всего прогона). Та же семья, что `flaky-schemaform-ref-select`
- [ ] **could** `tsc-build-test-looseness` — `tsc -b` даёт 28 ошибок в тест/конфиг-файлах фронта, при зелёном CI-гейте `tsc --noEmit` (baseline orca-воркера 2026-07-04). Не гейтится, но это реальная типовая расхлябанность тестов. Подтянуть или задокументировать, почему гейт именно `--noEmit`
- [ ] **could** `flaky-schemaform-ref-select` — `frontend/src/components/master/__tests__/SchemaForm.test.tsx > renders ref field as select with fetched options` флапает в полном `npx vitest run` (ждёт 3 option, видит 1), но зелёный при изоляции файла и на повторе. Похоже на гонку мока fetch ref-опций / async-рендера select. Замечен на Sprint 018 phase 3 (бэкенд-only коммит, влиять не мог). Стабилизировать ожидание опций (`findBy`/`waitFor`) или изолировать fetch-мок между тестами

Expand Down
1 change: 1 addition & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ No active sprint.

## Recent activity (non-sprint)

- 2026-07-10: перенесены ценные фичи из `sprint/020-control-interfaces`: disconnect grace-period закрыл `session-disconnect-debounce`, spectator-listener добавил read-only WS `?spectate=true` и live-вкладку в master session view.
- 2026-07-10 — Sprint 020 thermo-sweep закрыт: integration 154 passed, post-audit E2E smoke 5/5, audit triaged, PR opened to main.
- 2026-07-04 — брейншторм [simulation-core](brainstorms/simulation-core.md): консенсус-модель времени/активности/внутреннего я/лестницы детализации. VISION.md переписан, BACKLOG реструктурирован (секция Simulation Core, поглощённые/переформулированные айтемы, чекбоксы фаз 1-2 спринта 020), ROADMAP Planned обновлён, указатели-актуализации в старых брейнштормах.
- 2026-06-20 — CORS origins сделаны конфигурируемыми (`CORS_ALLOWED_ORIGINS`); Docker base-image запинен по digest.
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/components/master/SessionLiveFeed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { WsClient } from "@/transport/wsClient"
import type { ServerMessage } from "@/types/ws"

interface FeedItem {
id: number
description: string
eventType: string
}

// Read-only live event feed for a master observing a session. Opens a dedicated
// spectator WebSocket (never the player singleton) and renders incoming
// `PerceivedEvent.description` lines, newest last.
export function SessionLiveFeed({ sessionId }: { sessionId: string }) {
const { t } = useTranslation(["master"])
const [items, setItems] = useState<FeedItem[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
const seqRef = useRef(0)

useEffect(() => {
const client = new WsClient()
const unsub = client.onMessage((msg: ServerMessage) => {
if (msg.type !== "turn" && msg.type !== "action_result" && msg.type !== "round_result") return
const events = msg.events ?? []
if (events.length === 0) return
setItems((prev) => [
...prev,
...events.map((e) => ({
id: seqRef.current++,
description: e.description,
eventType: e.event_type,
})),
])
})
client.connect(sessionId, { spectate: true })
return () => {
unsub()
client.disconnect()
}
}, [sessionId])

// Auto-scroll to newest line.
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
}, [items.length])

return (
<div
ref={scrollRef}
data-testid="live-feed"
className="max-h-[28rem] overflow-y-auto rounded border border-border bg-muted/20 p-3 font-mono text-xs"
>
{items.length === 0 ? (
<div className="text-muted-foreground">{t("master:live_empty")}</div>
) : (
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-2">
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{item.eventType}
</span>
<span className="text-foreground">{item.description}</span>
</li>
))}
</ul>
)}
</div>
)
}
5 changes: 4 additions & 1 deletion frontend/src/components/master/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import type { WorldStateResponse } from "@/types/api"
import { Button } from "@/components/ui/button"
import { WorldOverview } from "./WorldOverview"
import { CreatureList } from "./CreatureList"
import { SessionLiveFeed } from "./SessionLiveFeed"
import { TimeControl } from "./TimeControl"
import { SavesPanel } from "./SavesPanel"
import { Skeleton } from "@/components/ui/skeleton"
import { ArrowLeft } from "lucide-react"

type Tab = "world" | "creatures" | "time" | "saves"
type Tab = "world" | "creatures" | "live" | "time" | "saves"

export function SessionView() {
const { sessionId } = useParams<{ sessionId: string }>()
Expand Down Expand Up @@ -46,6 +47,7 @@ export function SessionView() {
const tabs: { key: Tab; label: string }[] = [
{ key: "world", label: t("master:tab_world") },
{ key: "creatures", label: t("master:tab_creatures") },
{ key: "live", label: t("master:tab_live") },
{ key: "time", label: t("master:tab_time") },
{ key: "saves", label: t("master:tab_saves") },
]
Expand Down Expand Up @@ -100,6 +102,7 @@ export function SessionView() {
{tab === "creatures" && (
<CreatureList sessionId={sessionId} />
)}
{tab === "live" && <SessionLiveFeed sessionId={sessionId} />}
{tab === "time" && (
<TimeControl sessionId={sessionId} onAdvanced={refresh} />
)}
Expand Down
Loading
Loading