Фикс ошибок

This commit is contained in:
Александр Кирюхин 2024-05-05 19:42:33 +03:00
parent 7e706a3981
commit a1dc96088c
No known key found for this signature in database
GPG key ID: 35E33E1AB7776B39
34 changed files with 1584 additions and 1180 deletions

View file

@ -1,4 +1,5 @@
package api
//go:generate merger -i parts/common.yaml -i parts/game.yaml -i parts/admin.yaml -i parts/user.yaml -i parts/schemas.yaml -i parts/responses.yaml -o openapi.yaml
//go:generate oapi-codegen -generate server,spec -package api -o ./server.go ./openapi.yaml
//go:generate oapi-codegen -generate types -package api -o ./types.go ./openapi.yaml

View file

@ -1,237 +1,245 @@
openapi: "3.1.0"
info:
version: 1.0.0
title: nQuest
servers:
- url: /api
paths:
# User routes
/user:
get:
responses:
200:
$ref: "#/components/responses/userResponse"
403:
$ref: "#/components/responses/errorResponse"
/user/login:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
required: [email, password]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/register:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
password2:
type: string
required: [username, email, password, password2]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/logout:
post:
responses:
204:
description: "success logout"
400:
$ref: "#/components/responses/errorResponse"
# Game routes
/games:
get:
responses:
200:
$ref: "#/components/responses/gameListResponse"
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/taskResponse"
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
required:
- code
responses:
200:
$ref: "#/components/responses/taskResponse"
/file/{uid}:
get:
operationId: getFile
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
description: file
content:
"application/octet-stream":
schema:
type: string
format: binary
# Admin routes
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
200:
$ref: "#/components/responses/uploadResponse"
/admin/file/{quest}:
get:
operationId: adminListFiles
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/filesListResponse"
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: "#/components/responses/gameListResponse"
post:
operationId: adminEditGame
security:
- cookieAuth: [creator, admin]
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
security:
- cookieAuth: [creator, admin]
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
components:
responses:
errorResponse:
content:
application/json:
schema:
properties:
code:
type: integer
message:
type: string
required:
- code
- message
type: object
description: ""
filesListResponse:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/fileItem'
type: array
description: ""
gameAdminResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/gameEdit'
description: ""
gameListResponse:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/gameView'
type: array
description: ""
gameResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/gameView'
description: ""
taskResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/taskView'
description: ""
uploadResponse:
content:
application/json:
schema:
properties:
uuid:
format: uuid
type: string
required:
- uuid
type: object
description: ""
userResponse:
content:
application/json:
schema:
$ref: '#/components/schemas/userView'
description: ""
schemas:
userView:
codeEdit:
properties:
code:
type: string
description:
type: string
id:
format: uuid
type: string
required:
- code
type: object
codeView:
properties:
code:
type: string
description:
type: string
type: object
fileItem:
properties:
id:
type: string
format: uuid
username:
type: string
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size
type: object
gameEdit:
properties:
description:
type: string
icon:
format: uuid
type: string
id:
format: uuid
type: string
points:
type: integer
tasks:
items:
$ref: '#/components/schemas/taskEdit'
type: array
title:
type: string
type:
$ref: '#/components/schemas/gameType'
visible:
type: boolean
required:
- visible
- title
- description
- type
- tasks
- points
type: object
gameType:
enum:
- virtual
- city
type: string
gameView:
properties:
authors:
items:
$ref: '#/components/schemas/userView'
type: array
createdAt:
type: string
description:
type: string
icon:
format: uuid
type: string
id:
format: uuid
type: string
points:
type: integer
taskCount:
type: integer
title:
type: string
type:
$ref: '#/components/schemas/gameType'
visible:
type: boolean
required:
- id
- title
- description
- type
- points
- taskCount
- createdAt
- authors
type: object
taskEdit:
properties:
codes:
items:
$ref: '#/components/schemas/codeEdit'
type: array
id:
format: uuid
type: string
text:
type: string
title:
type: string
required:
- id
- title
- text
- codes
type: object
taskView:
properties:
codes:
items:
$ref: '#/components/schemas/codeView'
type: array
message:
enum:
- ok_code
- invalid_code
- old_code
- next_level
- game_complete
type: string
text:
type: string
title:
type: string
required:
- title
- text
- codes
type: object
userView:
properties:
email:
type: string
experience:
type: integer
level:
type: integer
expToCurrentLevel:
type: integer
expToNextLevel:
type: integer
experience:
type: integer
games:
type: array
items:
$ref: "#/components/schemas/gameView"
role:
$ref: '#/components/schemas/gameView'
type: array
id:
format: uuid
type: string
level:
type: integer
role:
enum:
- user
- creator
- admin
type: string
username:
type: string
required:
- id
- username
@ -242,221 +250,201 @@ components:
- expToNextLevel
- games
- role
gameView:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
points:
type: integer
taskCount:
type: integer
createdAt:
type: string
authors:
type: array
items:
$ref: "#/components/schemas/userView"
icon:
type: string
format: uuid
required:
- id
- title
- description
- type
- points
- taskCount
- createdAt
- authors
taskView:
type: object
properties:
message:
type: string
enum:
- ok_code
- invalid_code
- old_code
- next_level
- game_complete
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeView"
required:
- title
- text
- codes
codeView:
type: object
properties:
description:
type: string
code:
type: string
gameEdit:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
tasks:
type: array
items:
$ref: "#/components/schemas/taskEdit"
points:
type: integer
icon:
type: string
format: uuid
required:
- visible
- title
- description
- type
- tasks
- points
taskEdit:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeEdit"
required:
- title
- text
- codes
codeEdit:
type: object
properties:
id:
type: string
format: uuid
description:
type: string
code:
type: string
required:
- code
gameType:
type: string
enum:
- virtual
- city
fileItem:
type: object
properties:
id:
type: string
format: uuid
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size
responses:
userResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/userView"
errorResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
code:
type: integer
message:
type: string
required: [code, message]
gameListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/gameView"
gameResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameView"
gameAdminResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
taskResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/taskView"
uploadResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
filesListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/fileItem"
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
type: apiKey
info:
title: nQuest
version: 1.0.0
openapi: 3.1.0
paths:
/admin/file/{quest}:
get:
operationId: adminListFiles
parameters:
- in: path
name: quest
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/filesListResponse'
security:
- cookieAuth:
- creator
- admin
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
parameters:
- in: path
name: quest
required: true
schema:
format: uuid
type: string
requestBody:
content:
multipart/form-data:
properties:
file:
format: binary
type: string
schema: null
type: object
responses:
200:
$ref: '#/components/responses/uploadResponse'
security:
- cookieAuth:
- creator
- admin
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: '#/components/responses/gameListResponse'
post:
operationId: adminEditGame
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/gameEdit'
responses:
200:
$ref: '#/components/responses/gameAdminResponse'
security:
- cookieAuth:
- creator
- admin
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/gameAdminResponse'
security:
- cookieAuth:
- creator
- admin
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
responses:
200:
$ref: '#/components/responses/taskResponse'
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- in: path
name: uid
required: true
schema:
format: uuid
type: string
requestBody:
content:
application/json:
schema:
properties:
code:
type: string
required:
- code
type: object
responses:
200:
$ref: '#/components/responses/taskResponse'
/games:
get:
responses:
200:
$ref: '#/components/responses/gameListResponse'
/user:
get:
responses:
200:
$ref: '#/components/responses/userResponse'
403:
$ref: '#/components/responses/errorResponse'
/user/login:
post:
requestBody:
content:
application/json:
schema:
properties:
email:
type: string
password:
type: string
required:
- email
- password
type: object
responses:
200:
$ref: '#/components/responses/userResponse'
400:
$ref: '#/components/responses/errorResponse'
security: []
/user/logout:
post:
responses:
204:
description: success logout
400:
$ref: '#/components/responses/errorResponse'
/user/register:
post:
requestBody:
content:
application/json:
schema:
properties:
email:
type: string
password:
type: string
password2:
type: string
username:
type: string
required:
- username
- email
- password
- password2
type: object
responses:
200:
$ref: '#/components/responses/userResponse'
400:
$ref: '#/components/responses/errorResponse'
security: []
security:
- cookieAuth: []
servers:
- url: /api

