184 build - буллиты, добавленна следующая серия

This commit is contained in:
2026-10-02 15:54:02 +03:00
parent bc0f0310ca
commit 5a0b1e1ce8
6 changed files with 186 additions and 6 deletions

69
README_BUILD187_RU.txt Normal file
View File

@@ -0,0 +1,69 @@
BUILD187 — 2026.10.02.7
Буллиты: следующая серия и SQL для шести картинок
Устанавливается поверх BUILD186 (2026.10.02.6).
В архиве только изменённые файлы приложения, SQL и эта инструкция:
app.py
ui_builder/static/app.js
ui_builder/static/styles.css
hockey_data/service.py
shootout_3_home_3_away.sql
Установка
1. Остановите сервер.
2. Замените четыре файла приложения, сохранив пути внутри проекта.
3. Запустите сервер и обновите браузер через Ctrl+F5.
Версия /api/health: 2026.10.02.7.
Миграция базы не нужна. Agent менять не требуется.
Вкладка «Буллиты»
Кнопка «Следующая серия» теперь видна постоянно. Под ней показано, что она
добавляет по одному буллиту каждой команде. Кнопка доступна оператору после
завершения текущей серии обеими командами при равном счёте. Иначе рядом показана
причина, почему следующую серию пока нельзя открыть.
Отображается основная серия или номер дополнительной серии.
Повторные нажатия во время запроса не добавляют несколько раундов.
Основная серия на 3 или 5 буллитов продолжает работать.
SQL
Содержимое shootout_3_home_3_away.sql вставьте в SQL-источник Mapping.
Для этого титра с тремя ячейками каждой команды выберите основную серию на 3 буллита.
Единственная переменная запроса — :game_id, внешний идентификатор выбранного матча.
Связи с шестью Image/Source полями титра:
pic1 — хозяева, первая ячейка
pic2 — хозяева, вторая ячейка
pic3 — хозяева, третья ячейка
pic4 — гости, первая ячейка
pic5 — гости, вторая ячейка
pic6 — гости, третья ячейка
Забил (scored = TRUE) → Green-01.png.
Не забил (scored = FALSE) → Red-01.png.
Попытка ещё не выполнена → Line-01.png.
scored — логическое поле базы, а не строки «Да»/«Нет».
Основная серия заполняет три ячейки каждой команды по порядку.
При нажатии «Следующая серия» текущая серия в базе меняется сразу, до первого
нового броска. SQL возвращает Line-01.png для всех шести ячеек.
Новые дополнительные броски заполняют только pic1 (хозяева) и pic4 (гости).
pic2, pic3, pic5, pic6 в дополнительных сериях всегда Line-01.png.
Следующая дополнительная серия снова очищает pic1/pic4 до нового результата.
Полный журнал прошлых бросков остаётся в базе и во вкладке «Буллиты».
SQL возвращает одну строку с шестью полями даже без выполненных попыток,
поэтому старые картинки не сохраняются из-за пустого результата запроса.
SQL обновляет плашки при выполнении источника Mapping. Для автоматической смены
картинок после регистрации броска включите автообновление именно этого SQL-источника.
При отключённом автообновлении выполните Mapping или вызовите настроенный титр
кнопкой, которая обновляет его данные перед выдачей.
Удаление попытки
Исправлена нумерация оставшегося журнала после удаления ранней попытки:
номера команды и серии пересчитываются, следующий бросок не занимает уже занятое
место. Уже открытая дополнительная серия сохраняется.
Проверки выполнялись на изолированной тестовой базе и в браузере с имитацией API.
Проверены пустой титр, гол/промах, порядок хозяева/гости, дополнительные серии,
удаление и повторная запись попыток, режимы 3/5, ограничения кнопки и повторные клики.
Доступ к рабочей базе и реальному vMix при подготовке не использовался.

2
app.py
View File

@@ -35,7 +35,7 @@ from khl_site.khl_data_center import APP as khl_site_app, configure_storage, syn
from hockey_data.khl_career import KhlCareerService
BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.10.02.6"
BUILD_VERSION = "2026.10.02.7"
# compatibility: BUILD_VERSION = "2026.09.24.2"
# compatibility: BUILD_VERSION = "2026.09.23.8"
# compatibility: BUILD_VERSION = "2026.09.23.7"

View File

