184 build - буллиты, добавленна следующая серия
This commit is contained in:
69
README_BUILD187_RU.txt
Normal file
69
README_BUILD187_RU.txt
Normal 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
2
app.py
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
40
shootout_3_home_3_away.sql
Normal file
40
shootout_3_home_3_away.sql
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user