74
api/parts/admin.yaml Normal file
View file

@ -0,0 +1,74 @@
paths:
/admin/file/{quest}/upload:
post:
operationId: adminUploadFile
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
200:
$ref: "#/components/responses/uploadResponse"
/admin/file/{quest}:
get:
operationId: adminListFiles
security:
- cookieAuth: [creator, admin]
parameters:
- name: quest
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/filesListResponse"
/admin/games:
get:
operationId: adminListGames
responses:
200:
$ref: "#/components/responses/gameListResponse"
post:
operationId: adminEditGame
security:
- cookieAuth: [creator, admin]
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
responses:
200:
$ref: "#/components/responses/gameAdminResponse"
/admin/games/{uid}:
get:
operationId: adminGetGame
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
security:
- cookieAuth: [creator, admin]
responses:
200:
$ref: "#/components/responses/gameAdminResponse"

19
api/parts/common.yaml Normal file
View file

@ -0,0 +1,19 @@
openapi: "3.1.0"
info:
version: 1.0.0
title: nQuest
servers:
- url: /api
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
security:
- cookieAuth: []

43
api/parts/game.yaml Normal file
View file

@ -0,0 +1,43 @@
paths:
/games:
get:
responses:
200:
$ref: "#/components/responses/gameListResponse"
/engine/{uid}:
get:
operationId: gameEngine
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
responses:
200:
$ref: "#/components/responses/taskResponse"
/engine/{uid}/code:
post:
operationId: enterCode
parameters:
- name: uid
in: path
required: true
schema:
type: string
format: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
code:
type: string
required:
- code
responses:
200:
$ref: "#/components/responses/taskResponse"

66
api/parts/responses.yaml Normal file
View file

@ -0,0 +1,66 @@
components:
responses:
userResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/userView"
errorResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
code:
type: integer
message:
type: string
required: [code, message]
gameListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/gameView"
gameResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameView"
gameAdminResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/gameEdit"
taskResponse:
description: ""
content:
application/json:
schema:
$ref: "#/components/schemas/taskView"
uploadResponse:
description: ""
content:
application/json:
schema:
type: object
properties:
uuid:
type: string
format: uuid
required:
- uuid
filesListResponse:
description: ""
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/fileItem"

186
api/parts/schemas.yaml Normal file
View file

@ -0,0 +1,186 @@
components:
schemas:
userView:
type: object
properties:
id:
type: string
format: uuid
username:
type: string
email:
type: string
experience:
type: integer
level:
type: integer
expToCurrentLevel:
type: integer
expToNextLevel:
type: integer
games:
type: array
items:
$ref: "#/components/schemas/gameView"
role:
type: string
enum:
- user
- creator
- admin
required:
- id
- username
- email
- experience
- level
- expToCurrentLevel
- expToNextLevel
- games
- role
gameView:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
points:
type: integer
taskCount:
type: integer
createdAt:
type: string
authors:
type: array
items:
$ref: "#/components/schemas/userView"
icon:
type: string
format: uuid
required:
- id
- title
- description
- type
- points
- taskCount
- createdAt
- authors
taskView:
type: object
properties:
message:
type: string
enum:
- ok_code
- invalid_code
- old_code
- next_level
- game_complete
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeView"
required:
- title
- text
- codes
codeView:
type: object
properties:
description:
type: string
code:
type: string
gameEdit:
type: object
properties:
id:
type: string
format: uuid
visible:
type: boolean
title:
type: string
description:
type: string
type:
$ref: "#/components/schemas/gameType"
tasks:
type: array
items:
$ref: "#/components/schemas/taskEdit"
points:
type: integer
icon:
type: string
format: uuid
required:
- visible
- title
- description
- type
- tasks
- points
taskEdit:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
text:
type: string
codes:
type: array
items:
$ref: "#/components/schemas/codeEdit"
required:
- id
- title
- text
- codes
codeEdit:
type: object
properties:
id:
type: string
format: uuid
description:
type: string
code:
type: string
required:
- code
gameType:
type: string
enum:
- virtual
- city
fileItem:
type: object
properties:
id:
type: string
format: uuid
originalName:
type: string
size:
type: integer
required:
- id
- originalName
- size

57
api/parts/user.yaml Normal file
View file

@ -0,0 +1,57 @@
paths:
/user:
get:
responses:
200:
$ref: "#/components/responses/userResponse"
403:
$ref: "#/components/responses/errorResponse"
/user/login:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
email:
type: string
password:
type: string
required: [email, password]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/register:
post:
security: []
requestBody:
content:
application/json:
schema:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
password2:
type: string
required: [username, email, password, password2]
responses:
200:
$ref: "#/components/responses/userResponse"
400:
$ref: "#/components/responses/errorResponse"
/user/logout:
post:
responses:
204:
description: "success logout"
400:
$ref: "#/components/responses/errorResponse"

View file