@@ -8533,6 +8533,29 @@ class HockeyDataService:
raise ValueError("Буллит не найден")
session.delete(attempt)
row = self._get_or_create_game_control(session, str(game_external_id))
session.flush()
remaining = list(session.scalars(
select(ShootoutAttempt)
.where(ShootoutAttempt.game_external_id == str(game_external_id))
.order_by(ShootoutAttempt.sequence_number, ShootoutAttempt.id)
))
# Deleting an earlier attempt shifts the journal slots. Rebuild the
# stored numbering too, so the next attempt cannot reuse a team's
# slot or keep an old result in the currently opened extra round.
# Move all sequence numbers aside before writing their final values
# to satisfy the game's unique sequence constraint during the flush.
for index, item in enumerate(remaining, 1):
item.sequence_number = -index
session.flush()
initial = 5 if row.shootout_initial_attempts == 5 else 3
side_attempt_numbers: dict[str, int] = {}
for index, item in enumerate(remaining, 1):
side_number = side_attempt_numbers.get(item.side, 0) + 1
side_attempt_numbers[item.side] = side_number
item.sequence_number = index
item.team_attempt_number = side_number
item.series_number = 1 if side_number <= initial else 1 + side_number - initial
session.flush()
row.updated_by = str(updated_by or "")[:128]
row.updated_at = datetime.utcnow()
result = self.game_control(game_external_id, language=language)

View File

@@ -0,0 +1,40 @@
WITH current_series AS (
SELECT
1 + COALESCE(c.shootout_extra_rounds, 0) AS series_number
FROM (SELECT CAST(:game_id AS text) AS game_id) p
LEFT JOIN hockey_game_control_states c
ON c.game_external_id = p.game_id
),
attempt_images AS (
SELECT
a.side,
CASE
WHEN s.series_number = 1 THEN a.team_attempt_number
ELSE 1
END AS slot,
CASE
WHEN a.scored IS TRUE
THEN 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Green-01.png'
WHEN a.scored IS FALSE
THEN 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Red-01.png'
ELSE 'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png'
END AS image_path
FROM hockey_shootout_attempts a
CROSS JOIN current_series s
WHERE a.game_external_id = :game_id
AND a.series_number = s.series_number
)
SELECT
COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 1),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic1,
COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 2),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic2,
COALESCE(MAX(image_path) FILTER (WHERE side = 'home' AND slot = 3),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic3,
COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 1),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic4,
COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 2),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic5,
COALESCE(MAX(image_path) FILTER (WHERE side = 'away' AND slot = 3),
'D:\Графика\ХОККЕЙ\KHL_26_27\Elements\Shootout\Line-01.png') AS pic6
FROM attempt_images;

View File

