This commit is contained in:
2026-10-04 13:23:26 +07:00
parent 88307ed402
commit c663fbb504
20 changed files with 2125 additions and 502 deletions
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"context"
"errors"
"evening_detective_server/internal/services/screen_plan_service"
proto "evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) GetScreenPlan(ctx context.Context, req *proto.GetScreenPlanReq) (*proto.GetScreenPlanRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
plan, err := s.screenPlanService.Get(ctx, int(req.GameID))
if errors.Is(err, screen_plan_service.ErrPlanNotFound) {
return &proto.GetScreenPlanRsp{}, nil
}
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить план экранов")
}
return &proto.GetScreenPlanRsp{Plan: mapScreenPlan(plan)}, nil
}
func (s *server) SaveScreenPlan(ctx context.Context, req *proto.SaveScreenPlanReq) (*proto.SaveScreenPlanRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
plan, err := s.screenPlanService.Save(ctx, int(req.GameID), mapScreenPlanSteps(req.Steps), int(req.CurrentStep))
if err != nil {
return nil, screenPlanStatus(err, "не удалось сохранить план экранов")
}
return &proto.SaveScreenPlanRsp{Plan: mapScreenPlan(plan)}, nil
}
func (s *server) SetScreenPlanStep(ctx context.Context, req *proto.SetScreenPlanStepReq) (*proto.SetScreenPlanStepRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 || req.CurrentStep < 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный шаг плана экранов")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
plan, err := s.screenPlanService.SetStep(ctx, int(req.GameID), int(req.CurrentStep))
if err != nil {
return nil, screenPlanStatus(err, "не удалось применить шаг плана экранов")
}
return &proto.SetScreenPlanStepRsp{Plan: mapScreenPlan(plan)}, nil
}
+22
View File
@@ -0,0 +1,22 @@
package app
import (
"errors"
"evening_detective_server/internal/services/screen_plan_service"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func screenPlanStatus(err error, fallback string) error {
switch {
case errors.Is(err, screen_plan_service.ErrPlanNotFound):
return status.Error(codes.NotFound, "план экранов не найден")
case errors.Is(err, screen_plan_service.ErrStepNotFound):
return status.Error(codes.InvalidArgument, "шаг плана экранов не найден")
case errors.Is(err, screen_plan_service.ErrInvalidPlan):
return status.Error(codes.InvalidArgument, "некорректный план экранов")
default:
return status.Error(codes.Internal, fallback)
}
}
+39
View File
@@ -0,0 +1,39 @@
package app
import (
"evening_detective_server/internal/services/screen_plan_service"
proto "evening_detective_server/proto"
)
func mapScreenPlanSteps(steps []*proto.ScreenPlanStep) []screen_plan_service.Step {
mapped := make([]screen_plan_service.Step, 0, len(steps))
for _, step := range steps {
if step == nil {
mapped = append(mapped, screen_plan_service.Step{})
continue
}
mapped = append(mapped, screen_plan_service.Step{
Mode: int32(step.Mode),
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
})
}
return mapped
}
func mapScreenPlan(plan *screen_plan_service.Plan) *proto.ScreenPlan {
if plan == nil {
return nil
}
steps := make([]*proto.ScreenPlanStep, 0, len(plan.Steps))
for _, step := range plan.Steps {
steps = append(steps, &proto.ScreenPlanStep{
Mode: proto.ScreenMode(step.Mode),
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
})
}
return &proto.ScreenPlan{Steps: steps, CurrentStep: int32(plan.CurrentStep)}
}
+4
View File
@@ -93,6 +93,9 @@ func (s *server) BindScreen(ctx context.Context, req *proto.BindScreenReq) (*pro
}
return nil, status.Error(codes.Internal, "не удалось привязать экран")
}
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.Internal, "не удалось применить план экранов")
}
session, err := s.screenService.Get(ctx, id, hash)
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить состояние экрана")
@@ -179,6 +182,7 @@ func screenState(session *screen_service.ScreenSession) *proto.ScreenState {
TimerDurationSeconds: session.TimerDurationSeconds,
TimerRemainingSeconds: timerRemaining,
TimerState: timerState,
Text: session.Text,
}
if time.Since(session.LastSeen) <= 5*time.Second {
state.Status = "connected"
+4
View File
@@ -9,6 +9,7 @@ import (
"evening_detective_server/internal/services/map_service"
"evening_detective_server/internal/services/participation_service"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/screen_plan_service"
"evening_detective_server/internal/services/screen_service"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/internal/services/ticket_service"
@@ -32,6 +33,7 @@ type server struct {
ticketService *ticket_service.TicketService
participationService *participation_service.ParticipationService
screenService *screen_service.ScreenService
screenPlanService *screen_plan_service.ScreenPlanService
mapService *map_service.MapService
}
@@ -49,6 +51,7 @@ func NewServer(
ticketService *ticket_service.TicketService,
participationService *participation_service.ParticipationService,
screenService *screen_service.ScreenService,
screenPlanService *screen_plan_service.ScreenPlanService,
mapService *map_service.MapService,
) proto.EveningDetectiveServerServer {
return &server{
@@ -64,6 +67,7 @@ func NewServer(
ticketService: ticketService,
participationService: participationService,
screenService: screenService,
screenPlanService: screenPlanService,
mapService: mapService,
}
}
@@ -0,0 +1,95 @@
// Package game_screen_plans_repo persists game screen plans.
package game_screen_plans_repo
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrPlanNotFound = errors.New("план экранов не найден")
// Step is a stored screen plan step.
type Step struct {
Mode int32 `json:"mode"`
Text string `json:"text,omitempty"`
TimerLabel string `json:"timerLabel,omitempty"`
TimerDurationSeconds int64 `json:"timerDurationSeconds,omitempty"`
}
// Plan is a stored game screen plan.
type Plan struct {
GameID int
Steps []Step
CurrentStep int
}
// GameScreenPlansRepo persists game screen plans.
type GameScreenPlansRepo struct {
pool *pgxpool.Pool
}
// NewGameScreenPlansRepo creates a game screen plans repository.
func NewGameScreenPlansRepo(pool *pgxpool.Pool) *GameScreenPlansRepo {
return &GameScreenPlansRepo{pool: pool}
}
func (r *GameScreenPlansRepo) Get(ctx context.Context, gameID int) (*Plan, error) {
plan := &Plan{GameID: gameID}
var raw []byte
err := r.pool.QueryRow(
ctx,
`
SELECT steps, current_step
FROM game_screen_plans
WHERE game_id = $1`,
gameID,
).Scan(&raw, &plan.CurrentStep)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPlanNotFound
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &plan.Steps); err != nil {
return nil, err
}
return plan, nil
}
func (r *GameScreenPlansRepo) Save(ctx context.Context, gameID int, steps []Step, currentStep int) (*Plan, error) {
raw, err := json.Marshal(steps)
if err != nil {
return nil, err
}
_, err = r.pool.Exec(
ctx,
`
INSERT INTO game_screen_plans (game_id, steps, current_step)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (game_id) DO UPDATE SET
steps = EXCLUDED.steps,
current_step = EXCLUDED.current_step,
updated_at = NOW()`,
gameID,
raw,
currentStep,
)
if err != nil {
return nil, err
}
return &Plan{GameID: gameID, Steps: steps, CurrentStep: currentStep}, nil
}
func (r *GameScreenPlansRepo) Delete(ctx context.Context, gameID int) error {
_, err := r.pool.Exec(
ctx,
`DELETE FROM game_screen_plans
WHERE game_id = $1`,
gameID,
)
return err
}
+36 -5
View File
@@ -29,6 +29,7 @@ type ScreenSession struct {
TimerRemainingSeconds int64
TimerState int32
TimerEndsAt *time.Time
Text string
}
// ScreenSessionsRepo persists screen sessions.
@@ -56,11 +57,11 @@ func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string)
session := &ScreenSession{ID: id}
err := r.pool.QueryRow(ctx, `
SELECT game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume,
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at, text
FROM screen_sessions
WHERE id = $1 AND access_token_hash = $2`, id, tokenHash).
Scan(&session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume,
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt)
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScreenNotFound
}
@@ -79,7 +80,7 @@ func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string)
func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*ScreenSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume,
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at
timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at, text
FROM screen_sessions
WHERE game_id = $1
ORDER BY id`, gameID)
@@ -92,7 +93,7 @@ func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*Scr
for rows.Next() {
session := &ScreenSession{}
if err := rows.Scan(&session.ID, &session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume,
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt); err != nil {
&session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt, &session.Text); err != nil {
return nil, err
}
sessions = append(sessions, session)
@@ -102,7 +103,35 @@ func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*Scr
// SetMode updates the display mode for a game.
func (r *ScreenSessionsRepo) SetMode(ctx context.Context, gameID int, mode int32) error {
_, err := r.pool.Exec(ctx, `UPDATE screen_sessions SET mode = $2, command = 0, state_version = state_version + 1, updated_at = NOW() WHERE game_id = $1`, gameID, mode)
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions
SET mode = $2,
text = '',
command = 0,
state_version = state_version + 1,
updated_at = NOW()
WHERE game_id = $1`,
gameID,
mode,
)
return err
}
// SetText updates the text screen for a game.
func (r *ScreenSessionsRepo) SetText(ctx context.Context, gameID int, text string) error {
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions
SET mode = 8,
text = $2,
command = 0,
state_version = state_version + 1,
updated_at = NOW()
WHERE game_id = $1`,
gameID,
text,
)
return err
}
@@ -130,6 +159,7 @@ func (r *ScreenSessionsRepo) SetTimer(ctx context.Context, gameID int, duration
timer_ends_at = NULL,
command = 0,
state_version = state_version + 1,
text = '',
updated_at = NOW()
WHERE game_id = $1`, gameID, label, duration)
return err
@@ -194,6 +224,7 @@ func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error {
timer_remaining_seconds = 600,
timer_state = 1,
timer_ends_at = NULL,
text = '',
state_version = state_version + 1,
updated_at = NOW()
WHERE id = $1`, id)
@@ -0,0 +1,29 @@
package screen_plan_service
import "evening_detective_server/internal/repos/game_screen_plans_repo"
func mapPlan(plan *game_screen_plans_repo.Plan) *Plan {
steps := make([]Step, 0, len(plan.Steps))
for _, step := range plan.Steps {
steps = append(steps, Step{
Mode: step.Mode,
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
})
}
return &Plan{GameID: plan.GameID, Steps: steps, CurrentStep: plan.CurrentStep}
}
func mapSteps(steps []Step) []game_screen_plans_repo.Step {
mapped := make([]game_screen_plans_repo.Step, 0, len(steps))
for _, step := range steps {
mapped = append(mapped, game_screen_plans_repo.Step{
Mode: step.Mode,
Text: step.Text,
TimerLabel: step.TimerLabel,
TimerDurationSeconds: step.TimerDurationSeconds,
})
}
return mapped
}
@@ -0,0 +1,157 @@
// Package screen_plan_service handles game screen plans.
package screen_plan_service
import (
"context"
"errors"
"strings"
"evening_detective_server/internal/repos/game_screen_plans_repo"
"evening_detective_server/internal/services/screen_service"
)
var (
// ErrPlanNotFound indicates that a game has no screen plan.
ErrPlanNotFound = errors.New("план экранов не найден")
// ErrInvalidPlan indicates invalid screen plan data.
ErrInvalidPlan = errors.New("некорректный план экранов")
// ErrStepNotFound indicates that a screen plan step does not exist.
ErrStepNotFound = errors.New("шаг плана экранов не найден")
)
// Step is a screen plan step used by the application layer.
type Step struct {
Mode int32
Text string
TimerLabel string
TimerDurationSeconds int64
}
// Plan is a game screen plan used by the application layer.
type Plan struct {
GameID int
Steps []Step
CurrentStep int
}
// ScreenPlanService handles game screen plans and applies their current step.
type ScreenPlanService struct {
repo *game_screen_plans_repo.GameScreenPlansRepo
screenService *screen_service.ScreenService
}
// NewScreenPlanService creates a screen plan service.
func NewScreenPlanService(
repo *game_screen_plans_repo.GameScreenPlansRepo,
screenService *screen_service.ScreenService,
) *ScreenPlanService {
return &ScreenPlanService{repo: repo, screenService: screenService}
}
// Get returns a game's screen plan.
func (s *ScreenPlanService) Get(ctx context.Context, gameID int) (*Plan, error) {
stored, err := s.repo.Get(ctx, gameID)
if errors.Is(err, game_screen_plans_repo.ErrPlanNotFound) {
return nil, ErrPlanNotFound
}
if err != nil {
return nil, err
}
return mapPlan(stored), nil
}
// Save validates, stores and applies a game's screen plan.
func (s *ScreenPlanService) Save(ctx context.Context, gameID int, steps []Step, currentStep int) (*Plan, error) {
steps, err := normalizePlan(steps, currentStep)
if err != nil {
return nil, err
}
if len(steps) == 0 {
if err := s.repo.Delete(ctx, gameID); err != nil {
return nil, err
}
return nil, nil
}
stored, err := s.repo.Save(ctx, gameID, mapSteps(steps), currentStep)
if err != nil {
return nil, err
}
plan := mapPlan(stored)
if err := s.applyStep(ctx, gameID, plan); err != nil {
return nil, err
}
return plan, nil
}
// SetStep stores and applies the selected step.
func (s *ScreenPlanService) SetStep(ctx context.Context, gameID, currentStep int) (*Plan, error) {
plan, err := s.Get(ctx, gameID)
if err != nil {
return nil, err
}
if currentStep < 0 || currentStep >= len(plan.Steps) {
return nil, ErrStepNotFound
}
stored, err := s.repo.Save(ctx, gameID, mapSteps(plan.Steps), currentStep)
if err != nil {
return nil, err
}
plan = mapPlan(stored)
if err := s.applyStep(ctx, gameID, plan); err != nil {
return nil, err
}
return plan, nil
}
// ApplyCurrent applies the current step when a screen joins a game.
func (s *ScreenPlanService) ApplyCurrent(ctx context.Context, gameID int) error {
plan, err := s.Get(ctx, gameID)
if errors.Is(err, ErrPlanNotFound) {
return nil
}
if err != nil {
return err
}
return s.applyStep(ctx, gameID, plan)
}
func normalizePlan(steps []Step, currentStep int) ([]Step, error) {
if currentStep < 0 || (len(steps) > 0 && currentStep >= len(steps)) || (len(steps) == 0 && currentStep != 0) {
return nil, ErrInvalidPlan
}
normalized := make([]Step, 0, len(steps))
for _, step := range steps {
if step.Mode <= 0 || step.Mode > 8 {
return nil, ErrInvalidPlan
}
step.Text = strings.TrimSpace(step.Text)
step.TimerLabel = strings.TrimSpace(step.TimerLabel)
if step.Mode == 8 && (step.Text == "" || len(step.Text) > 10000) {
return nil, ErrInvalidPlan
}
if step.Mode == 7 {
if step.TimerDurationSeconds <= 0 || step.TimerDurationSeconds > 24*60*60 {
return nil, ErrInvalidPlan
}
if step.TimerLabel == "" {
step.TimerLabel = "Перерыв"
}
if len(step.TimerLabel) > 200 {
return nil, ErrInvalidPlan
}
}
normalized = append(normalized, step)
}
return normalized, nil
}
func (s *ScreenPlanService) applyStep(ctx context.Context, gameID int, plan *Plan) error {
step := plan.Steps[plan.CurrentStep]
if step.Mode == 8 {
return s.screenService.SetText(ctx, gameID, step.Text)
}
if step.Mode == 7 {
return s.screenService.SetTimer(ctx, gameID, step.TimerDurationSeconds, step.TimerLabel)
}
return s.screenService.SetMode(ctx, gameID, step.Mode)
}
@@ -0,0 +1,46 @@
package screen_plan_service
import "testing"
func TestNormalizePlan(t *testing.T) {
tests := []struct {
name string
steps []Step
currentStep int
wantErr bool
}{
{
name: "normalizes text and timer defaults",
steps: []Step{
{Mode: 8, Text: " Внимание "},
{Mode: 7, TimerDurationSeconds: 60},
},
},
{
name: "rejects empty text",
steps: []Step{{Mode: 8}},
wantErr: true,
},
{
name: "rejects missing current step",
steps: []Step{{Mode: 1}},
currentStep: 1,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
steps, err := normalizePlan(tt.steps, tt.currentStep)
if (err != nil) != tt.wantErr {
t.Fatalf("normalizePlan() error = %v, want error: %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if steps[0].Text != "Внимание" || steps[1].TimerLabel != "Перерыв" {
t.Fatalf("plan defaults were not normalized: %+v", steps)
}
})
}
}
@@ -23,6 +23,7 @@ type ScreenSession struct {
TimerRemainingSeconds int64
TimerState int32
TimerEndsAt *time.Time
Text string
}
func mapSession(session *screen_sessions_repo.ScreenSession) *ScreenSession {
@@ -41,5 +42,6 @@ func mapSession(session *screen_sessions_repo.ScreenSession) *ScreenSession {
TimerRemainingSeconds: session.TimerRemainingSeconds,
TimerState: session.TimerState,
TimerEndsAt: session.TimerEndsAt,
Text: session.Text,
}
}
@@ -57,6 +57,11 @@ func (s *ScreenService) SetTimer(ctx context.Context, gameID int, duration int64
return s.repo.SetTimer(ctx, gameID, duration, label)
}
// SetText updates the text screen for a game.
func (s *ScreenService) SetText(ctx context.Context, gameID int, text string) error {
return s.repo.SetText(ctx, gameID, text)
}
// SetTimerCommand updates the timer command for a game.
func (s *ScreenService) SetTimerCommand(ctx context.Context, gameID int, command int32) error {
return s.repo.SetTimerCommand(ctx, gameID, command)