update
This commit is contained in:
@@ -59,12 +59,31 @@ CREATE TABLE IF NOT EXISTS score_attachments (
|
||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_positions (
|
||||
board TEXT NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(board, player_id),
|
||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stage_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phase TEXT NOT NULL,
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
round INTEGER NOT NULL DEFAULT 1,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||
('view_proof_in_view', '0'),
|
||||
('live_active_view', 'group_live'),
|
||||
|
||||
@@ -78,6 +78,84 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleStartCurrentStage(c echo.Context) error {
|
||||
var req StageControlStartRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
phase, ok := normalizeStagePhase(req.Phase)
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid phase")
|
||||
}
|
||||
groupKey, ok := normalizeStageGroup(phase, req.GroupKey)
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid group for selected phase")
|
||||
}
|
||||
round := normalizeStageRound(phase, req.Round)
|
||||
|
||||
current, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if current != nil {
|
||||
return writeError(c, http.StatusConflict, "there is already an active stage. end it first")
|
||||
}
|
||||
|
||||
_, err = a.db.Exec(`
|
||||
INSERT INTO stage_sessions(phase, group_key, round, started_at)
|
||||
VALUES(?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, phase, groupKey, round)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("start stage: %v", err))
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleEndCurrentStage(c echo.Context) error {
|
||||
current, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if current == nil {
|
||||
return writeError(c, http.StatusBadRequest, "no active stage to end")
|
||||
}
|
||||
|
||||
_, err = a.db.Exec(`
|
||||
UPDATE stage_sessions
|
||||
SET ended_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND ended_at IS NULL
|
||||
`, current.ID)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("end stage: %v", err))
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleGetStageHistory(c echo.Context) error {
|
||||
items, err := a.readStageHistory()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePlayer(c echo.Context) error {
|
||||
var req PlayerCreateRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
@@ -295,6 +373,77 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdatePositionBoard(c echo.Context) error {
|
||||
board, ok := normalizePositionBoard(c.Param("board"))
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid position board")
|
||||
}
|
||||
|
||||
var req PositionBoardUpdateRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
for _, slot := range req.Slots {
|
||||
if slot.PlayerID <= 0 {
|
||||
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||
}
|
||||
if seen[slot.PlayerID] {
|
||||
return writeError(c, http.StatusBadRequest, "duplicate player id in slots")
|
||||
}
|
||||
seen[slot.PlayerID] = true
|
||||
if strings.TrimSpace(slot.GroupKey) == "" {
|
||||
return writeError(c, http.StatusBadRequest, "groupKey is required")
|
||||
}
|
||||
if slot.Position <= 0 {
|
||||
return writeError(c, http.StatusBadRequest, "position must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, "failed to start position transaction")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_positions WHERE board = ?`, board); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("clear board positions: %v", err))
|
||||
}
|
||||
|
||||
for _, slot := range req.Slots {
|
||||
groupKey := normalizePositionGroupKey(board, slot.GroupKey, "")
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO player_positions(board, player_id, group_key, position, updated_at)
|
||||
VALUES(?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, board, slot.PlayerID, groupKey, slot.Position); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("save board position: %v", err))
|
||||
}
|
||||
|
||||
if board == positionBoardPreliminary {
|
||||
groupCode := strings.TrimSpace(groupKey)
|
||||
if strings.EqualFold(groupCode, positionBoardUnassigned) {
|
||||
groupCode = ""
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, slot.PlayerID); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player group from board: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, "failed to commit positions")
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateScore(c echo.Context) error {
|
||||
stage, err := validateStage(c.Param("stage"))
|
||||
if err != nil {
|
||||
|
||||
@@ -84,13 +84,30 @@ type FinalGroups struct {
|
||||
}
|
||||
|
||||
type StateResponse struct {
|
||||
Competition CompetitionMeta `json:"competition"`
|
||||
Players []Player `json:"players"`
|
||||
Scores map[string]map[string]int `json:"scores"`
|
||||
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||
Settings AppSettings `json:"settings"`
|
||||
Derived DerivedState `json:"derived"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
Competition CompetitionMeta `json:"competition"`
|
||||
Players []Player `json:"players"`
|
||||
Scores map[string]map[string]int `json:"scores"`
|
||||
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
|
||||
Settings AppSettings `json:"settings"`
|
||||
Derived DerivedState `json:"derived"`
|
||||
CurrentStage *StageSessionState `json:"current_stage"`
|
||||
LastStage *StageSessionState `json:"last_stage"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
}
|
||||
|
||||
type StageSessionState struct {
|
||||
ID int `json:"id"`
|
||||
Phase string `json:"phase"`
|
||||
GroupKey string `json:"group"`
|
||||
Round int `json:"round"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt,omitempty"`
|
||||
}
|
||||
|
||||
type PositionSlot struct {
|
||||
GroupKey string `json:"groupKey"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type AppSettings struct {
|
||||
@@ -170,6 +187,16 @@ type AutoGroupPlayersRequest struct {
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
type PositionBoardUpdateRequest struct {
|
||||
Slots []PositionBoardSlotInput `json:"slots"`
|
||||
}
|
||||
|
||||
type PositionBoardSlotInput struct {
|
||||
PlayerID int `json:"playerId"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type ScoreAdviceResponse struct {
|
||||
Stage string `json:"stage"`
|
||||
PlayerID int `json:"playerId"`
|
||||
@@ -177,3 +204,9 @@ type ScoreAdviceResponse struct {
|
||||
Reason string `json:"reason"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type StageControlStartRequest struct {
|
||||
Phase string `json:"phase"`
|
||||
GroupKey string `json:"group"`
|
||||
Round int `json:"round"`
|
||||
}
|
||||
|
||||
@@ -1080,6 +1080,7 @@
|
||||
"prelim_overall",
|
||||
"final_groups",
|
||||
"final_tie",
|
||||
"final_overall",
|
||||
"podium"
|
||||
]
|
||||
},
|
||||
@@ -1149,6 +1150,7 @@
|
||||
"prelim_overall",
|
||||
"final_groups",
|
||||
"final_tie",
|
||||
"final_overall",
|
||||
"podium"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,9 +22,13 @@ func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||
api.POST("/admin/login", app.handleAdminLogin)
|
||||
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
||||
api.GET("/admin/state", app.handleGetState, app.adminOnly)
|
||||
api.GET("/admin/stage/history", app.handleGetStageHistory, app.adminOnly)
|
||||
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
|
||||
api.POST("/admin/stage/start", app.handleStartCurrentStage, app.adminOnly)
|
||||
api.POST("/admin/stage/end", app.handleEndCurrentStage, app.adminOnly)
|
||||
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
|
||||
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
|
||||
api.PUT("/admin/positions/:board", app.handleUpdatePositionBoard, app.adminOnly)
|
||||
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
|
||||
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
|
||||
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
|
||||
|
||||
@@ -355,7 +355,7 @@ func normalizeRotationPlayerCountInt(value int) int {
|
||||
|
||||
func normalizeLiveActiveView(value string, fallback string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
|
||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium":
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
default:
|
||||
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
||||
|
||||
172
backend/stage_sessions.go
Normal file
172
backend/stage_sessions.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
stagePhasePreliminary = "preliminary"
|
||||
stagePhasePrelimTie = "prelim_tiebreak"
|
||||
stagePhaseFinal = "final"
|
||||
stagePhaseFinalTie = "final_tiebreak"
|
||||
)
|
||||
|
||||
var allowedStagePhases = map[string]bool{
|
||||
stagePhasePreliminary: true,
|
||||
stagePhasePrelimTie: true,
|
||||
stagePhaseFinal: true,
|
||||
stagePhaseFinalTie: true,
|
||||
}
|
||||
|
||||
func normalizeStagePhase(raw string) (string, bool) {
|
||||
phase := strings.ToLower(strings.TrimSpace(raw))
|
||||
if !allowedStagePhases[phase] {
|
||||
return "", false
|
||||
}
|
||||
return phase, true
|
||||
}
|
||||
|
||||
func normalizeStageGroup(phase string, raw string) (string, bool) {
|
||||
group := strings.TrimSpace(raw)
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if strings.EqualFold(group, positionBoardUnassigned) {
|
||||
return positionBoardUnassigned, true
|
||||
}
|
||||
group = normalizePreliminaryGroupKey(group)
|
||||
if group == "" {
|
||||
return "", false
|
||||
}
|
||||
return group, true
|
||||
case stagePhaseFinal:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || (parsed != 1 && parsed != 2) {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
case stagePhasePrelimTie, stagePhaseFinalTie:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || parsed <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStageRound(phase string, round int) int {
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 3 {
|
||||
return 3
|
||||
}
|
||||
return round
|
||||
case stagePhaseFinal:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 2 {
|
||||
return 2
|
||||
}
|
||||
return round
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimestamp(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
|
||||
var item StageSessionState
|
||||
var ended sql.NullString
|
||||
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
|
||||
item.GroupKey = strings.TrimSpace(item.GroupKey)
|
||||
item.StartedAt = normalizeTimestamp(item.StartedAt)
|
||||
if ended.Valid {
|
||||
item.EndedAt = normalizeTimestamp(ended.String)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *App) readCurrentStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read current stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readLastStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read last stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readStageHistory() ([]StageSessionState, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query stage history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]StageSessionState, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanStageSession(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan stage history: %w", scanErr)
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stage history: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
244
backend/state.go
244
backend/state.go
@@ -8,6 +8,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
positionBoardPreliminary = "preliminary"
|
||||
positionBoardFinal = "final"
|
||||
positionBoardPrelimTie = "prelim_tiebreak"
|
||||
positionBoardFinalTie = "final_tiebreak"
|
||||
positionBoardUnassigned = "UNASSIGNED"
|
||||
)
|
||||
|
||||
var allowedPositionBoards = map[string]bool{
|
||||
positionBoardPreliminary: true,
|
||||
positionBoardFinal: true,
|
||||
positionBoardPrelimTie: true,
|
||||
positionBoardFinalTie: true,
|
||||
}
|
||||
|
||||
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
players, err := a.readPlayers()
|
||||
if err != nil {
|
||||
@@ -38,17 +53,32 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
}
|
||||
|
||||
derived := computeDerived(players, scoreMap)
|
||||
positionBoards, err := a.readPositionBoards(players, derived)
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
currentStage, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
lastStage, err := a.readLastStage()
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
|
||||
response := StateResponse{
|
||||
Competition: CompetitionMeta{
|
||||
TitleAr: "بطولة دويتوايلر للرماية",
|
||||
TitleEn: "Datwyler Shooting Event",
|
||||
},
|
||||
Players: players,
|
||||
Scores: scoreMapToJSON(scoreMap),
|
||||
Settings: settings,
|
||||
Derived: derived,
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
Players: players,
|
||||
Scores: scoreMapToJSON(scoreMap),
|
||||
PositionBoards: positionBoardMapToJSON(positionBoards),
|
||||
Settings: settings,
|
||||
Derived: derived,
|
||||
CurrentStage: currentStage,
|
||||
LastStage: lastStage,
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if includeScoreProofs {
|
||||
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
|
||||
@@ -133,6 +163,210 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri
|
||||
return out
|
||||
}
|
||||
|
||||
func positionBoardMapToJSON(positionMap map[string]map[int]PositionSlot) map[string]map[string]PositionSlot {
|
||||
out := map[string]map[string]PositionSlot{}
|
||||
for board := range allowedPositionBoards {
|
||||
out[board] = map[string]PositionSlot{}
|
||||
}
|
||||
for board, boardMap := range positionMap {
|
||||
if _, ok := out[board]; !ok {
|
||||
out[board] = map[string]PositionSlot{}
|
||||
}
|
||||
for playerID, slot := range boardMap {
|
||||
out[board][strconv.Itoa(playerID)] = slot
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) readPositionBoards(players []Player, derived DerivedState) (map[string]map[int]PositionSlot, error) {
|
||||
defaults := defaultPositionBoards(players, derived)
|
||||
merged := map[string]map[int]PositionSlot{}
|
||||
for board, boardMap := range defaults {
|
||||
merged[board] = map[int]PositionSlot{}
|
||||
for playerID, slot := range boardMap {
|
||||
merged[board][playerID] = slot
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := a.db.Query(`SELECT board, player_id, group_key, position FROM player_positions`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query player positions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var boardRaw string
|
||||
var playerID int
|
||||
var groupKey string
|
||||
var position int
|
||||
if err := rows.Scan(&boardRaw, &playerID, &groupKey, &position); err != nil {
|
||||
return nil, fmt.Errorf("scan player position: %w", err)
|
||||
}
|
||||
board, ok := normalizePositionBoard(boardRaw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
boardMap := merged[board]
|
||||
if boardMap == nil {
|
||||
continue
|
||||
}
|
||||
defaultSlot, exists := boardMap[playerID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
pos := normalizePositionValue(position, defaultSlot.Position)
|
||||
group := normalizePositionGroupKey(board, groupKey, defaultSlot.GroupKey)
|
||||
boardMap[playerID] = PositionSlot{
|
||||
GroupKey: group,
|
||||
Position: pos,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate player positions: %w", err)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func defaultPositionBoards(players []Player, derived DerivedState) map[string]map[int]PositionSlot {
|
||||
out := map[string]map[int]PositionSlot{
|
||||
positionBoardPreliminary: {},
|
||||
positionBoardFinal: {},
|
||||
positionBoardPrelimTie: {},
|
||||
positionBoardFinalTie: {},
|
||||
}
|
||||
|
||||
preRows := make([]Player, 0, len(players))
|
||||
preRows = append(preRows, players...)
|
||||
sort.SliceStable(preRows, func(i, j int) bool {
|
||||
ga := normalizePreliminaryGroupKey(preRows[i].GroupCode)
|
||||
gb := normalizePreliminaryGroupKey(preRows[j].GroupCode)
|
||||
if ga != gb {
|
||||
return ga < gb
|
||||
}
|
||||
return preRows[i].ID < preRows[j].ID
|
||||
})
|
||||
preGroupCounts := map[string]int{}
|
||||
for _, player := range preRows {
|
||||
group := normalizePreliminaryGroupKey(player.GroupCode)
|
||||
preGroupCounts[group]++
|
||||
out[positionBoardPreliminary][player.ID] = PositionSlot{
|
||||
GroupKey: group,
|
||||
Position: preGroupCounts[group],
|
||||
}
|
||||
}
|
||||
|
||||
finalGroupCounts := map[string]int{}
|
||||
for _, row := range derived.FinalGroups.Group1 {
|
||||
group := "1"
|
||||
finalGroupCounts[group]++
|
||||
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||
}
|
||||
for _, row := range derived.FinalGroups.Group2 {
|
||||
group := "2"
|
||||
finalGroupCounts[group]++
|
||||
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||
}
|
||||
|
||||
preRowsByID := map[int]RankingRow{}
|
||||
for _, row := range derived.PreliminaryRanking.Rows {
|
||||
preRowsByID[row.PlayerID] = row
|
||||
}
|
||||
preTieRows := make([]RankingRow, 0, len(derived.PreliminaryRanking.TieBreak.PlayerIDs))
|
||||
for _, id := range derived.PreliminaryRanking.TieBreak.PlayerIDs {
|
||||
row, ok := preRowsByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
preTieRows = append(preTieRows, row)
|
||||
}
|
||||
sort.SliceStable(preTieRows, func(i, j int) bool {
|
||||
if preTieRows[i].TieBreak != preTieRows[j].TieBreak {
|
||||
return preTieRows[i].TieBreak > preTieRows[j].TieBreak
|
||||
}
|
||||
if preTieRows[i].Score != preTieRows[j].Score {
|
||||
return preTieRows[i].Score > preTieRows[j].Score
|
||||
}
|
||||
return preTieRows[i].PlayerID < preTieRows[j].PlayerID
|
||||
})
|
||||
for idx, row := range preTieRows {
|
||||
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||
position := (idx % 6) + 1
|
||||
out[positionBoardPrelimTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||
}
|
||||
|
||||
finalRowsByID := map[int]RankingRow{}
|
||||
for _, row := range derived.FinalRanking.Rows {
|
||||
finalRowsByID[row.PlayerID] = row
|
||||
}
|
||||
finalTieRows := make([]RankingRow, 0, len(derived.FinalRanking.TieBreak.PlayerIDs))
|
||||
for _, id := range derived.FinalRanking.TieBreak.PlayerIDs {
|
||||
row, ok := finalRowsByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
finalTieRows = append(finalTieRows, row)
|
||||
}
|
||||
sort.SliceStable(finalTieRows, func(i, j int) bool {
|
||||
if finalTieRows[i].TieBreak != finalTieRows[j].TieBreak {
|
||||
return finalTieRows[i].TieBreak > finalTieRows[j].TieBreak
|
||||
}
|
||||
if finalTieRows[i].Score != finalTieRows[j].Score {
|
||||
return finalTieRows[i].Score > finalTieRows[j].Score
|
||||
}
|
||||
return finalTieRows[i].PlayerID < finalTieRows[j].PlayerID
|
||||
})
|
||||
for idx, row := range finalTieRows {
|
||||
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||
position := (idx % 6) + 1
|
||||
out[positionBoardFinalTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePositionBoard(board string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(board))
|
||||
if !allowedPositionBoards[normalized] {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizePreliminaryGroupKey(groupCode string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(groupCode))
|
||||
if normalized == "" {
|
||||
return positionBoardUnassigned
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePositionGroupKey(board, groupKey, fallback string) string {
|
||||
if board == positionBoardPreliminary {
|
||||
normalized := normalizePreliminaryGroupKey(groupKey)
|
||||
if normalized == "" {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
normalized := strings.TrimSpace(groupKey)
|
||||
if normalized == "" {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePositionValue(position int, fallback int) int {
|
||||
if position > 0 {
|
||||
return position
|
||||
}
|
||||
if fallback > 0 {
|
||||
return fallback
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (a *App) ensureScoreRows(players []Player) error {
|
||||
if len(players) == 0 {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user