@@ -14806,6 +14806,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
return node;
}
const shootout = control.shootout || {};
state.hockeyShootoutRoundBusy ||= new Set();
const attempts = Array.isArray(shootout.attempts) ? shootout.attempts : [];
const homeAttempts = attempts.filter((item) => item.side === "home");
const awayAttempts = attempts.filter((item) => item.side === "away");
@@ -14888,8 +14889,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<section class="hso-setup">
<div class="hso-series-size"><span>${language === "en" ? "Main series" : "Основная серия"}</span><button type="button" data-shootout-size="3" class="${initialAttempts === 3 ? "active" : ""}">3</button><button type="button" data-shootout-size="5" class="${initialAttempts === 5 ? "active" : ""}">5</button></div>
<button type="button" class="hso-start" data-shootout-start ${runtime ? "" : "disabled"}>${shootout.started ? (language === "en" ? "Restart series" : "Начать заново") : (language === "en" ? "Start series" : "Начать серию")}</button>
${shootout.can_add_round ? `<button type="button" class="hso-next-series" data-shootout-round ${runtime ? "" : "disabled"}>+ ${language === "en" ? "1 attempt each" : "по 1 буллиту"}</button>` : ""}
<div class="hso-round-action"><button type="button" class="hso-next-series" data-shootout-round disabled>${language === "en" ? "Next series" : "Следующая серия"}</button><small>${language === "en" ? "+1 attempt for each team" : "+1 буллит каждой команде"}</small></div>
<div class="hso-series-info"><strong>${allowed}</strong><span>${language === "en" ? "attempts per team available" : "буллитов доступно каждой команде"}</span></div>
<div class="hso-round-state"><strong data-shootout-series-state></strong><span data-shootout-round-status role="status" aria-live="polite"></span></div>
</section>
<section class="hso-progress">
<div class="side-home"><strong>${escapeHtml(home.name || "—")}</strong><div>${hockeyShootoutSlots(homeAttempts, allowed, language)}</div></div>
@@ -14923,9 +14925,51 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
});
state.formValues[selectedKey] = null;
});
node.querySelector("[data-shootout-round]")?.addEventListener("click", async () => {
if (!runtime) return;
node.dataset.shootoutGame = gameId;
const roundButton = node.querySelector("[data-shootout-round]");
const sameGame = () => {
const current = getByPath(state.data, props.gamePath || "hockey.selected_game") || {};
return String(current.external_id || current.id || "").trim() === gameId;
};
const refreshRoundAction = () => {
const current = state.hockeyGameControl[gameId]?.shootout || {};
const busy = state.hockeyShootoutRoundBusy.has(gameId);
const canControl = runtime && hockeyCanControl() && sameGame();
const extraRounds = Math.max(0, Number(current.extra_rounds || 0));
const seriesState = extraRounds > 0
? `${language === "en" ? "Extra series" : "Дополнительная серия"} №${extraRounds}`
: language === "en" ? "Main series" : "Основная серия";
const status = busy ? (language === "en" ? "Adding one attempt for each team…" : "Добавляем по одному буллиту каждой команде…")
: !canControl ? (language === "en" ? "Next series is available to the match operator." : "Следующая серия доступна оператору матча.")
: !current.started ? (language === "en" ? "Start the main series first." : "Сначала начните основную серию.")
: !current.both_complete ? (language === "en" ? "Complete the current series for both teams." : "Завершите текущую серию обеих команд.")
: !current.tied ? (language === "en" ? "An extra series is available when the score is tied." : "Дополнительная серия доступна при равном счёте.")
: current.can_add_round === true ? (language === "en" ? "The score is tied. Add one attempt for each team." : "Счёт равный: можно добавить по одному буллиту каждой команде.")
: language === "en" ? "Wait for the shootout data to update." : "Дождитесь обновления данных буллитов.";
roundButton.disabled = busy || !canControl || current.can_add_round !== true;
roundButton.textContent = busy ? (language === "en" ? "Adding…" : "Добавляем…") : (language === "en" ? "Next series" : "Следующая серия");
roundButton.title = status;
roundButton.setAttribute("aria-busy", String(busy));
node.querySelector("[data-shootout-series-state]").textContent = seriesState;
node.querySelector("[data-shootout-round-status]").textContent = status;
};
node.refreshShootoutRoundAction = refreshRoundAction;
refreshRoundAction();
roundButton.addEventListener("click", async () => {
if (!runtime || !hockeyCanControl() || !sameGame() || state.hockeyShootoutRoundBusy.has(gameId)
|| state.hockeyGameControl[gameId]?.shootout?.can_add_round !== true) return;
state.hockeyShootoutRoundBusy.add(gameId);
refreshRoundAction();
try {
await hockeyUpdateGameControl(gameId, `/shootout/round?language=${language}`, { method: "POST" });
} catch (error) {
console.error("Shootout next series failed", error);
} finally {
state.hockeyShootoutRoundBusy.delete(gameId);
document.querySelectorAll(".hockey-shootout-control").forEach((current) => {
if (current.dataset.shootoutGame === gameId) current.refreshShootoutRoundAction?.();
});
}
});
const applyShootoutSearch = (side, rawValue) => {
const query = normalizePlayerSearch(rawValue);

View File

@@ -5687,7 +5687,7 @@ body.hockey-navigation-open .runtime-viewport > .runtime-sizer { margin-inline:
.hso-score { display: flex; align-items: center; gap: 12px; }
.hso-score span { max-width: 160px; overflow: hidden; color: #8ca0b6; font-size: 10px; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
.hso-score strong { min-width: 92px; padding: 7px 12px; color: #fff; border: 1px solid #334b65; border-radius: 11px; background: #0e1d2d; font-size: 23px; font-weight: 950; text-align: center; font-variant-numeric: tabular-nums; }
.hso-setup { min-height: 52px; display: flex; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #1d3147; background: rgba(255,255,255,.018); }
.hso-setup { min-height: 52px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 16px; border-bottom: 1px solid #1d3147; background: rgba(255,255,255,.018); }
.hso-series-size { display: flex; align-items: center; gap: 5px; }
.hso-series-size>span { margin-right: 4px; color: #8296ad; font-size: 9px; font-weight: 850; }
.hso-series-size button,
@@ -5697,6 +5697,10 @@ body.hockey-navigation-open .runtime-viewport > .runtime-sizer { margin-inline:
.hso-series-size button.active { color: #07150f; border-color: #48dfbd; background: #48dfbd; }
.hso-start { color: #dbe8f5; }
.hso-next-series { color: #ffe2a3; border-color: rgba(240,189,101,.55); background: rgba(240,189,101,.09); }
.hso-round-action { display: grid; gap: 3px; text-align: center; }
.hso-round-action small { color: #8296ad; font-size: 8px; }
.hso-round-state { flex-basis: 100%; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px 12px; color: #8296ad; font-size: 9px; }
.hso-round-state strong { color: #dce8f4; font-size: 10px; }
.hso-series-size button:disabled,
.hso-start:disabled,
.hso-next-series:disabled { opacity: .45; cursor: default; }