@ -43,9 +43,6 @@ type ServerInterface interface {
// (POST /engine/{uid}/code)
EnterCode(ctx echo.Context, uid openapi_types.UUID) error
// (GET /file/{uid})
GetFile(ctx echo.Context, uid openapi_types.UUID) error
// (GET /games)
GetGames(ctx echo.Context) error
@ -179,24 +176,6 @@ func (w *ServerInterfaceWrapper) EnterCode(ctx echo.Context) error {
return err
}
// GetFile converts echo context to params.
func (w *ServerInterfaceWrapper) GetFile(ctx echo.Context) error {
var err error
// ------------- Path parameter "uid" -------------
var uid openapi_types.UUID
err = runtime.BindStyledParameterWithOptions("simple", "uid", ctx.Param("uid"), &uid, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter uid: %s", err))
}
ctx.Set(CookieAuthScopes, []string{})
// Invoke the callback with all the unmarshaled arguments
err = w.Handler.GetFile(ctx, uid)
return err
}
// GetGames converts echo context to params.
func (w *ServerInterfaceWrapper) GetGames(ctx echo.Context) error {
var err error
@ -283,7 +262,6 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/admin/games/:uid", wrapper.AdminGetGame)
router.GET(baseURL+"/engine/:uid", wrapper.GameEngine)
router.POST(baseURL+"/engine/:uid/code", wrapper.EnterCode)
router.GET(baseURL+"/file/:uid", wrapper.GetFile)
router.GET(baseURL+"/games", wrapper.GetGames)
router.GET(baseURL+"/user", wrapper.GetUser)
router.POST(baseURL+"/user/login", wrapper.PostUserLogin)
@ -295,27 +273,26 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"H4sIAAAAAAAC/8xZS2/cNhD+KwXbo2JtHifdUsM1ghpBmzq9GAuDlsZrxhIpkyPHW0P/vRhS0kor6uGN",
"ssnJWnI4j28enKGfWayyXEmQaFj0zDSYXEkD9gdorfSnaoUWYiURJNInz/NUxByFkuEXoyStmfgOMk5f",
"uVY5aBSOT6wSexy3ObCICYmwAc3KgGVgDN+0Nw1qITesLAOm4aEQGhIWXTkWO/p1UNOrmy8QIyvpQAIm",
"1iInnVjEiP+tSMFcCIMHWSEQMmvAbxpuWcR+DXdghY7MhCTiA0JG4iqduNZ8O6TShmfwPsmEPEilMU2I",
"81kicEzy98WCJPwr4OtcLJCb+8VhIKZOCb/QIk8VTxYI66IQCf29VTrjyCK3EExEsiWaG7+FAb04QMR0",
"GKAyqNg0qWtjaiqpa2v3GHr2DwHNCuuDFtgNa8tS+pUeIU2K94TMsiVgSouNkDz9yDO/Tkb8562QezBY",
"7h1m1VEfNE056Gk96aHYbUzaNdP8XInqeunfAJStZnaBIWpX4vYLTMBQYOpH1y1Ml65LoisD9iiMuOnw",
"ulEqBS57Dqkpa+nd6Kok10Y2QAx567LSE2SROe4aC56ygMUCt61jO9OaittzMi/wTun50O6qQh/aWANH",
"SN7jYQl/5HA6VYXEge0fECPOiLHwqAxqa9/GPGh86QucJiW8FXC+/5tK7/H/TMcgPPkjZAj2PaRqkCyf",
"oNJ/yObhqv8ym4divtWa1vmo7q+rPlTIR56KpP6p0uZTwhNep/AIlLYUMNckNwUEb/4eDbImvXuQQcZF",
"6tUBnvJLdVpoDRIvrEnepLJkH+Fpgga0ABkPDAKElFmgz5wdq+mwrlqlHbcTdHVCKvri1L573Umk0n/J",
"+6pCQx5UXugAVSvpc0QP9RrCSn1PgxkwA3GhBW7/IRjrfFH3At4XeGfBpxbQLVEoW0OYAWNa5SpiPBd/",
"QtXTC3mrrLEuXJn8uwBDkfgI2riW8vXJ6mRlu6AcJM8Fi9jbk9cnKyp8HO+sGqGF1M5S4fMDsShpeQM2",
"OShabaP7IWERs8MTzTF/0HBnuWieAQJddleVEcR5Z8JDpdTOAagLCFr98lQ7ug66o/Gb1WooSBu6sD99",
"tr1glW3jf9UPsXJNJzzohG6KsdmszBBKny0R4XRMmCyb31Wy3ZtYsiJFkXONIbF5lXD0DFVkZEfSjZBc",
"b70TgmeEermT9sbBb/JQU8XGA/e8ztQXK9sb4a3RIxFAN/q5qzDDjlnmyaE81KDua8i3OyB8LkQyUT/O",
"oYZlOi1cqB+5diwGDMiNkDCBCWFxZgl/XkQ6j0Uu3jrGhfXE70+HM4mgT12TdiQLD8u3Wc8Y895HDkxK",
"H9Lu8hkPIsDZV813iqABkFWMgK8MauBZF+zpa6b3RGZvqAqV8YJfFZnlSj2JtN3oiMTPrls94CJsvzWW",
"AXu3ejt9qPsvgpaKYao2Qg6n41/KWFUvLNlS2TI8y+TcmK9KJ9O5VHfizYnF8qqP8OrlCHcugXUHb1Xg",
"LMCJrqf/u97LIDNFHIMxv1SsD9V4p6OGjTDo4ndcy0815Q+NjN3mG+/u/HHPM+k1cttSfu5YG+1A1lSU",
"DejHuuwXOmURC2nkK9fl/wEAAP//MtUIV2ocAAA=",
"H4sIAAAAAAAC/8xYS3OkNhD+KyklR2JmHyduG5fj2oprK9l4c3FNuWRoj7UGCUvNrCcu/nuqJWBgEA+P",
"iXdPHkPTj6+/bnXricUqy5UEiYZFT0yDyZU0YP8BrZX+XD2hB7GSCBLpJ8/zVMQchZLhV6MkPTPxHWSc",
"fuVa5aBROD2xSuznuMuBRUxIhA1oVgYsA2P4pv3SoBZyw8oyYBoeCqEhYdGVU7GXXwe1vLr5CjGykj5I",
"wMRa5OQTixjpvxUpmAth8KgoBEJmA/hFwy2L2M/hHqzQiZmQTHxEyMhc5RPXmu+GXNrwDD4kmZBHuTTm",
"CWk+SwSOWf5/sSAL/wj4NhcL5OZ+cRhIqXPCb7TIU8WTBWhdFCKhv7dKZxxZ5B4EE0y2QnP5WxjQiwNE",
"SocBKoNKTVO6llNTRV1He6DQ8/4Y0KyxPmiBfWFjWcq/0mOkKfGekVmxBExpsRGSp5945vfJiH+9HfIA",
"Bqu9o6z61AdN0w56Xk9mKHYvJuOaGX6uRHW89E8AqlYzu8GQtGtxhw0mYCgw9aPrHky3rkuSKwO2FUbc",
"dHTdKJUCl72E1JK19S67Kst1kA0QQ9m6rPwEWWROu8aCpyxgscBd67N9aE3H7SWZF3in9Hxo912hD22s",
"gSMkH/C4gn9lOp2qQuLA6+/AERfEGD2qgNretzEPmlz6iNOUhLcDzs9/0+k9+Z+ZGIRHP0OGYB9DyioL",
"qiCGAh9u/c8LfIj4rfm0Lkp1f10No0JueSqS+l+VNj8lPOJ1Clug2iXWXJPdFBC8RfxS3GZD1tR4DzLI",
"uEi9PsBjfqlOC61B4oUNyVtZVuwTPE7IgBYg44FtgJAyCwybswmbDvuqVdpJO0FXV6WiX5xmeG86SVT6",
"T3of4RvxoMpCB6jaSV8ieqjXEFbue6bMgBmICy1w9zfBWNeLuhfwocA7Cz7Nge4RUdkGwgwY0+pZEeO5",
"+AOqwV7IW2WDdXRl8q8CDDFxC9q4ufLNyepkZUehHCTPBYvYu5M3JyvqfhzvrBuhhdQuVOHTA6ko6fEG",
"bHEQW+20+zFhEbMbFC0zv9OGZ7VongECnXhXVRCkeR/CQ+XUPgGoCwhaQ/PUTLoOuvvx29VqiKSNXNhf",
"QdtZsM628b/qU6xc0xcedEK3ythqVmYIpS9WiHB6TZismt9UsjtYW7IiRZFzjSGp+TXh6NmsKMiOpRsh",
"ud551wTPHvX8JB3shC/KUNPFxol7Xlfqs53t7fE26BEG0LF+7jrMcGKWuXcojw2oeyXy8gSET4VIJvrH",
"OdSwTJeFo/or947FgAG5ERImMCEszqzgj4tI58bI8a0TXFiv/f5yOJMI+tQNaa8U4XH1NusuY94lyZFF",
"6UN6vLVV5bRcUyOTdu4asfjFzWVHtPz21VoZsPerd9MfdW/EWy6GqdoIOUy8P5Wxrl5YsaV4MTy159yY",
"b0on06ypZ87mi8UY1Ed49XyEO+1u3cFbFTgLcJLr+f++dxHGTBHHYMxPlepjPd77qGEjDDr+jnv5uZb8",
"rszYv3zrfTt/sfHsNI3dtpUfm2ujZ+2a2rsBva0PkEKnLGIhLTfluvwvAAD//8w97C1ZGwAA",
}
// GetSwagger returns the content of the embedded swagger specification file

View file

@ -84,10 +84,10 @@ type GameView struct {
// TaskEdit defines model for taskEdit.
type TaskEdit struct {
Codes []CodeEdit `json:"codes"`
Id *openapi_types.UUID `json:"id,omitempty"`
Text string `json:"text"`
Title string `json:"title"`
Codes []CodeEdit `json:"codes"`
Id openapi_types.UUID `json:"id"`
Text string `json:"text"`
Title string `json:"title"`
}
// TaskView defines model for taskView.

View file

@ -1,33 +1,34 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: [
'standard',
'plugin:react/recommended',
'plugin:react/jsx-runtime'
],
overrides: [
{
env: {
node: true
},
files: [
'.eslintrc.{js,cjs}'
],
parserOptions: {
sourceType: 'script'
}
env: {
browser: true,
es2021: true
},
extends: [
'standard',
'plugin:react/recommended',
'plugin:react/jsx-runtime'
],
overrides: [
{
env: {
node: true
},
files: [
'.eslintrc.{js,cjs}'
],
parserOptions: {
sourceType: 'script'
}
}
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: [
'react'
],
rules: {
indent: ['error', 4]
}
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: [
'react'
],
rules: {
}
}

View file

@ -18,81 +18,81 @@ import EditQuest from './pages/admin/Quest'
import AdminQuest from './pages/admin/Quests'
const router = createBrowserRouter(
createRoutesFromElements(
<Route
path="/"
id="root"
element={<Layout />}
loader={async () => ajax('/api/user').catch(x => { console.log(x); return null })}
>
<Route
index
element={<Index />}
/>
<Route
id="quests"
path="/quests"
element={<Auth><Quests /></Auth>}
loader={() => ajax('/api/games').catch(x => { console.log(x); return null })}
/>
<Route path="me" element={<User />} />
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route
path="go/:gameId"
element={<Auth><Engine /></Auth>}
loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => { console.log(x); return null })}
/>
<Route
path="/admin/quests/new"
element={<Auth role="creator"><EditQuest /></Auth>}
loader={() => ({ quest: null, files: [] })}
/>
<Route
path="/admin/quests/:gameId"
element={<Auth role="creator"><EditQuest /></Auth>}
loader={async ({ params }) => {
const quest = await ajax(`/api/admin/games/${params.gameId}`)
const files = await ajax(`/api/admin/file/${params.gameId}`)
createRoutesFromElements(
<Route
path="/"
id="root"
element={<Layout />}
loader={async () => ajax('/api/user').catch(x => { console.log(x); return null })}
>
<Route
index
element={<Index />}
/>
<Route
id="quests"
path="/quests"
element={<Auth><Quests /></Auth>}
loader={() => ajax('/api/games').catch(x => { console.log(x); return null })}
/>
<Route path="me" element={<User />} />
<Route path="login" element={<Login />} />
<Route path="register" element={<Register />} />
<Route
path="go/:gameId"
element={<Auth><Engine /></Auth>}
loader={({ params }) => ajax(`/api/engine/${params.gameId}`).catch(x => { console.log(x); return null })}
/>
<Route
path="/admin/quests/new"
element={<Auth role="creator"><EditQuest /></Auth>}
loader={() => ({ quest: null, files: [] })}
/>
<Route
path="/admin/quests/:gameId"
element={<Auth role="creator"><EditQuest /></Auth>}
loader={async ({ params }) => {
const quest = await ajax(`/api/admin/games/${params.gameId}`)
const files = await ajax(`/api/admin/file/${params.gameId}`)
return { quest, files }
}
}
/>
<Route
path="/admin/quests"
element={<Auth role="creator"><AdminQuest /></Auth>}
loader={() => ajax('/api/admin/games')}
/>
return { quest, files }
}
}
/>
<Route
path="/admin/quests"
element={<Auth role="creator"><AdminQuest /></Auth>}
loader={() => ajax('/api/admin/games')}
/>
<Route path="*" element={<NoMatch />} />
</Route>
)
<Route path="*" element={<NoMatch />} />
</Route>
)
)
function App () {
return (
<RouterProvider router={router} />
)
return (
<RouterProvider router={router} />
)
}
function Auth (props) {
const baseUser = useRouteLoaderData('root')
const { user } = UserProvider.useContainer()
const { hasRole } = useRole()
const location = useLocation()
if (!user && !baseUser) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (props.role && !hasRole(props.role)) {
return null
}
const baseUser = useRouteLoaderData('root')
const { user } = UserProvider.useContainer()
const { hasRole } = useRole()
const location = useLocation()
if (!user && !baseUser) {
return <Navigate to="/login" state={{ from: location }} replace />
}
if (props.role && !hasRole(props.role)) {
return null
}
return props.children
return props.children
}
Auth.propTypes = {
children: PropTypes.any,
role: PropTypes.string
children: PropTypes.any,
role: PropTypes.string
}
export default App

View file

@ -8,82 +8,82 @@ import { Content, Header } from 'antd/es/layout/layout'
import { useRole } from '../utils/roles'
const AppLayout = () => {
const params = useLoaderData()
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
setUser(params)
}, [params])
const { user, setUser } = UserProvider.useContainer()
const { hasRole } = useRole()
const params = useLoaderData()
const navigate = useNavigate()
const location = useLocation()
useEffect(() => {
setUser(params)
}, [params])
const { user, setUser } = UserProvider.useContainer()
const { hasRole } = useRole()
const logout = () => {
ajax('/api/user/logout', {
method: 'POST'
})
.then(() => { setUser(null); navigate('/login') })
.catch(() => { setUser(null); navigate('/login') })
}
let items = [
{ key: 'login', label: 'Вход', link: '/login' },
{ key: 'register', label: 'Регистрация', link: '/register' }
]
if (user != null) {
items = [
{
key: 'quests',
label: 'Квесты',
link: '/quests'
},
{
key: 'me',
label: `${user.username} [${user.level}]`,
link: '/me'
},
{
key: 'logout',
label: 'Выход',
onClick: logout
}
const logout = () => {
ajax('/api/user/logout', {
method: 'POST'
})
.then(() => { setUser(null); navigate('/login') })
.catch(() => { setUser(null); navigate('/login') })
}
let items = [
{ key: 'login', label: 'Вход', link: '/login' },
{ key: 'register', label: 'Регистрация', link: '/register' }
]
if (user != null) {
items = [
{
key: 'quests',
label: 'Квесты',
link: '/quests'
},
{
key: 'me',
label: `${user.username} [${user.level}]`,
link: '/me'
},
{
key: 'logout',
label: 'Выход',
onClick: logout
}
]
if (hasRole('creator')) {
items.push({
key: 'admin/quests',
label: 'Управление квестами',
link: '/admin/quests'
})
if (hasRole('creator')) {
items.push({
key: 'admin/quests',
label: 'Управление квестами',
link: '/admin/quests'
})
}
if (hasRole('admin')) {
items.push({
key: 'admin',
label: 'Админка',
link: '/admin'
})
}
}
const menuHandler = (x) => {
const item = items.find(y => y.key === x.key)
if (item.link) { navigate(item.link) }
}
if (hasRole('admin')) {
items.push({
key: 'admin',
label: 'Админка',
link: '/admin'
})
}
}
const menuHandler = (x) => {
const item = items.find(y => y.key === x.key)
if (item.link) { navigate(item.link) }
}
return (<Layout>
<Header style={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}>
<Link to="/" className='navbar-brand'><img src="/assets/logo.png" /></Link>
<Menu
mode="horizontal"
items={items}
selectedKeys={location.pathname.replace('/', '')}
onClick={menuHandler}
overflowedIndicator={<MenuOutlined />}
style={{ width: '100%' }}
/>
</Header>
<Content style={{ padding: '0 24px' }}>
<Outlet />
</Content>
</Layout>)
return (<Layout>
<Header style={{ display: 'flex', alignItems: 'center', width: '100%', justifyContent: 'space-between' }}>
<Link to="/" className='navbar-brand'><img src="/assets/logo.png" /></Link>
<Menu
mode="horizontal"
items={items}
selectedKeys={location.pathname.replace('/', '')}
onClick={menuHandler}
overflowedIndicator={<MenuOutlined />}
style={{ width: '100%' }}
/>
</Header>
<Content style={{ padding: '0 24px' }}>
<Outlet />
</Content>
</Layout>)
}
export default AppLayout

View file

@ -12,27 +12,27 @@ const { darkAlgorithm } = theme
console.log(import.meta.env.VITE_VERSION)
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Compose providers={store}>
<ConfigProvider
locale={ruRU}
theme={{
token: {
colorTextBase: '#fff',
colorPrimary: '#59FBEA',
colorButtonText: '#000',
colorInfo: '#59FBEA',
colorSuccess: '#15803d',
colorBgBase: '#171e26',
borderRadius: 2,
wireframe: false
},
algorithm: darkAlgorithm
}}>
<AntdApp>
<App />
</AntdApp>
</ConfigProvider>
</Compose>
</React.StrictMode>
<React.StrictMode>
<Compose providers={store}>
<ConfigProvider
locale={ruRU}
theme={{
token: {
colorTextBase: '#fff',
colorPrimary: '#59FBEA',
colorButtonText: '#000',
colorInfo: '#59FBEA',
colorSuccess: '#15803d',
colorBgBase: '#171e26',
borderRadius: 2,
wireframe: false
},
algorithm: darkAlgorithm
}}>
<AntdApp>
<App />
</AntdApp>
</ConfigProvider>
</Compose>
</React.StrictMode>
)

View file

@ -6,65 +6,65 @@ import Title from 'antd/es/typography/Title'
import { Alert, App, Button, Card, Col, Form, Input, List, Row } from 'antd'
const Engine = () => {
const params = useParams()
const loadedTask = useLoaderData()
const [task, setTask] = useState(loadedTask)
const { message } = App.useApp()
const params = useParams()
const loadedTask = useLoaderData()
const [task, setTask] = useState(loadedTask)
const { message } = App.useApp()
useEffect(() => {
if (!task) {
return
}
switch (task.message) {
case 'invalid_code':
message.error('Неверный код')
break
case 'old_code':
message.error('Этот код уже вводился')
break
case 'next_level':
message.success('Переход на новый уровень')
break
case 'ok_code':
message.success('Код принят, ищите оставшиеся')
break
}
}, [task])
const [form] = Form.useForm()
const onFinish = ({ code }) => {
ajax(`/api/engine/${params.gameId}/code`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
})
.then((x) => {
if (x != null) {
setTask(x)
form.setFieldsValue({ code: '' })
useEffect(() => {
if (!task) {
return
}
}).catch(e => {
console.warn(e)
})
}
switch (task.message) {
case 'invalid_code':
message.error('Неверный код')
break
case 'old_code':
message.error('Этот код уже вводился')
break
case 'next_level':
message.success('Переход на новый уровень')
break
case 'ok_code':
message.success('Код принят, ищите оставшиеся')
break
}
}, [task])
if (task && task.message === 'game_complete') {
return (<div style={{ padding: 8 }}>
const [form] = Form.useForm()
const onFinish = ({ code }) => {
ajax(`/api/engine/${params.gameId}/code`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
})
.then((x) => {
if (x != null) {
setTask(x)
form.setFieldsValue({ code: '' })
}
}).catch(e => {
console.warn(e)
})
}
if (task && task.message === 'game_complete') {
return (<div style={{ padding: 8 }}>
<Alert type="success" message="Вы прошли все уровни!" />
<Link to={'/'}>К списку игр</Link>
</div>)
}
if (!task) {
return (<div style={{ padding: 8 }}>
}
if (!task) {
return (<div style={{ padding: 8 }}>
<Alert type="warning" message="Для вас не предусмотренно уровней" />
<Link to={'/'}>К списку игр</Link>
</div>)
}
}
return (<>
return (<>
<Title>{task.title}</Title>
<Row gutter={8}>
<Col xs={24} sm={24} md={18}>

View file

@ -5,9 +5,9 @@ import { UserProvider } from '../store/user'
const { Title, Paragraph } = Typography
const Index = () => {
const { user } = UserProvider.useContainer()
const navigate = useNavigate()
return (<>
const { user } = UserProvider.useContainer()
const navigate = useNavigate()
return (<>
<Title>NQuest</Title>
<Paragraph>Привет! Это платформа для ARG игр.</Paragraph>
<Paragraph>
@ -15,11 +15,11 @@ const Index = () => {
А если ты знаешь зачем пришёл, то добро пожаловать!
</Paragraph>
{!user
? (<Button.Group>
? (<Button.Group>
<Button type="primary" onClick={() => navigate('/login')}>Вход</Button>
<Button onClick={() => navigate('/register')}>Регистрация</Button>
</Button.Group>)
: (<Button type="primary" onClick={() => navigate('/quests')}>К квестам</Button>)}
: (<Button type="primary" onClick={() => navigate('/quests')}>К квестам</Button>)}
</>)
}

View file

@ -5,45 +5,45 @@ import { ajax } from '../utils/fetch'
import { Alert, Button, Form, Input } from 'antd'
const Login = () => {
const { user, setUser } = UserProvider.useContainer()
const { state } = useLocation()
const [error, setError] = useState(null)
const navigate = useNavigate()
const [form] = Form.useForm()
const { user, setUser } = UserProvider.useContainer()
const { state } = useLocation()
const [error, setError] = useState(null)
const navigate = useNavigate()
const [form] = Form.useForm()
useEffect(() => {
if (user) {
navigate(state && state.from ? state.from : '/')
useEffect(() => {
if (user) {
navigate(state && state.from ? state.from : '/')
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/login', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Проверьте e-mail и пароль'))
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/login', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Проверьте e-mail и пароль'))
}
return (<>
return (<>
<h1>Вход</h1>
{error ? <Alert type='error' message={error} /> : null}
<Form
form={form}
name='login'
labelCol={{
span: 8
span: 8
}}
wrapperCol={{
span: 16
span: 16
}}
style={{
maxWidth: 600
maxWidth: 600
}}
onFinish={onFinish}>
@ -51,14 +51,14 @@ const Login = () => {
label='E-mail'
name='email'
rules={[
{
required: true,
message: 'Обязательное поле'
},
{
type: 'email',
message: 'E-mail некорректный'
}
{
required: true,
message: 'Обязательное поле'
},
{
type: 'email',
message: 'E-mail некорректный'
}
]}
>
<Input
@ -70,18 +70,18 @@ const Login = () => {
label='Пароль'
name='password'
rules={[
{
required: true,
message: 'Обязательное поле'
}
{
required: true,
message: 'Обязательное поле'
}
]}
>
<Input.Password />
</Form.Item>
<Form.Item
wrapperCol={{
offset: 8,
span: 16
offset: 8,
span: 16
}}
>
<Button type='primary' htmlType='submit'>

View file

@ -8,12 +8,12 @@ import { UserProvider } from '../store/user'
const { Title, Paragraph } = Typography
const Quests = () => {
moment.locale('ru')
const games = useLoaderData()
const { user } = UserProvider.useContainer()
const navigate = useNavigate()
moment.locale('ru')
const games = useLoaderData()
const { user } = UserProvider.useContainer()
const navigate = useNavigate()
return (<>
return (<>
<Title>Квесты</Title>
{games.map(item => renderItem(user, navigate, item))}
{games.length === 0 ? (<strong>Квестов пока не анонсировано</strong>) : null}
@ -21,38 +21,38 @@ const Quests = () => {
}
const renderItem = (user, navigate, item) => {
const actions = [
const actions = [
<Popover
key='type'
content={<Paragraph>Квесты бывают двух типов: <ul><li><b>Виртуальные</b> - проходимые из дома</li><li><b>Полевые</b> - проходимые в городе Казани</li></ul></Paragraph>}
title="Тип квеста">
<Space>
{item.type === 'city' ? 'Полевой' : 'Виртуальный'}
</Space>
key='type'
content={<Paragraph>Квесты бывают двух типов: <ul><li><b>Виртуальные</b> - проходимые из дома</li><li><b>Полевые</b> - проходимые в городе Казани</li></ul></Paragraph>}
title="Тип квеста">
<Space>
{item.type === 'city' ? 'Полевой' : 'Виртуальный'}
</Space>
</Popover>,
<Popover
key='exp'
content={<Paragraph>Вы получите {item.points} очков опыта за выполнение этого квеста.<br />Чем сложнее квест - тем больше за него опыта!</Paragraph>}
title='Опыт за выполнения квеста'>
<Space>{item.points} ОО</Space>
key='exp'
content={<Paragraph>Вы получите {item.points} очков опыта за выполнение этого квеста.<br />Чем сложнее квест - тем больше за него опыта!</Paragraph>}
title='Опыт за выполнения квеста'>
<Space>{item.points} ОО</Space>
</Popover>,
<Popover key='taskCount' content={`Этот квест состоит из ${item.taskCount} уровней`} title='Количество уровней в квесте'>
<Space>{item.taskCount} ур</Space>
<Space>{item.taskCount} ур</Space>
</Popover>,
<>{moment(item.createdAt).fromNow()}</>,
<>Автор {item.authors.map(a => a.username)}</>
]
]
let questAction = (<span>Необходимо войти</span>)
let questAction = (<span>Необходимо войти</span>)
if (user) {
questAction = (user.games.find(x => x.id === item.id)
? <span>Вы уже прошли этот квест</span>
: <Button onClick={() => navigate(`/go/${item.id}`)} type="primary">Начать квест</Button>
)
}
if (user) {
questAction = (user.games.find(x => x.id === item.id)
? <span>Вы уже прошли этот квест</span>
: <Button onClick={() => navigate(`/go/${item.id}`)} type="primary">Начать квест</Button>
)
}
return (
return (
<Card
key={item.id}
actions={actions}
@ -65,7 +65,7 @@ const renderItem = (user, navigate, item) => {
</Markdown>
<Space>{questAction}</Space>
</Card>
)
)
}
export default Quests

View file

@ -5,42 +5,42 @@ import { ajax } from '../utils/fetch'
import { Alert, Button, Form, Input } from 'antd'
const Register = () => {
const { user, setUser } = UserProvider.useContainer()
const [error, setError] = useState(null)
const navigate = useNavigate()
const { user, setUser } = UserProvider.useContainer()
const [error, setError] = useState(null)
const navigate = useNavigate()
useEffect(() => {
if (user) {
navigate('/')
useEffect(() => {
if (user) {
navigate('/')
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/register', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Ошибка регистрации'))
}
}, [user])
const onFinish = (values) => {
ajax('/api/user/register', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(setUser)
.catch(({ message }) => setError('Ошибка регистрации'))
}
return (<>
return (<>
<h1>Регистрация</h1>
{error ? <Alert type="error" message={error} /> : null}
<Form
name="register"
labelCol={{
span: 8
span: 8
}}
wrapperCol={{
span: 16
span: 16
}}
style={{
maxWidth: 600
maxWidth: 600
}}
onFinish={onFinish}>
@ -48,10 +48,10 @@ const Register = () => {
label="Имя пользователя"
name="username"
rules={[
{
required: true,
message: 'Обязательное поле'
}
{
required: true,
message: 'Обязательное поле'
}
]}
>
<Input />
@ -60,14 +60,14 @@ const Register = () => {
label="E-mail"
name="email"
rules={[
{
required: true,
message: 'Обязательное поле'
},
{
type: 'email',
message: 'E-mail некорректный'
}
{
required: true,
message: 'Обязательное поле'
},
{
type: 'email',
message: 'E-mail некорректный'
}
]}
help="Не видно другим пользователям"
>
@ -80,10 +80,10 @@ const Register = () => {
label="Пароль"
name="password"
rules={[
{
required: true,
message: 'Обязательное поле'
}
{
required: true,
message: 'Обязательное поле'
}
]}
>
<Input.Password />
@ -94,26 +94,26 @@ const Register = () => {
dependencies={['password']}
hasFeedback
rules={[
{
required: true,
message: 'Обязательное поле'
},
({ getFieldValue }) => ({
validator (_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('Пароли отличаются!'))
}
})
{
required: true,
message: 'Обязательное поле'
},
({ getFieldValue }) => ({
validator (_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve()
}
return Promise.reject(new Error('Пароли отличаются!'))
}
})
]}
>
<Input.Password />
</Form.Item>
<Form.Item
wrapperCol={{
offset: 8,
span: 16
offset: 8,
span: 16
}}
>
<Button type="primary" htmlType="submit">

View file

@ -5,29 +5,29 @@ import Markdown from 'react-markdown'
const { Paragraph } = Typography
const User = () => {
const { user } = UserProvider.useContainer()
if (user == null) {
return (<Space>Загрузка...</Space>)
}
const { user } = UserProvider.useContainer()
if (user == null) {
return (<Space>Загрузка...</Space>)
}
return (<>
return (<>
<h1>{user.username}</h1>
<Paragraph>Уровень: {user.level} ур</Paragraph>
<Paragraph>Очков опыта: {user.experience} ОО</Paragraph>
<Paragraph>Следующий уровень: {user.expToNextLevel} ОО</Paragraph>
<Progress
value={user.experience}
percent={((user.experience - user.expToCurrentLevel) / (user.expToNextLevel - user.expToCurrentLevel) * 100)}
size="small"
showInfo={false}
/>
{user.games.map(item => <Popover
<Paragraph>Уровень: {user.level} ур</Paragraph>
<Paragraph>Очков опыта: {user.experience} ОО</Paragraph>
<Paragraph>Следующий уровень: {user.expToNextLevel} ОО</Paragraph>
<Progress
value={user.experience}
percent={((user.experience - user.expToCurrentLevel) / (user.expToNextLevel - user.expToCurrentLevel) * 100)}
size="small"
showInfo={false}
/>
{user.games.map(item => <Popover
key={item.id}
title={item.title}
content={<Markdown>{item.description}</Markdown>}
>
>
<Avatar size={64} style={{ marginRight: 4, marginBottom: 4 }} key={item.id} src={`/api/file/${item.icon}`} />
</Popover>)}
</Popover>)}
</>)
}

View file

@ -9,121 +9,121 @@ import Markdown from 'react-markdown'
const { Title } = Typography
const Quest = () => {
let { quest, files } = useLoaderData()
const [error, setError] = useState()
if (!quest) {
quest = {
type: 'city',
points: 10,
tasks: [],
id: uuidv4(),
visible: false,
title: '',
description: ''
let { quest, files } = useLoaderData()
const [error, setError] = useState()
if (!quest) {
quest = {
type: 'city',
points: 10,
tasks: [],
id: uuidv4(),
visible: false,
title: '',
description: ''
}
}
}
const [fields, setFields] = useState(quest)
const [preview, setPreview] = useState(false)
const navigate = useNavigate()
const normFile = (e) => {
if (Array.isArray(e)) {
return e
const [fields, setFields] = useState(quest)
const [preview, setPreview] = useState(false)
const navigate = useNavigate()
const normFile = (e) => {
if (Array.isArray(e)) {
return e
}
if (e.file.response) {
return e.file.response.uuid
}
return e
}
if (e.file.response) {
return e.file.response.uuid
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 14 }
}
const buttonLayout = {
offset: 6,
span: 14
}
return e
}
const formItemLayout = {
labelCol: { span: 6 },
wrapperCol: { span: 14 }
}
const buttonLayout = {
offset: 6,
span: 14
}
const onFinish = (values) => {
ajax('/api/admin/games', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(g => navigate(`/admin/quests/${g.id}/`))
.catch(({ message }) => setError('Ошибка создания'))
}
const onFinish = (values) => {
ajax('/api/admin/games', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(values)
})
.then(g => navigate(`/admin/quests/${g.id}/`))
.catch(({ message }) => setError('Ошибка создания'))
}
return (
return (
<>
<Title>{quest.title ? (quest.title) : ('Новый квест')}</Title>
{error ? <Alert type="error" message={error} /> : null}
<Row gutter={8}>
<Col xs={24} sm={16} md={16}>
<Form
initialValues={quest}
onFinish={onFinish}
{...formItemLayout}
onValuesChange={(_, allFields) => setFields(allFields)}
>
<Form.Item wrapperCol={buttonLayout}>
<Button.Group block>
<Button type='primary' htmlType='submit' block>
Сохранить квест
</Button>
<Button type='default' block onClick={() => setPreview(true)}>
Предпросмотр
</Button>
</Button.Group>
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item label='Опубликован?' name='visible'>
<Switch />
</Form.Item>
<Form.Item label='Название' name='title'>
<Input />
</Form.Item>
<Form.Item label='Описание' name='description' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.Item
name='icon'
label='Иконка'
getValueFromEvent={normFile}
>
{quest.icon ? <Avatar src={`/api/file/${quest.icon}`} /> : null}
<Upload name='file' action={`/api/admin/file/${quest.id}/upload`} listType='picture' maxCount={1}>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
</Form.Item>
<Form.Item label='Тип квеста' name='type'>
<Radio.Group>
<Radio.Button value='city'>Полевой</Radio.Button>
<Radio.Button value='virtual'>Виртуальный</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label='Очков опыта за квест' name='points'>
<InputNumber />
</Form.Item>
<Form.List name='tasks'>
{(tasks, { add, remove }) => (
<>
{tasks.map(renderTaskForm(remove))}
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' onClick={() => add()} block>
<PlusOutlined/> Добавить уровень
<Col xs={24} sm={16} md={16}>
<Form
initialValues={quest}
onFinish={onFinish}
{...formItemLayout}
onValuesChange={(_, allFields) => setFields(allFields)}
>
<Form.Item wrapperCol={buttonLayout}>
<Button.Group block>
<Button type='primary' htmlType='submit' block>
Сохранить квест
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
<Button type='default' block onClick={() => setPreview(true)}>
Предпросмотр
</Button>
</Button.Group>
</Form.Item>
<Form.Item name='id' hidden>
<Input />
</Form.Item>
<Form.Item label='Опубликован?' name='visible'>
<Switch />
</Form.Item>
<Form.Item label='Название' name='title'>
<Input />
</Form.Item>
<Form.Item label='Описание' name='description' help='Поддерживается Markdown'>
<Input.TextArea />
</Form.Item>
<Form.Item
name='icon'
label='Иконка'
getValueFromEvent={normFile}
>
{quest.icon ? <Avatar src={`/api/file/${quest.icon}`} /> : null}
<Upload name='file' action={`/api/admin/file/${quest.id}/upload`} listType='picture' maxCount={1}>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
</Form.Item>
<Form.Item label='Тип квеста' name='type'>
<Radio.Group>
<Radio.Button value='city'>Полевой</Radio.Button>
<Radio.Button value='virtual'>Виртуальный</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item label='Очков опыта за квест' name='points'>
<InputNumber />
</Form.Item>
<Form.List name='tasks'>
{(tasks, { add, remove }) => (
<>
{tasks.map(renderTaskForm(remove))}
<Form.Item wrapperCol={buttonLayout}>
<Button type='primary' onClick={() => add()} block>
<PlusOutlined /> Добавить уровень
</Button>
</Form.Item>
</>
)}
</Form.List>
</Form>
</Col>
<Col xs={24} sm={8} md={8}>
<Title>Файлы</Title>
@ -132,41 +132,40 @@ const Quest = () => {
action={`/api/admin/file/${quest.id}/upload`}
listType='picture'
maxCount={10}
itemRender={renderFile}
itemRender={(e, file) => renderFile(e, file, quest)}
>
<Button icon={<UploadOutlined />}>Загрузка</Button>
</Upload>
Ранее загруженные файлы:
<List dataSource={files} renderItem={renderFileItem} />
<List dataSource={files} renderItem={x => renderFileItem(x, quest)} />
</Col>
</Row>
<Modal
title="Предпросмотр квеста"
open={preview}
footer={<Button onClick={() => setPreview(false)}>Закрыть</Button>}
width={'80%'}
centered
>
<List dataSource={fields.tasks} renderItem={(task) => (
<List.Item key={task.id}>
<List.Item.Meta
title={task.title}
description={
<><Card>
<Markdown>{task.text}</Markdown>
</Card>
</Row>
<Modal
title="Предпросмотр квеста"
open={preview}
footer={<Button onClick={() => setPreview(false)}>Закрыть</Button>}
width={'80%'}
centered
>
<List dataSource={fields.tasks} renderItem={(task) => (
<List.Item key={task.id}>
<List.Item.Meta
title={task.title}
description={
<><Card>
<Markdown>{task.text}</Markdown>
</Card>
Коды:
<ul>
<ul>
{task.codes.map(c => <li key={c.key}>{c.code}</li>)}
</ul>
</>
}
/>
</List.Item>
)} />
</Modal>
</>
)
</ul>
</>
}
/>
</List.Item>
)} />
</Modal>
</>)
}
// eslint-disable-next-line react/display-name
@ -184,11 +183,11 @@ const renderTaskForm = remove => task => (
cancelText='Нет'
>
<Button danger>
<CloseOutlined/> Удалить уровень
<CloseOutlined /> Удалить уровень
</Button>
</Popconfirm>
]}
>
>
<Form.Item name={[task.name, 'id']} hidden>
<Input />
</Form.Item>
@ -203,9 +202,9 @@ const renderTaskForm = remove => task => (
<>
{codes.map(renderCodeForm(codesOpts.remove))}
<Form.Item wrapperCol={{ offset: 6, span: 14 }}>
<Button key='addCode' type='primary' onClick={() => codesOpts.add()} block>
<PlusOutlined/> Добавить код
</Button>
<Button key='addCode' type='primary' onClick={() => codesOpts.add()} block>
<PlusOutlined /> Добавить код
</Button>
</Form.Item>
</>
)}
@ -219,19 +218,19 @@ const renderCodeForm = remove => code => (
key={code.key}
style={{ marginBottom: 8 }}
actions={[
// <Popconfirm
// key='delete'
// title='Удалить код?'
// onConfirm={() => remove(code.name)}
// okText='Да'
// cancelText='Нет'
// >
// <Popconfirm
// key='delete'
// title='Удалить код?'
// onConfirm={() => remove(code.name)}
// okText='Да'
// cancelText='Нет'
// >
<Button key="delete" danger onClick={() => remove(code.name)}>
<CloseOutlined/> Удалить код
<CloseOutlined /> Удалить код
</Button>
// </Popconfirm>
// </Popconfirm>
]}
>
>
<Form.Item name={[code.name, 'id']} hidden>
<Input />
</Form.Item>
@ -244,21 +243,24 @@ const renderCodeForm = remove => code => (
</Card>
)
const renderFile = (e, file) => (
<div key={file ? file.uid : null}>
{e}
{file && file.response && file.response.uuid
? <>Код для вставки: <pre>![](/api/file/{file.response.uuid})</pre></>
: null}
</div>
)
const renderFile = (e, file, quest) => {
console.log(file)
return (
<div key={file ? file.uid : null}>
{e}
{file && file.response && file.response.uuid
? <>Код для вставки: <pre>![](/file/{quest.id}/{file.originFileObj.name})</pre></>
: null}
</div>
)
}
const renderFileItem = (file) => (
const renderFileItem = (file, quest) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={`/api/file/${file.id}`} />}
avatar={<Avatar src={`/file/${quest.id})/${file.originalName}`} />}
title={file.originalName}
description={<>Код для вставки: <pre>![](/api/file/{file.id})</pre></>}
description={<>Код для вставки: <pre>![](/file/{quest.id}/{file.originalName})</pre></>}
/>
</List.Item>

View file

@ -4,9 +4,9 @@ import { Link, useLoaderData } from 'react-router-dom'
const { Title } = Typography
const Quests = () => {
const quests = useLoaderData()
const quests = useLoaderData()
return (
return (
<>
<Title>Управление своими квестами</Title>
<Link to="/admin/quests/new">Создать новый квест</Link>
@ -14,29 +14,29 @@ const Quests = () => {
dataSource={quests}
rowKey={'id'}
columns={[
{
title: 'Опубликован?',
dataIndex: 'visible',
key: 'visible',
render: visible => visible ? 'Да' : 'Нет'
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title, q) => <Link to={`/admin/quests/${q.id}`}>{title}</Link>
},
{
title: 'Тип',
dataIndex: 'type',
key: 'type',
render: type => type === 'virtual' ? 'Виртуальный' : 'Полевой'
}
{
title: 'Опубликован?',
dataIndex: 'visible',
key: 'visible',
render: visible => visible ? 'Да' : 'Нет'
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title, q) => <Link to={`/admin/quests/${q.id}`}>{title}</Link>
},
{
title: 'Тип',
dataIndex: 'type',
key: 'type',
render: type => type === 'virtual' ? 'Виртуальный' : 'Полевой'
}
]}
/>
</>
)
)
}
export default Quests

View file

@ -1,5 +1,5 @@
import { UserProvider } from './user'
export const store = [
UserProvider.Provider
UserProvider.Provider
]

View file

@ -2,8 +2,8 @@ import { useState } from 'react'
import { createContainer } from 'unstated-next'
const useUser = () => {
const [user, setUser] = useState(null)
return { user, setUser }
const [user, setUser] = useState(null)
return { user, setUser }
}
export const UserProvider = createContainer(useUser)

View file

@ -1,10 +1,10 @@
export const ajax = async (path, params) => {
return fetch(path, params)
.then(r => {
if (r.status < 200 || r.status >= 300) {
throw Error(r.statusText)
}
return r
})
.then(r => r.json())
return fetch(path, params)
.then(r => {
if (r.status < 200 || r.status >= 300) {
throw Error(r.statusText)
}
return r
})
.then(r => r.json())
}

View file

@ -1,24 +1,24 @@
import { UserProvider } from '../store/user'
const roleHierarchy = {
user: {
user: true
},
creator: {
user: true,
creator: true
},
admin: {
user: true,
creator: true,
admin: true
}
user: {
user: true
},
creator: {
user: true,
creator: true
},
admin: {
user: true,
creator: true,
admin: true
}
}
export const useRole = () => {
const { user } = UserProvider.useContainer()
const { user } = UserProvider.useContainer()
return {
hasRole: (role) => user && user.role && !!roleHierarchy[user.role][role]
}
return {
hasRole: (role) => user && user.role && !!roleHierarchy[user.role][role]
}
}

View file

@ -1,5 +1,5 @@
export function uuidv4 () {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
)
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
(+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
)
};

View file

@ -3,88 +3,95 @@ import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
const manifest = {
registerType: 'autoUpdate',
includeAssets: ['assets/icon.png', 'assets/logo.png'],
workbox: {
cleanupOutdatedCaches: true
},
manifest: {
name: 'NQuest',
short_name: 'NQuest',
description: 'NQuest - платформа для ARG игр.',
icons: [
{
src: 'assets/icons/icon-72x72.png',
sizes: '72x72',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-96x96.png',
sizes: '96x96',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-128x128.png',
sizes: '128x128',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-144x144.png',
sizes: '144x144',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-152x152.png',
sizes: '152x152',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any'
}
],
theme_color: '#59FBEA',
background_color: '#171e26',
display: 'standalone',
scope: '/',
start_url: '/quests'
}
registerType: 'autoUpdate',
includeAssets: ['assets/icon.png', 'assets/logo.png'],
workbox: {
cleanupOutdatedCaches: true
},
base: 'assets',
manifest: {
name: 'NQuest',
short_name: 'NQuest',
description: 'NQuest - платформа для ARG игр.',
icons: [
{
src: 'assets/icons/icon-72x72.png',
sizes: '72x72',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-96x96.png',
sizes: '96x96',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-128x128.png',
sizes: '128x128',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-144x144.png',
sizes: '144x144',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-152x152.png',
sizes: '152x152',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-384x384.png',
sizes: '384x384',
type: 'image/png',
purpose: 'maskable any'
},
{
src: 'assets/icons/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable any'
}
],
theme_color: '#59FBEA',
background_color: '#171e26',
display: 'standalone',
scope: '/',
start_url: '/quests'
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), splitVendorChunkPlugin(), VitePWA(manifest)],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
ws: false
}
}
},
build: {
plugins: [react(), splitVendorChunkPlugin(), VitePWA(manifest)],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
ws: false
},
'/file': {
target: 'http://localhost:8000',
changeOrigin: true,
secure: false,
ws: false
}
}
},
build: {
// generate .vite/manifest.json in outDir
manifest: true
}
manifest: true
}
})

View file

@ -127,12 +127,12 @@ func main() {
api.RegisterHandlersWithBaseURL(codegen, handler, "/api")
e.FileFS("/", "index.html", distIndexHtml)
e.FileFS("/*", "index.html", distIndexHtml)
e.StaticFS("/", distDirFS)
// --[ System ]--
e.GET("/metrics", echoprometheus.NewHandler())
e.StaticFS("/file", afero.NewIOFS(storage))
e.StaticFS("/*", distDirFS)
e.Logger.Debugf("backend version %s", Version)
e.Logger.Fatal(e.Start(cfg.Listen))
}

View file

@ -45,7 +45,7 @@ func (a *Admin) AdminEditGame(ctx echo.Context) error {
})
}
tasks = append(tasks, api.TaskEdit{
Id: &t.ID,
Id: t.ID,
Codes: codes,
Text: t.Text,
Title: t.Title,
@ -98,7 +98,7 @@ func (a *Admin) AdminGetGame(ctx echo.Context, uid uuid.UUID) error {
})
}
tasks = append(tasks, api.TaskEdit{
Id: &t.ID,
Id: t.ID,
Codes: codes,
Text: t.Text,
Title: t.Title,
@ -163,12 +163,8 @@ func (*Admin) mapCreateGameRequest(req *api.GameEdit, user *models.User) *models
IconID: req.Icon,
}
for order, te := range req.Tasks {
id := uuid.New()
if te.Id != nil {
id = *te.Id
}
task := &models.Task{
ID: id,
ID: te.Id,
Title: te.Title,
Text: te.Text,
Codes: make([]*models.Code, 0, len(te.Codes)),

View file

@ -40,16 +40,6 @@ func (u *File) AdminUploadFile(c echo.Context, quest uuid.UUID) error {
})
}
// (GET /file/{uid})
func (u *File) GetFile(c echo.Context, uid uuid.UUID) error {
f, rdr, err := u.FileService.GetFile(c.Request().Context(), uid)
if err != nil {
return err
}
return c.Stream(200, f.ContentType, rdr)
}
func (u *File) AdminListFiles(c echo.Context, quest uuid.UUID) error {
fl, err := u.FileService.GetFilesByQuest(c.Request().Context(), quest)
if err != nil {

View file

@ -10,6 +10,7 @@ type Task struct {
Title string
Text string
MaxTime int
Game *Game
GameID uuid.UUID
Codes []*Code `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
TaskOrder uint

View file

@ -3,7 +3,6 @@ package service
import (
"context"
"fmt"
"io"
"mime/multipart"
"github.com/google/uuid"
@ -53,21 +52,6 @@ func (u *File) Upload(
return file.ID, u.DB.WithContext(ctx).Create(file).Error
}
func (u *File) GetFile(ctx context.Context, uid uuid.UUID) (*models.File, io.ReadCloser, error) {
f := new(models.File)
if err := u.DB.WithContext(ctx).First(f, uid).Error; err != nil {
return nil, nil, err
}
filePath := fmt.Sprintf("%s/%s", f.QuestID.String(), f.Filename)
file, err := u.store.Open(filePath)
if err != nil {
return nil, nil, err
}
return f, file, nil
}
func (u *File) GetFilesByQuest(ctx context.Context, quest uuid.UUID) ([]*models.File, error) {
list := make([]*models.File, 0)

View file

@ -68,7 +68,14 @@ func (gs *Game) ListByAuthor(ctx context.Context, author *models.User) ([]*model
}
func (gs *Game) UpsertGame(ctx context.Context, game *models.Game) (*models.Game, error) {
return game, gs.DB.Debug().
ids := []uuid.UUID{}
for _, t := range game.Tasks {
ids = append(ids, t.ID)
}
gs.DB.Delete([]models.Task{}, `game_id = ? and id not in (?)`, game.ID, ids)
err := gs.DB.
Session(&gorm.Session{FullSaveAssociations: true}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
@ -76,6 +83,11 @@ func (gs *Game) UpsertGame(ctx context.Context, game *models.Game) (*models.Game
"title", "description",
}),
}).
Create(&game).
Create(game).
Error
if err != nil {
return nil, err
}
return game, gs.DB.Save(game).Error
}