From 31957206b8f3e2a1a55652e36fb89b8717fd9e1d Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Sep 2025 18:43:28 +0300 Subject: [PATCH] initial --- Dockerfile | 52 ++ Makefile | 32 ++ README.md | 5 + cmd/server/main.go | 47 ++ cmd/ui/main.go | 14 + cmd/wasm/main.go | 128 +++++ entrypoint.sh | 7 + go.mod | 27 ++ go.sum | 44 ++ internal/model/model.go | 58 +++ internal/puzzle/assets/audio/click.mp3 | Bin 0 -> 12152 bytes internal/puzzle/assets/fonts/README.md | 6 + internal/puzzle/assets/fonts/font9x16.ttf | Bin 0 -> 70744 bytes internal/puzzle/assets/text/game_field.txt | 16 + internal/puzzle/assets/text/gopher_beige.txt | 12 + internal/puzzle/assets/text/gopher_cyan.txt | 12 + internal/puzzle/assets/text/gopher_gray.txt | 5 + internal/puzzle/assets/text/gopher_white.txt | 6 + internal/puzzle/assets/text/splash_screen.txt | 16 + internal/puzzle/controller.go | 448 ++++++++++++++++++ internal/puzzle/debug.go | 35 ++ internal/puzzle/form.go | 113 +++++ internal/puzzle/handler.go | 44 ++ internal/puzzle/l10n.go | 74 +++ internal/puzzle/puzzle.go | 295 ++++++++++++ internal/puzzle/puzzle_test.go | 58 +++ internal/puzzle/splash.go | 80 ++++ internal/puzzle/tile.go | 139 ++++++ internal/puzzle/url.go | 24 + internal/repo/repo.go | 195 ++++++++ internal/repo/repo_test.go | 154 ++++++ internal/tgbot/tgbot.go | 45 ++ internal/validator/validator.go | 67 +++ internal/validator/validator_test.go | 28 ++ internal/web-service/handler/handler.go | 182 +++++++ internal/web-service/handler/handler_test.go | 208 ++++++++ .../handler/testdata/tgwebapp.html | 1 + internal/web-service/server/server.go | 30 ++ web/tgwebapp.html | 222 +++++++++ web/wasmwebapp.html | 18 + 40 files changed, 2947 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 cmd/ui/main.go create mode 100644 cmd/wasm/main.go create mode 100644 entrypoint.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/model/model.go create mode 100644 internal/puzzle/assets/audio/click.mp3 create mode 100644 internal/puzzle/assets/fonts/README.md create mode 100644 internal/puzzle/assets/fonts/font9x16.ttf create mode 100644 internal/puzzle/assets/text/game_field.txt create mode 100644 internal/puzzle/assets/text/gopher_beige.txt create mode 100644 internal/puzzle/assets/text/gopher_cyan.txt create mode 100644 internal/puzzle/assets/text/gopher_gray.txt create mode 100644 internal/puzzle/assets/text/gopher_white.txt create mode 100644 internal/puzzle/assets/text/splash_screen.txt create mode 100644 internal/puzzle/controller.go create mode 100644 internal/puzzle/debug.go create mode 100644 internal/puzzle/form.go create mode 100644 internal/puzzle/handler.go create mode 100644 internal/puzzle/l10n.go create mode 100644 internal/puzzle/puzzle.go create mode 100644 internal/puzzle/puzzle_test.go create mode 100644 internal/puzzle/splash.go create mode 100644 internal/puzzle/tile.go create mode 100644 internal/puzzle/url.go create mode 100644 internal/repo/repo.go create mode 100644 internal/repo/repo_test.go create mode 100644 internal/tgbot/tgbot.go create mode 100644 internal/validator/validator.go create mode 100644 internal/validator/validator_test.go create mode 100644 internal/web-service/handler/handler.go create mode 100644 internal/web-service/handler/handler_test.go create mode 100644 internal/web-service/handler/testdata/tgwebapp.html create mode 100644 internal/web-service/server/server.go create mode 100644 web/tgwebapp.html create mode 100644 web/wasmwebapp.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b84fa05 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +ARG REPO_URL=docker.io +ARG IMAGE_BUILDER=golang:1.23.0-alpine3.20 +ARG IMAGE_RUNNER=alpine:3.20 + +## --- Builder + +FROM ${REPO_URL}/${IMAGE_BUILDER} AS builder +LABEL stage=builder +WORKDIR /build + +ARG GOPROXY_URL +ENV GOPROXY=${GOPROXY_URL} +SHELL [ "/bin/sh", "-ec" ] + +RUN apk add --no-cache make + +COPY go.mod go.sum Makefile ./ +COPY cmd/ cmd/ +COPY internal/ internal/ + +ENV GOOS=linux +ENV GOARCH=amd64 + +RUN cp $(go env GOROOT)/misc/wasm/wasm_exec.js . + +RUN make build +RUN make build/wasm + +## --- Runner + +FROM ${REPO_URL}/${IMAGE_RUNNER} + +ARG UID=1337 +ARG GID=1337 +ENV APPLICATION_HOME=/home/app \ + TARGET_USER=app \ + TARGET_GROUP=app + +RUN apk add --no-cache tzdata ; \ + getent group ${GID} || addgroup --gid ${GID} ${TARGET_GROUP} ; \ + adduser -u ${UID} -g ${TARGET_GROUP} -D -S -h ${APPLICATION_HOME} ${TARGET_USER} +WORKDIR ${APPLICATION_HOME} +USER ${TARGET_USER} + +COPY --from=builder /tmp/bin/server /usr/bin/server +COPY --from=builder --chown=${TARGET_USER}:${TARGET_GROUP} /tmp/bin/game.wasm ./ +COPY --from=builder --chown=${TARGET_USER}:${TARGET_GROUP} /build/wasm_exec.js ./ +COPY --chown=${TARGET_USER}:${TARGET_GROUP} web/*.html ./ + +COPY --chown=${TARGET_USER}:${TARGET_GROUP} entrypoint.sh ./ + +CMD [ "/bin/sh", "/home/app/entrypoint.sh", "/usr/bin/server" ] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0b9eccf --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: tidy +tidy: + go mod tidy + go fmt ./... + +.PHONY: build +build: tidy + go build -o=/tmp/bin/server ./cmd/server + +.PHONY: build/wasm +build/wasm: tidy + GOOS=js GOARCH=wasm go build -o /tmp/bin/game.wasm ./cmd/wasm + +.PHONY: run +run: build + /tmp/bin/server + +.PHONY: run/local +run/local: build build/wasm + cp web/*.html /tmp/bin/ + cp ${shell go env GOROOT}/misc/wasm/wasm_exec.js /tmp/bin/ + STATIC_DIR=/tmp/bin SERVER_PORT=9001 /tmp/bin/server + +.PHONY: run/docker +run/docker: + docker build -t 15-puzzle:dev . + docker run -it -p 9001:9001 --rm --env SERVER_PORT=9001 --name 15-puzzle 15-puzzle:dev + +.PHONY: run/ui +run/ui: + go build -v -o=/tmp/bin/ui ./cmd/ui/ + /tmp/bin/ui diff --git a/README.md b/README.md new file mode 100644 index 0000000..be091f9 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# 15 Puzzle game with Go + +## Credits + +- [Gopher by gheimifurt](https://www.reddit.com/r/golang/comments/xdxb9a/gopher_ascii_art_for_bashrc/). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..3bcf42e --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "15-puzzle/internal/repo" + "15-puzzle/internal/tgbot" + "15-puzzle/internal/web-service/handler" + "15-puzzle/internal/web-service/server" + "context" + "fmt" + "log/slog" + "os" + "os/signal" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + token := requireEnv("BOT_TOKEN") + + bot, err := tgbot.NewTgBot(ctx, token) + if err != nil { + exitWithError("bot init: %s", err) + } + bot.Start() + + r, err := repo.NewFileRepo(ctx, requireEnv("DATA_FILE")) + if err != nil { + exitWithError("repo init: %s", err) + } + + server.StartServer(ctx, + handler.NewHandler(r, token, requireEnv("ACCESS_CODE"), os.Getenv("CONTEXT_ROOT"), os.Getenv("STATIC_DIR"), requireEnv("PROJECT_LINK"))) +} + +func requireEnv(env string) string { + v, ok := os.LookupEnv(env) + if !ok { + exitWithError(env + " variable not set") + } + return v +} + +func exitWithError(format string, a ...any) { + slog.Error(fmt.Sprintf(format, a...)) + os.Exit(1) +} diff --git a/cmd/ui/main.go b/cmd/ui/main.go new file mode 100644 index 0000000..5ef4cbb --- /dev/null +++ b/cmd/ui/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "15-puzzle/internal/puzzle" + "fmt" + "os" +) + +func main() { + if err := puzzle.Init(func(p *puzzle.Controller) { p.SetActive(true) }); err != nil { + fmt.Fprintf(os.Stderr, "start failed: %v", err) + os.Exit(1) + } +} diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go new file mode 100644 index 0000000..c2a74b5 --- /dev/null +++ b/cmd/wasm/main.go @@ -0,0 +1,128 @@ +//go:build js && wasm + +package main + +import ( + "15-puzzle/internal/model" + "15-puzzle/internal/puzzle" + "15-puzzle/internal/web-service/handler" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "syscall/js" +) + +func main() { + if err := puzzle.Init(func(p *puzzle.Controller) { + p.InfoRequest = func() { + js.Global().Call("apiRequest", js.ValueOf(http.MethodGet), js.ValueOf("api/info")) + } + p.UserStatsRequest = func() { + js.Global().Call("apiRequest", js.ValueOf(http.MethodGet), js.ValueOf("api/stats")) + } + p.OnGameStart = func() { + js.Global().Call("apiRequest", js.ValueOf(http.MethodPut), js.ValueOf("api/start")) + } + p.OnGameSolve = func(moves int) { + js.Global().Call("apiRequest", js.ValueOf(http.MethodPut), js.ValueOf("api/solve?moves="+strconv.Itoa(moves))) + } + p.MonitoringRequest = func(code string) { + js.Global().Call("apiRequest", js.ValueOf(http.MethodGet), js.ValueOf("api/monitoring"), js.ValueOf(code)) + } + p.UrlOpener = func(url string) { + js.Global().Call("openLink", js.ValueOf(url)) + } + js.Global().Set("wasmHTTPRequest", HTTPRequestFunc(p.ApiResponseHandler)) + js.Global().Set("wasmDebug", js.FuncOf(func(this js.Value, args []js.Value) any { p.Debug(args[0].String()); return nil })) + js.Global().Set("wasmOnLoad", js.FuncOf(func(this js.Value, args []js.Value) any { + p.OnLoad(args[0].Float(), args[1].String(), args[2].String()) + return nil + })) + js.Global().Set("wasmSetActive", js.FuncOf(func(this js.Value, args []js.Value) any { p.SetActive(args[0].Bool()); return nil })) + }); err != nil { + fmt.Fprintf(os.Stderr, "start failed: %v", err) + os.Exit(1) + } +} + +func HTTPRequestFunc(responseHandler func(model.ApiResponse)) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) any { + // Get the URL as argument + reqMethod := args[0].String() + reqUrl := args[1].String() + code := args[2].String() + appData := args[3].String() + handler := js.FuncOf(func(this js.Value, args []js.Value) any { + resolve := args[0] + reject := args[1] + onErr := func(err error) { + errorConstructor := js.Global().Get("Error") + errorObject := errorConstructor.New(err.Error()) + reject.Invoke(errorObject) + errStr := err.Error() + responseHandler(model.ApiResponse{Err: &errStr}) + } + var result model.ApiResponse + go func() { + // The HTTP request + req, err := http.NewRequest(reqMethod, reqUrl, nil) + if err != nil { + onErr(fmt.Errorf("http new request: %s", err)) + return + } + req.Header.Add(handler.WebAppInitDataHeader, appData) + if code != "" { + req.Header.Add(handler.WebAppExtraCodeHeader, code) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + onErr(fmt.Errorf("http do request: %s", err)) + return + } + defer res.Body.Close() + + data := make([]byte, 0) + + // Func to return a Promise because HTTP requests are blocking in Go + returnPromise := func() { + arrayConstructor := js.Global().Get("Uint8Array") + dataJS := arrayConstructor.New(len(data)) + js.CopyBytesToJS(dataJS, data) + responseConstructor := js.Global().Get("Response") + response := responseConstructor.New(dataJS) + resolve.Invoke(response) + } + + switch res.StatusCode { + case http.StatusForbidden: + returnPromise() + return + case http.StatusOK: + default: + onErr(fmt.Errorf("http code: %d", res.StatusCode)) + return + } + + // Read the response body + data, err = io.ReadAll(res.Body) + if err != nil { + onErr(fmt.Errorf("http read response: %s", err)) + return + } + if err := json.Unmarshal(data, &result); err != nil { + onErr(fmt.Errorf("http response to json: %s", err)) + return + } + + responseHandler(result) + returnPromise() + }() + return nil + }) + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) + }) +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..b78e6c0 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +echo "Starting app" +/bin/sh -c $@ +exit_code=$? +echo "App exit code: ${exit_code}" +exit ${exit_code} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9f0c79f --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module 15-puzzle + +go 1.23.0 + +require github.com/hajimehoshi/ebiten/v2 v2.8.5 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/oto/v3 v3.3.1 // indirect + github.com/ebitengine/purego v0.8.0 // indirect + github.com/go-telegram/bot v1.11.1 + github.com/go-text/typesetting v0.2.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/image v0.20.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6710122 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325 h1:Gk1XUEttOk0/hb6Tq3WkmutWa0ZLhNn/6fc6XZpM7tM= +github.com/ebitengine/gomobile v0.0.0-20240911145611-4856209ac325/go.mod h1:ulhSQcbPioQrallSuIzF8l1NKQoD7xmMZc5NxzibUMY= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/oto/v3 v3.3.1 h1:d4McwGQuXOT0GL7bA5g9ZnaUEIEjQvG3hafzMy+T3qE= +github.com/ebitengine/oto/v3 v3.3.1/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U= +github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE= +github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-telegram/bot v1.11.1 h1:pvsXydwKpNcD1M4Y5TeKzGHUuRuQwx+FRXXgcviEFGc= +github.com/go-telegram/bot v1.11.1/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho= +github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= +github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= +github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= +github.com/hajimehoshi/bitmapfont/v3 v3.2.0 h1:0DISQM/rseKIJhdF29AkhvdzIULqNIIlXAGWit4ez1Q= +github.com/hajimehoshi/bitmapfont/v3 v3.2.0/go.mod h1:8gLqGatKVu0pwcNCJguW3Igg9WQqVXF0zg/RvrGQWyg= +github.com/hajimehoshi/ebiten/v2 v2.8.5 h1:w1/3XxjEwIo+amtQCOnCrwGzu4e6dr0ewu83JUKoxrM= +github.com/hajimehoshi/ebiten/v2 v2.8.5/go.mod h1:SXx/whkvpfsavGo6lvZykprerakl+8Uo1X8d2U5aAnA= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/image v0.20.0 h1:7cVCUjQwfL18gyBJOmYvptfSHS8Fb3YUDtfLIZ7Nbpw= +golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..e3f9d22 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,58 @@ +package model + +import ( + "encoding/json" + "time" +) + +type JSONTimestamp time.Time + +func (t JSONTimestamp) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Time(t).Unix()) +} + +func (t *JSONTimestamp) UnmarshalJSON(data []byte) error { + var v int64 + if err := json.Unmarshal(data, &v); err != nil { + return err + } + *t = JSONTimestamp(time.Unix(v, 0)) + return nil +} + +type Data struct { + Users map[int]User `json:"users"` +} + +type User struct { + UserID int `json:"user_id"` + GamesStarted int `json:"games_started"` + GamesSolved int `json:"games_solved"` + LastStartTime *JSONTimestamp `json:"last_start_ts,omitempty"` + BestResult *float32 `json:"best_result,omitempty"` + BestSolveTime *JSONTimestamp `json:"best_solve_ts,omitempty"` + Monitoring *Monitoring `json:"-"` +} + +type ApiResponse struct { + Stats *Stats `json:"stats,omitempty"` + Monitoring *Monitoring `json:"monitoring,omitempty"` + Info *Info `json:"info,omitempty"` + Err *string `json:"error,omitempty"` +} + +type Stats struct { + Rank int `json:"rank"` + GamesStarted int `json:"games_started"` + GamesSolved int `json:"games_solved"` +} + +type Monitoring struct { + Users int `json:"users,omitempty"` + GamesStarted int `json:"games_started,omitempty"` + GamesSolved int `json:"games_solved,omitempty"` +} + +type Info struct { + ProjectLink string `json:"project_link"` +} diff --git a/internal/puzzle/assets/audio/click.mp3 b/internal/puzzle/assets/audio/click.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ceeeed46977c792d8d677889b9b64c802825b424 GIT binary patch literal 12152 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μ#|E=cAjmrJ}s&$Mms-Pxs)yBBei;M~ZbIJYoJgTT4v4V5IJ zJRCRm;~dMQ3vND|S1Xb^muD%JlMAQ*F_37}KL~c?^zo!Z-?G-|Q7>ERlq-uRJ~~}Q zQ;qfacwJ~0Rq9;emvW*z_029;@VGNgs)@bO5kO~ggii%Cv8)PSgZg7zdUJ#gXoruF z==}`R95LhZcm!ho+KU&4b-z}V?En1xU-7l5)%x4b;GJ6j>kt3_&-$qa&U}3BZQNhR zSHx@J%|Y*hKktE27vRqYv`>OX7huUh1^qMN$_04xm%+Aw4s5vqTmJd$0*v_=ZV0gE zUqsE9aCN|*e;I82E4Vt~(7yuxt65)4V4r`4`nSPD3$V~XhRvVg65!5%3g6xZ8z;bo z{{r{lfbF)ydi@sYzr!WKjQt+>KjLbG?XLe_vgf!2c-nuo1UTRo?r-56r~tqCS$v$( z)*1ALbVi3Z1pUg&TWzq0Pg)(Yfj@x$bL(f& z-?iR>h4-xY(B@axFL8h0dLK1^vVINy1M35{`HS^u=pR}i!v1sX`3=$TSs%3oxUs#K Kc!59l9R3%mNIrW2 literal 0 HcmV?d00001 diff --git a/internal/puzzle/assets/text/game_field.txt b/internal/puzzle/assets/text/game_field.txt new file mode 100644 index 0000000..f4a4a7f --- /dev/null +++ b/internal/puzzle/assets/text/game_field.txt @@ -0,0 +1,16 @@ +▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌ +▐ ▌ +▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐ ▌ +▐▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▌ \ No newline at end of file diff --git a/internal/puzzle/assets/text/gopher_beige.txt b/internal/puzzle/assets/text/gopher_beige.txt new file mode 100644 index 0000000..c63c9e0 --- /dev/null +++ b/internal/puzzle/assets/text/gopher_beige.txt @@ -0,0 +1,12 @@ + + + + + ( ) + + + O O + + + + oO Oo \ No newline at end of file diff --git a/internal/puzzle/assets/text/gopher_cyan.txt b/internal/puzzle/assets/text/gopher_cyan.txt new file mode 100644 index 0000000..52978f6 --- /dev/null +++ b/internal/puzzle/assets/text/gopher_cyan.txt @@ -0,0 +1,12 @@ + ´.-::::::-.´ + .:-::::::::::::::-:. + ´_:: ::_´ + .: :. + ´:: ::. + ´::::::: :::::::´ + .::::::::::::::::. + :::::::::::::::: + -::::::::::::::::- + ´::::::::::::::::´ + .::::::::::::::. + ::::::: \ No newline at end of file diff --git a/internal/puzzle/assets/text/gopher_gray.txt b/internal/puzzle/assets/text/gopher_gray.txt new file mode 100644 index 0000000..9ceed4a --- /dev/null +++ b/internal/puzzle/assets/text/gopher_gray.txt @@ -0,0 +1,5 @@ + + + + + .. \ No newline at end of file diff --git a/internal/puzzle/assets/text/gopher_white.txt b/internal/puzzle/assets/text/gopher_white.txt new file mode 100644 index 0000000..747fcaa --- /dev/null +++ b/internal/puzzle/assets/text/gopher_white.txt @@ -0,0 +1,6 @@ + + + : :: : + ( ^ :: ^ ) + : : + UU \ No newline at end of file diff --git a/internal/puzzle/assets/text/splash_screen.txt b/internal/puzzle/assets/text/splash_screen.txt new file mode 100644 index 0000000..4722c94 --- /dev/null +++ b/internal/puzzle/assets/text/splash_screen.txt @@ -0,0 +1,16 @@ +┌─────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ -> │ +└─────────────────────────────┘ \ No newline at end of file diff --git a/internal/puzzle/controller.go b/internal/puzzle/controller.go new file mode 100644 index 0000000..75de0e4 --- /dev/null +++ b/internal/puzzle/controller.go @@ -0,0 +1,448 @@ +package puzzle + +import ( + "bytes" + _ "embed" + "encoding/hex" + "fmt" + "image" + "image/color" + "log/slog" + "math" + "strings" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/audio" + "github.com/hajimehoshi/ebiten/v2/audio/mp3" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/text/v2" +) + +const ( + puzzleTileSymW = 7 + puzzleTileSymH = 3 + puzzleSymW = 9 + puzzleSymH = 16 + puzzleSymX = 31 + puzzleSymY = 16 + puzzlePixX = puzzleSymX * puzzleSymW + puzzlePixY = puzzleSymY * puzzleSymH + + logLineH = 18 +) + +var ( + //go:embed assets/fonts/font9x16.ttf + DosFont []byte + //go:embed assets/audio/click.mp3 + Click_mp3 []byte + //go:embed assets/text/game_field.txt + playFieldTemplate string + //go:embed assets/text/splash_screen.txt + splashScreenTemplate string + //go:embed assets/text/gopher_cyan.txt + gopherCyan string + //go:embed assets/text/gopher_white.txt + gopherWhite string + //go:embed assets/text/gopher_beige.txt + gopherBeige string + //go:embed assets/text/gopher_gray.txt + gopherGray string + + defaultBgColor = color.Black +) + +type screen int + +const ( + screenGame screen = iota + screenForm + screenSplash + screenDebug +) + +type ticker int + +const ( + ticker1Hz ticker = iota + ticker2Hz + ticker4Hz + ticker10Hz + ticker25Hz +) + +type actionResult int + +const ( + resultNone actionResult = iota + resultSwitchGame + resultSwitchForm + resultSwitchDebug +) + +type Audio interface { + PlaySound() +} + +type Screen interface { + Fill(rect image.Rectangle, clr color.Color) + Print(txt string, coord image.Point, clr color.Color) + PrintDebug(msg string, line int) +} + +type Handler interface { + Draw(Screen) + Tick(ticker) + Interact(Audio, int, int, time.Duration) actionResult + SetLang(langCode) + Activate() +} + +type Controller struct { + screen image.Rectangle + bgColor color.Color + scr *ebiten.Image + font *text.GoTextFaceSource + + activeState atomic.Bool + activeScreen screen + screens map[screen]Handler + + debugFn func(string) + + btnPressed time.Time + touchTapped map[ebiten.TouchID]time.Time + OnGameStart func() + OnGameSolve func(int) + InfoRequest func() + UserStatsRequest func() + MonitoringRequest func(string) + UrlOpener func(string) + + audioCtx *audio.Context + player *audio.Player +} + +func Init(init ...func(*Controller)) error { + ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) + ebiten.SetWindowSize(640, 480) + ebiten.SetWindowTitle(l10nGameTitle(langCodeEn)) + c, err := newController() + if err != nil { + return err + } + for i := range init { + init[i](c) + } + c.screens[screenGame] = newGame(c.OnGameStart, c.OnGameSolve, c.UserStatsRequest) + c.screens[screenForm] = newForm(c.MonitoringRequest) + c.screens[screenSplash] = newSplash(c.UrlOpener) + c.screens[screenDebug], c.debugFn = newDebugOverlay() + + c.SetLangCode(langCodeRu) + + return ebiten.RunGame(c) +} + +func newController() (*Controller, error) { + tfs, err := text.NewGoTextFaceSource(bytes.NewReader(DosFont)) + if err != nil { + return nil, err + } + + audioCtx := audio.NewContext(44100) + s, err := mp3.DecodeWithoutResampling(bytes.NewReader(Click_mp3)) + if err != nil { + return nil, err + } + player, err := audioCtx.NewPlayer(s) + if err != nil { + return nil, err + } + + p := &Controller{ + scr: ebiten.NewImage(puzzlePixX, puzzlePixY), + font: tfs, + activeScreen: screenSplash, + screens: make(map[screen]Handler), + touchTapped: make(map[ebiten.TouchID]time.Time), + player: player, + audioCtx: audioCtx, + activeState: atomic.Bool{}, + } + p.OnGameStart = func() {} + p.OnGameSolve = func(int) {} + p.InfoRequest = func() {} + p.UserStatsRequest = func() {} + p.MonitoringRequest = func(string) {} + p.UrlOpener = func(url string) { + if err := openURL(url); err != nil { + p.Debug("url open error: %v", err) + } + } + go p.tick() + return p, nil +} + +func (c *Controller) tick() { + t1Hz := time.NewTicker(time.Second) + t2Hz := time.NewTicker(time.Millisecond * 500) + t4Hz := time.NewTicker(time.Millisecond * 250) + t10Hz := time.NewTicker(time.Millisecond * 100) + t25Hz := time.NewTicker(time.Millisecond * 40) + for { + select { + case <-t1Hz.C: + c.TickEvent(ticker1Hz) + case <-t2Hz.C: + c.TickEvent(ticker2Hz) + case <-t4Hz.C: + c.TickEvent(ticker4Hz) + case <-t10Hz.C: + c.TickEvent(ticker10Hz) + case <-t25Hz.C: + c.TickEvent(ticker25Hz) + } + } +} + +func (c *Controller) SetActive(active bool) { + c.activeState.Store(active) +} + +func (c *Controller) TickEvent(t ticker) { + c.screens[c.activeScreen].Tick(t) +} + +func (c *Controller) Update() error { + if !c.activeState.Load() { + return nil + } + switch { + case inpututil.IsMouseButtonJustPressed(ebiten.MouseButton0): + c.btnPressed = time.Now() + case inpututil.IsMouseButtonJustReleased(ebiten.MouseButton0): + x, y := ebiten.CursorPosition() + c.interact(x, y, time.Since(c.btnPressed)) + c.btnPressed = time.Time{} + } + + for _, id := range inpututil.AppendJustPressedTouchIDs(make([]ebiten.TouchID, 0)) { + c.touchTapped[id] = time.Now() + } + + for id, v := range c.touchTapped { + if v.IsZero() || v.Add(time.Second*5).Before(time.Now()) { + delete(c.touchTapped, id) + } + } + + for _, id := range inpututil.AppendJustReleasedTouchIDs(make([]ebiten.TouchID, 0)) { + if v, ok := c.touchTapped[id]; ok { + x, y := inpututil.TouchPositionInPreviousTick(id) + c.interact(x, y, time.Since(v)) + } + delete(c.touchTapped, id) + } + + return nil +} + +func (c *Controller) Draw(screen *ebiten.Image) { + if !c.activeState.Load() { + return + } + screen.Fill(c.backgroundColor()) + + c.drawMatrixScreen(c.calcBounds(screen)) +} + +func (c *Controller) PrintDebug(msg string, line int) { + ebitenutil.DebugPrintAt(c.scr, msg, 0, c.scr.Bounds().Dy()-logLineH*(line+1)-12) +} + +func (c *Controller) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { + return outsideWidth, outsideHeight +} + +func (c *Controller) PlaySound() { + c.player.SetVolume(0.2) + c.player.SetPosition(0) + c.player.Play() +} + +func (c *Controller) interact(x, y int, t time.Duration) { + col := int(math.Floor(float64(x-c.screen.Min.X) / (float64(puzzleSymW) * (float64(c.screen.Bounds().Dx()) / float64(c.scr.Bounds().Dx()))))) + row := int(math.Floor(float64(y-c.screen.Min.Y) / (float64(puzzleSymH) * (float64(c.screen.Bounds().Dy()) / float64(c.scr.Bounds().Dy()))))) + + switch c.screens[c.activeScreen].Interact(c, col, row, t) { + case resultSwitchDebug: + c.screens[screenDebug].Activate() + fallthrough + case resultSwitchGame: + c.switchScreen(screenGame) + case resultSwitchForm: + c.switchScreen(screenForm) + default: + // NOOP + } +} + +func (c *Controller) switchScreen(s screen) { + c.btnPressed = time.Time{} + c.activeScreen = s + c.screens[c.activeScreen].Activate() +} + +func (c *Controller) Debug(format string, a ...any) { + msg := fmt.Sprintf(format, a...) + slog.Default().Debug(msg) + c.debugFn(msg) +} + +func (c *Controller) calcBounds(screen *ebiten.Image) *ebiten.Image { + bounds := screen.Bounds() + var rec = &image.Rectangle{Min: image.Point{}, Max: image.Point{bounds.Dx(), bounds.Dy()}} + + kefX := float64(puzzlePixX) / float64(puzzlePixY) + kefY := float64(puzzlePixY) / float64(puzzlePixX) + + screenX := math.Floor(kefX * float64(bounds.Dy())) + screenY := math.Floor(kefY * float64(bounds.Dx())) + + if int(screenX) > bounds.Dx() { + screenX = kefX * screenY + } + if int(screenY) > bounds.Dy() { + screenY = kefY * screenX + } + + rec.Min.X = (bounds.Dx() - int(screenX)) / 2 + rec.Max.X = bounds.Dx() - rec.Min.X + rec.Min.Y = (bounds.Dy() - int(screenY)) / 2 + rec.Max.Y = bounds.Dy() - rec.Min.Y + + c.screen = *rec + + return screen.SubImage(*rec).(*ebiten.Image) +} + +func (c *Controller) drawMatrixScreen(scr *ebiten.Image) { + c.scr.Clear() + + c.screens[c.activeScreen].Draw(c) + c.screens[screenDebug].Draw(c) // appears as overlay when active + + opts := &ebiten.DrawImageOptions{} + opts.GeoM.Scale(float64(scr.Bounds().Dx())/float64(c.scr.Bounds().Dx()), float64(scr.Bounds().Dy())/float64(c.scr.Bounds().Dy())) + opts.GeoM.Translate(float64(scr.Bounds().Min.X), float64(scr.Bounds().Min.Y)) + scr.DrawImage(c.scr, opts) +} + +func drawGameField(s Screen) { + s.Fill(image.Rect(0, 0, puzzleSymX, puzzleSymY), // header and all field + nil) // background: outside background (screen) + + s.Fill(image.Rect(1, 0, puzzleSymX-1, 2), // header only + color.RGBA{0x40, 0xA0, 0x40, 0xFF}) // background: yellow + + s.Fill(image.Rect(1, 2, puzzleSymX-1, puzzleSymY), // play field + color.RGBA{0, 0x00, 0xA0, 0xFF}) // background: ALWAYS same as tile + + s.Print(playFieldTemplate, // border + image.Point{0, 0}, + color.RGBA{0xFF, 0xFF, 0xFF, 0xFF}) // text: white +} + +func (c *Controller) backgroundColor() color.Color { + if c.bgColor == nil { + return defaultBgColor + } + return c.bgColor +} + +func (c *Controller) Fill(rect image.Rectangle, color color.Color) { + if color == nil { + color = c.backgroundColor() + } + (c.scr.SubImage(image.Rect( + rect.Min.X*puzzleSymW, + rect.Min.Y*puzzleSymH, + int(math.Min(float64(rect.Max.X), float64(puzzleSymX)))*puzzleSymW, + int(math.Min(float64(rect.Max.Y), float64(puzzleSymY)))*puzzleSymH))).(*ebiten.Image).Fill(color) +} + +func (c *Controller) Print(txt string, coord image.Point, clr color.Color) { + op := &text.DrawOptions{} + op.GeoM.Translate(float64(coord.X*puzzleSymW), float64(coord.Y*puzzleSymH)) + op.ColorScale.ScaleWithColor(clr) + op.LineSpacing = float64(puzzleSymH) + op.PrimaryAlign = text.AlignStart + op.SecondaryAlign = text.AlignStart + text.Draw(c.scr, txt, &text.GoTextFace{ + Source: c.font, + Size: float64(puzzleSymH), + }, op) +} + +func printHeader(s Screen, text string, align int) { + var p image.Point + l := utf8.RuneCountInString(text) + switch { + case align < 0: + p = image.Point{2, 1} + case align > 0: + p = image.Point{puzzleSymX - 2 - l, 1} + default: + p = image.Point{(puzzleSymX - l) / 2, 1} + } + s.Print(text, p, color.White) +} + +func (c *Controller) OnLoad(loadSec float64, bgColor, langCode string) { + c.InfoRequest() + c.SetLangCode(langCode) + c.SetBgColor(bgColor) +} + +func (c *Controller) SetLangCode(lc string) { + for _, s := range c.screens { + s.SetLang(l10nCode(lc)) + } +} + +func (c *Controller) SetBgColor(clr string) { + colorStr, err := normalize(clr) + if err != nil { + c.Debug(fmt.Sprintf("ERROR: %s", err)) + } else { + b, err := hex.DecodeString(colorStr) + switch { + case err != nil: + c.Debug(fmt.Sprintf("ERROR: %s", err)) + case c.bgColor == nil: + fallthrough + default: + c.bgColor = color.RGBA{b[0], b[1], b[2], b[3]} + } + } +} + +func normalize(colorStr string) (string, error) { + offset := 0 + if strings.HasPrefix(colorStr, "#") { + offset = 1 + } + b := colorStr[offset:] + if len(b) == 8 { + return b, nil + } + if len(b) != 6 { + return "", fmt.Errorf("normalize: not enough symbols: '%s'", colorStr) + } + return b + "FF", nil +} diff --git a/internal/puzzle/debug.go b/internal/puzzle/debug.go new file mode 100644 index 0000000..d69ba21 --- /dev/null +++ b/internal/puzzle/debug.go @@ -0,0 +1,35 @@ +package puzzle + +import "time" + +type debug struct { + idx int + debugMsg []string + active bool +} + +func newDebugOverlay() (*debug, func(string)) { + d := &debug{ + debugMsg: make([]string, 10), + } + return d, d.AddMessage +} + +func (d *debug) AddMessage(msg string) { + d.debugMsg[d.idx] = msg + d.idx = (d.idx + 1) % len(d.debugMsg) +} + +func (d *debug) Draw(s Screen) { + if !d.active || len(d.debugMsg) == 0 { + return + } + for i := range d.debugMsg { + s.PrintDebug(d.debugMsg[i], i) + } +} + +func (d *debug) Activate() { d.active = true } +func (d *debug) Interact(Audio, int, int, time.Duration) actionResult { return resultNone } +func (d *debug) Tick(ticker) {} +func (d *debug) SetLang(langCode) {} diff --git a/internal/puzzle/form.go b/internal/puzzle/form.go new file mode 100644 index 0000000..edf4dc5 --- /dev/null +++ b/internal/puzzle/form.go @@ -0,0 +1,113 @@ +package puzzle + +import ( + "15-puzzle/internal/model" + "fmt" + "image" + "image/color" + "strconv" + "sync/atomic" + "time" +) + +const ( + dials = 10 + codeInputTemplate = `[%s] [%s] [%s] [%s]` +) + +type form struct { + dials [dials]*button + request func(string) + requestSent bool + authFail bool + idx int + input [4]byte + mon atomic.Value +} + +func intFn(i int) func() int { return func() int { return i } } +func stringFn(s string) func() string { return func() string { return s } } +func codeBox(b byte) string { + if b > 0 { + return "*" + } + return "_" +} + +func newForm(request func(string)) *form { + col := func(i int) int { return (i%3)*puzzleTileSymW + 5 } + row := func(i int) int { return (i/3)*puzzleTileSymH + 3 } + f := &form{request: request} + for i := 1; i < dials; i++ { + f.dials[i] = NewButton(stringFn(strconv.Itoa(i)), intFn(col(i-1)), intFn(row(i-1))) + } + f.dials[0] = NewButton(stringFn("0"), intFn(col(10)), intFn(row(10))) + return f +} + +func (f *form) ApiMonitoringHandler(m model.Monitoring) { + f.mon.Store(m) + f.authFail = false +} + +func (f *form) Tick(t ticker) { + if t == ticker1Hz && f.requestSent { + f.authFail = f.mon.Load() == nil + } +} + +func (f *form) Draw(s Screen) { + drawGameField(s) + if v := f.mon.Load(); v != nil { + m := v.(model.Monitoring) + printHeader(s, "Usage Statistics", 0) + s.Print(fmt.Sprintf("Players: %d\nGames: %d\nSolved: %d", m.Users, m.GamesStarted, m.GamesSolved), + image.Point{3, 4}, + color.RGBA{0, 0xFF, 0xFF, 0xFF}) + } else { + r := image.Rect(8, 1, len(codeInputTemplate)+4, 2) + if f.authFail { + s.Fill(r, color.RGBA{0xFF, 0, 0, 0xFF}) + } else if f.idx == len(f.input) { + s.Fill(r, color.RGBA{0x80, 0x80, 0x80, 0xFF}) + } + printHeader(s, fmt.Sprintf(codeInputTemplate, codeBox(f.input[0]), codeBox(f.input[1]), codeBox(f.input[2]), codeBox(f.input[3])), 0) + for i := range f.dials { + f.dials[i].Draw(s) + } + } +} + +func (f *form) Interact(a Audio, col, row int, t time.Duration) actionResult { + if (image.Point{col, row}).In(image.Rect(2, 1, puzzleSymX-2, 2)) { + f.input = [4]byte{} + f.authFail = false + f.requestSent = false + f.idx = 0 + return resultSwitchGame + } + if f.mon.Load() == nil { + for i := range f.dials { + if f.dials[i].Interact(col, row) { + f.pressNextButton('0' + byte(i)) + break + } + } + } + return resultNone +} + +func (f *form) pressNextButton(digit byte) { + if f.idx >= len(f.input) { + return + } + f.input[f.idx] = digit + f.idx++ + if f.idx == len(f.input) { + f.request(string(f.input[:])) + f.requestSent = true + } +} + +func (f *form) Activate() {} +func (f *form) SetLang(langCode) {} diff --git a/internal/puzzle/handler.go b/internal/puzzle/handler.go new file mode 100644 index 0000000..58fbf43 --- /dev/null +++ b/internal/puzzle/handler.go @@ -0,0 +1,44 @@ +package puzzle + +import "15-puzzle/internal/model" + +func (c *Controller) ApiResponseHandler(u model.ApiResponse) { + switch { + case u.Err != nil: + c.ApiErrorHandler(*u.Err) + case u.Info != nil: + c.ApiInfoHandler(*u.Info) + case u.Stats != nil: + c.ApiStatsHandler(*u.Stats) + case u.Monitoring != nil: + c.ApiMonitoringHandler(*u.Monitoring) + } +} + +func (c *Controller) ApiInfoHandler(s model.Info) { + for _, h := range c.screens { + if i, ok := h.(interface{ ApiInfoHandler(model.Info) }); ok { + i.ApiInfoHandler(s) + } + } +} + +func (c *Controller) ApiStatsHandler(s model.Stats) { + for _, h := range c.screens { + if i, ok := h.(interface{ ApiStatsHandler(model.Stats) }); ok { + i.ApiStatsHandler(s) + } + } +} + +func (c *Controller) ApiMonitoringHandler(m model.Monitoring) { + for _, h := range c.screens { + if i, ok := h.(interface{ ApiMonitoringHandler(model.Monitoring) }); ok { + i.ApiMonitoringHandler(m) + } + } +} + +func (c *Controller) ApiErrorHandler(e string) { + c.Debug("api error: %s", e) +} diff --git a/internal/puzzle/l10n.go b/internal/puzzle/l10n.go new file mode 100644 index 0000000..876046a --- /dev/null +++ b/internal/puzzle/l10n.go @@ -0,0 +1,74 @@ +package puzzle + +type langCode string + +const ( + langCodeEn = "en" + langCodeRu = "ru" +) + +func l10nCode(lc string) langCode { + switch langCode(lc) { + case langCodeRu: + return langCodeRu + case langCodeEn: + fallthrough + default: + return langCodeEn + } +} + +func l10nGameTitle(lc langCode) string { + switch lc { + case langCodeRu: + return "Пятнашки" + case langCodeEn: + fallthrough + default: + return "15 Puzzle" + } +} + +func l10nSilent(lc langCode) string { + switch lc { + case langCodeRu: + return "Тишина" + case langCodeEn: + fallthrough + default: + return "Silent" + } +} + +func l10nMoves(lc langCode) string { + switch lc { + case langCodeRu: + return "Ходы" + case langCodeEn: + fallthrough + default: + return "Moves" + } +} + +func l10nRank(lc langCode) string { + switch lc { + case langCodeRu: + return "Рейтинг" + case langCodeEn: + fallthrough + default: + return "Rank" + } +} + +func l10nWins(lc langCode) string { + switch lc { + case langCodeRu: + return "Побед" + case langCodeEn: + fallthrough + default: + return "Wins" + } +} diff --git a/internal/puzzle/puzzle.go b/internal/puzzle/puzzle.go new file mode 100644 index 0000000..262b89e --- /dev/null +++ b/internal/puzzle/puzzle.go @@ -0,0 +1,295 @@ +package puzzle + +import ( + "15-puzzle/internal/model" + "errors" + "fmt" + "image" + "image/color" + "math/rand/v2" + "sync/atomic" + "time" +) + +const ( + tiles = 16 + fieldSymX = 29 + fieldSymY = 12 +) + +var ( + checkbox = map[bool]string{true: "[x]", false: "[ ]"} + frames = [...]byte{'|', '/', '-', '\\'} +) + +type game struct { + langCode langCode + tiles [tiles]*tile + moves int + solved bool + muted bool + onStart func() + onSolve func(int) + requestStats func() + + stats atomic.Value + blinkCoef []float64 + + blink [fieldSymX][fieldSymY]int + color [fieldSymX][fieldSymY]color.RGBA +} + +func newGame(onStart func(), onSolve func(int), request func()) *game { + p := &game{ + langCode: langCodeEn, + solved: true, + onStart: onStart, + onSolve: onSolve, + requestStats: request, + blinkCoef: []float64{1, .8, .6, .4, .2, 0, 0, .2, .4, .6, .8, 1}, + } + + for i := range tiles { + var pos int + if i == 0 { + pos = tiles - 1 + } else { + pos = i - 1 + } + p.tiles[i] = NewTile(i, pos) + } + + for x := range fieldSymX { + for y := range fieldSymY { + p.blink[x][y] = rand.IntN(len(frames)) + } + } + + p.requestStats() + + return p +} + +func (g *game) Tick(t ticker) { + if t == ticker10Hz && g.solved { + b := g.blinkCoef[0] + for i := 1; i < len(g.blinkCoef); i++ { + g.blinkCoef[i-1] = g.blinkCoef[i] + } + g.blinkCoef[len(g.blinkCoef)-1] = b + if g.showCongrats() { + for x := range fieldSymX { + for y := range fieldSymY { + g.blink[x][y] = (g.blink[x][y] + 1) % len(frames) + g.color[x][y] = color.RGBA{uint8(rand.IntN(256)), uint8(rand.IntN(256)), uint8(rand.IntN(256)), 0xFF} + } + } + } + } +} + +func (g *game) SetLang(lc langCode) { + g.langCode = lc +} + +func (g *game) ApiStatsHandler(s model.Stats) { + g.stats.Store(s) +} + +func (g *game) Draw(s Screen) { + drawGameField(s) + if g.isSolved() { + rating := l10nRank(g.langCode) + ": " + wins := l10nWins(g.langCode) + ": " + if !g.withStats(func(s model.Stats) { + if s.Rank > 0 && s.GamesSolved > 0 { + rating += fmt.Sprint(s.Rank) + } else { + rating = "" + } + wins += fmt.Sprint(s.GamesSolved) + }) { + rating, wins = "_", "_" + } + printHeader(s, rating, -1) + printHeader(s, wins, 1) + } else { + printHeader(s, fmt.Sprintf(l10nSilent(g.langCode)+" %s", checkbox[g.muted]), 1) + if !g.muted { + printHeader(s, fmt.Sprintf(l10nMoves(g.langCode)+": %d", g.moves), -1) + } + } + + if g.showCongrats() { + for x := range fieldSymX { + for y := range fieldSymY { + s.Print(string(frames[g.blink[x][y]]), image.Point{x + 1, y + 3}, g.color[x][y]) + } + } + return + } + + boardBottomColor := color.Black + // boardBottomColor := color.RGBA{0xFF, 0x00, 0xFF, 0xFF} + + for i := range tiles { + var tileBackground color.Color = color.RGBA{0, 0, 0xA0, 0xFF} // background: 0x0000A0 (lighter) / 0x00006B (darker) + var tileForeground color.Color = color.RGBA{0, 0xFF, 0xFF, 0xFF} // text: cyan 0x00FFFF + fillRect := image.Rect(g.tiles[i].X(), g.tiles[i].Y(), g.tiles[i].X()+puzzleTileSymW, g.tiles[i].Y()+puzzleTileSymH) + if i == 0 { + if g.solved { + cr, cg, cb, ca := tileForeground.RGBA() + tileForeground = color.RGBA{byte(g.blinkCoef[0] * float64(cr)), + byte(g.blinkCoef[0] * float64(cg)), + byte(g.blinkCoef[0] * float64(cb)), + byte(g.blinkCoef[0] * float64(ca))} + } else { + tileBackground = boardBottomColor + fillRect.Max.X-- + } + } + if g.tiles[i].moving { + // fillRect contains unshifted yet coordinates, time to fix font gallucinations when animating adjacent tiles + fillRect.Max.X-- // right border: skip last column to justify on move + s.Fill(fillRect, boardBottomColor) // partial "board bottom" from both sides + fillRect.Max.X++ // right border: restore + fillRect.Min.X-- // left border: extend to 1 col left to justify on move + } + s.Fill(fillRect, tileBackground) + if i == 0 && g.solved || i > 0 { + s.Print(tileTemplate.Format(g.tiles[i].Title()), image.Point{g.tiles[i].X(), g.tiles[i].Y()}, tileForeground) + } + } +} + +func (g *game) Interact(a Audio, col, row int, t time.Duration) (result actionResult) { + result = resultNone + if !g.isSolved() && row == 1 && col < puzzleSymX-2 && col > puzzleSymX-13 { + g.muted = !g.muted + return + } + if g.showCongrats() { + g.moves = 0 + return + } + for i := range tiles { + if g.tiles[i].CanInteract(col, row) { + if g.tiles[i].num == 0 && t > time.Second*3 { + return resultSwitchForm + } + if g.press(a, g.tiles[i]) { + break + } + } + } + return +} + +func (g *game) shuffle() { + rand.Shuffle(tiles, func(i, j int) { g.tiles[i].pos, g.tiles[j].pos = g.tiles[j].pos, g.tiles[i].pos }) + var puzzle [tiles]byte + for i := range g.tiles { + puzzle[i] = byte(g.tiles[i].pos) + } + if solvable, err := IsSolvable(puzzle); err == nil && !solvable { + g.tiles[1].pos, g.tiles[2].pos = g.tiles[2].pos, g.tiles[1].pos + } + g.moves = 0 + g.solved = g.isSolved() +} + +func (g *game) press(a Audio, t *tile) bool { + if g.solved { + if g.tiles[0].num == t.num { + g.shuffle() + } + return true + } + exchange := func() { t.pos, g.tiles[0].pos = g.tiles[0].pos, t.pos; g.onMove(a) } + switch { + case t.Col() != g.tiles[0].Col() && t.Row() != g.tiles[0].Row(): + return false + case t.Col() == g.tiles[0].Col() && g.tiles[0].Row()-t.Row() == 1: + t.MoveDown(exchange) + case t.Col() == g.tiles[0].Col() && t.Row()-g.tiles[0].Row() == 1: + t.MoveUp(exchange) + case t.Row() == g.tiles[0].Row() && g.tiles[0].Col()-t.Col() == 1: + t.MoveRight(exchange) + case t.Row() == g.tiles[0].Row() && t.Col()-g.tiles[0].Col() == 1: + t.MoveLeft(exchange) + } + return true +} + +func (g *game) onMove(a Audio) { + if !g.muted { + a.PlaySound() + } + if g.moves == 0 { + g.onStart() + } + g.moves++ + solved := g.isSolved() + if solved && !g.solved { + g.onSolve(g.moves) + } + g.solved = solved +} + +func (g *game) isSolved() bool { + for i := 1; i < tiles; i++ { + if g.tiles[i].pos != i-1 { + return false + } + } + return g.moves > 0 +} + +func (g *game) isTopRated() bool { + firstPlace := false + firstPlace = g.withStats(func(s model.Stats) { firstPlace = s.Rank == 1 }) + return firstPlace +} + +func (g *game) showCongrats() bool { + return g.isSolved() && g.isTopRated() +} + +func (g *game) withStats(consumer func(model.Stats)) bool { + if stats := g.stats.Load(); stats != nil { + consumer(stats.(model.Stats)) + return true + } + return false +} + +func (g *game) Activate() {} + +func IsSolvable(puzzle [tiles]byte) (bool, error) { + var x *int + for i := range puzzle { + if puzzle[i] == 0 { + if x != nil { + return false, errors.New("only one blank position allowed") + } + x = &i + } + } + if x == nil { + return false, errors.New("blank position required to be set") + } + blank := *x + invCount := 0 + for i := range tiles - 1 { + for j := i + 1; j < tiles; j++ { + if puzzle[j] > 0 && puzzle[i] > 0 && puzzle[i] > puzzle[j] { + invCount += 1 + } + } + } + if (blank/4+1)%2 == 0 { + return invCount%2 == 0, nil + } else { + return invCount%2 == 1, nil + } +} diff --git a/internal/puzzle/puzzle_test.go b/internal/puzzle/puzzle_test.go new file mode 100644 index 0000000..f3af9c1 --- /dev/null +++ b/internal/puzzle/puzzle_test.go @@ -0,0 +1,58 @@ +package puzzle_test + +import ( + "15-puzzle/internal/puzzle" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsSolvable(t *testing.T) { + table := []struct { + puzzle [16]byte + solvable bool + }{ + {[16]byte{ + 13, 2, 10, 3, + 1, 12, 8, 4, + 5, 0, 9, 6, + 15, 14, 11, 7}, + true}, + {[16]byte{ + 6, 13, 7, 10, + 8, 9, 11, 0, + 15, 2, 12, 5, + 14, 3, 1, 4}, + true}, + {[16]byte{ + 12, 1, 10, 2, + 7, 11, 4, 14, + 5, 0, 9, 15, + 8, 13, 6, 3}, + true}, + {[16]byte{ // not solvable permutation + 3, 9, 1, 15, + 14, 11, 4, 6, + 13, 0, 10, 12, + 2, 7, 8, 5}, + false}, + {[16]byte{ // same as before but tiles 1 and 2 are switched + 3, 9, 2, 15, + 14, 11, 4, 6, + 13, 0, 10, 12, + 1, 7, 8, 5}, + true}, + } + for i := range table { + t.Run(fmt.Sprintf("case_%d", i), func(t *testing.T) { + solvable, err := puzzle.IsSolvable(table[i].puzzle) + assert.NoError(t, err) + assert.Equal(t, table[i].solvable, solvable) + }) + } + _, err := puzzle.IsSolvable([16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 11, 13, 14, 15, 16}) + assert.Error(t, err) + _, err = puzzle.IsSolvable([16]byte{0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0}) + assert.Error(t, err) +} diff --git a/internal/puzzle/splash.go b/internal/puzzle/splash.go new file mode 100644 index 0000000..c22aba1 --- /dev/null +++ b/internal/puzzle/splash.go @@ -0,0 +1,80 @@ +package puzzle + +import ( + "15-puzzle/internal/model" + "image" + "image/color" + "sync/atomic" + "time" +) + +const ( + gopherX = 8 + gopherY = 1 +) + +type splash struct { + langCode langCode + urlOpener func(string) + gopherDx int + info atomic.Value +} + +func newSplash(urlOpener func(string)) *splash { + f := &splash{langCode: langCodeEn, urlOpener: urlOpener, gopherDx: 22} + return f +} + +func (sp *splash) SetLang(lc langCode) { + sp.langCode = lc +} + +func (sp *splash) Tick(t ticker) { + if t == ticker25Hz && sp.gopherDx > 0 { + sp.gopherDx-- + } +} + +func (sp *splash) ApiInfoHandler(i model.Info) { + sp.info.Store(i) +} + +func (sp *splash) Interact(a Audio, col, row int, t time.Duration) actionResult { + if sp.gopherDx > 0 { + return resultNone + } + if info := sp.info.Load(); info != nil && row == puzzleSymY-2 && col < puzzleSymX-2 && col > puzzleSymX-9 { + sp.urlOpener(info.(model.Info).ProjectLink) + return resultNone + } + if t > time.Second*10 { + return resultSwitchDebug + } + return resultSwitchGame +} + +func (sp *splash) Draw(s Screen) { + s.Fill(image.Rect(0, 0, puzzleSymX, puzzleSymY), color.RGBA{0x0A, 0x23, 0x4E, 0xFF}) + sp.drawGopher(s) + s.Print(splashScreenTemplate, image.Point{0, 0}, color.RGBA{0x80, 0x80, 0x80, 0xFF}) + s.Print(`"`+l10nGameTitle(sp.langCode)+`"`, image.Point{1, 1}, color.White) + sp.drawProjectLink(s) +} + +func (sp *splash) drawGopher(s Screen) { + x := gopherX + sp.gopherDx + s.Print(gopherCyan, image.Point{x, gopherY}, color.RGBA{0x00, 0xFF, 0xFF, 0xFF}) + s.Print(gopherWhite, image.Point{x, gopherY}, color.White) + s.Print(gopherBeige, image.Point{x, gopherY}, color.RGBA{0xF4, 0xB3, 0x6D, 0xFF}) + s.Print(gopherGray, image.Point{x, gopherY}, color.RGBA{0x80, 0x80, 0x80, 0xFF}) + s.Fill(image.Rect(puzzleSymX-1, 0, puzzleSymX, puzzleSymY), color.RGBA{0x0A, 0x23, 0x4E, 0xFF}) +} + +func (sp *splash) drawProjectLink(s Screen) { + if sp.info.Load() != nil { + s.Print("______", image.Point{puzzleSymX - 8, puzzleSymY - 2}, color.RGBA{0x00, 0xFF, 0xFF, 0xFF}) + } + s.Print("GitHub", image.Point{puzzleSymX - 8, puzzleSymY - 2}, color.RGBA{0xFB, 0xE6, 0x8E, 0xFF}) +} + +func (sp *splash) Activate() {} diff --git a/internal/puzzle/tile.go b/internal/puzzle/tile.go new file mode 100644 index 0000000..d29641b --- /dev/null +++ b/internal/puzzle/tile.go @@ -0,0 +1,139 @@ +package puzzle + +import ( + "fmt" + "image" + "image/color" + "math" + "strconv" + "time" +) + +type tileFormatter string + +func (t tileFormatter) Format(c string) string { + return fmt.Sprintf(string(t), c[:int(math.Min(float64(len(c)), 2))]) +} + +const tileTemplate tileFormatter = `╔════╗ +║ %2s ║ +╚════╝` + +type button struct { + title func() string + x, y func() int +} + +func (t *button) Interact(x, y int) bool { + if (image.Point{x, y}).In(image.Rect(t.x(), t.y(), t.x()+puzzleTileSymW, t.y()+puzzleTileSymH)) { + t.onTrigger() + return true + } + return false +} + +func (t *button) Title() string { + return t.title() +} + +func (t *button) Draw(s Screen) { + s.Print(tileTemplate.Format(t.Title()), + image.Point{t.x(), t.y()}, + color.RGBA{0, 0xFF, 0xFF, 0xFF}, + ) +} + +func (t *button) onTrigger() { +} + +func NewButton(title func() string, x, y func() int) *button { + return &button{ + title: title, + x: x, + y: y, + } +} + +type tile struct { + button + num int + pos int + + moving bool + dx, dy float64 +} + +func NewTile(num, pos int) *tile { + t := &tile{ + num: num, + pos: pos, + } + t.button = *NewButton(t.title, t.X, t.Y) + return t +} + +func (t *tile) Title() string { + if t.num == 0 { + return "Go" + } + return strconv.Itoa(t.num) +} + +func (t *tile) CanInteract(x, y int) bool { + return !t.moving && t.button.Interact(x, y) +} + +func (t *tile) MoveUp(finish func()) { + t.shift(&t.dy, -1, finish) +} + +func (t *tile) MoveDown(finish func()) { + t.shift(&t.dy, 1, finish) +} + +func (t *tile) MoveLeft(finish func()) { + t.shift(&t.dx, -1, finish) +} + +func (t *tile) MoveRight(finish func()) { + t.shift(&t.dx, 1, finish) +} + +func (t *tile) shift(src *float64, target float64, finish func()) { + if t.moving { + return + } + t.moving = true + *src = 0 + go t.shifter(src, target, func() { finish(); t.moving = false }) +} + +func (t *tile) shifter(src *float64, target float64, finish func()) { + step := target / 10 + ticker := time.NewTicker(time.Millisecond * 10) + for { + <-ticker.C + *src += step + if math.Abs(*src) >= math.Abs(target) { + *src = 0 + break + } + } + finish() +} + +func (t *tile) Col() int { + return t.pos % 4 +} + +func (t *tile) Row() int { + return t.pos / 4 +} + +func (t *tile) X() int { + return t.Col()*puzzleTileSymW + int(t.dx*puzzleTileSymW) + 2 +} + +func (t *tile) Y() int { + return t.Row()*puzzleTileSymH + int(t.dy*puzzleTileSymH) + 3 +} diff --git a/internal/puzzle/url.go b/internal/puzzle/url.go new file mode 100644 index 0000000..8396d02 --- /dev/null +++ b/internal/puzzle/url.go @@ -0,0 +1,24 @@ +package puzzle + +import ( + "os/exec" + "runtime" +) + +func openURL(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "rundll32" + args = []string{"url.dll,FileProtocolHandler", url} + case "darwin": + cmd = "open" + args = []string{url} + default: + cmd = "xdg-open" + args = []string{url} + } + return exec.Command(cmd, args...).Start() +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..1a32a89 --- /dev/null +++ b/internal/repo/repo.go @@ -0,0 +1,195 @@ +package repo + +import ( + "15-puzzle/internal/model" + "cmp" + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path" + "slices" + "sync" + "time" +) + +type FileRepo struct { + dataFile string + latch sync.RWMutex + data *model.Data +} + +func NewFileRepo(ctx context.Context, dataFile string) (*FileRepo, error) { + dataFile = path.Clean(dataFile) + r := &FileRepo{ + dataFile: dataFile, + data: &model.Data{Users: make(map[int]model.User)}, + } + + b, err := os.ReadFile(dataFile) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + if len(b) == 0 { + // init with empty writeable file + if err := r.withData(func(d *model.Data) {}); err != nil { + return nil, err + } + return r, nil + } + + if err := json.Unmarshal(b, &r.data); err != nil { + return nil, err + } + + return r, nil +} + +func (r *FileRepo) Monitoring() (model.Monitoring, error) { + r.latch.RLock() + defer r.latch.RUnlock() + + m := &model.Monitoring{Users: len(r.data.Users)} + for i := range r.data.Users { + m.GamesStarted += r.data.Users[i].GamesStarted + m.GamesSolved += r.data.Users[i].GamesSolved + } + return *m, nil +} + +func (r *FileRepo) Stats(UserID int) (model.User, error) { + r.latch.RLock() + defer r.latch.RUnlock() + + user, ok := r.data.Users[UserID] + if !ok { + return model.User{}, fmt.Errorf("user not found: user_id=%d", UserID) + } + return user, nil +} + +func (r *FileRepo) AddUser(UserID int) error { + if err := r.withUser(UserID, func(*model.User) {}); err != nil { + return err + } + return nil +} + +func (r *FileRepo) RegisterGameStart(UserID int) (model.User, error) { + var result model.User + if err := r.withUser(UserID, func(u *model.User) { + u.GamesStarted++ + ts := model.JSONTimestamp(time.Now().UTC()) + u.LastStartTime = &ts + result = *u + }); err != nil { + return result, err + } + return result, nil +} + +func (r *FileRepo) RegisterGameSolve(UserID, moves int) (model.User, error) { + var result model.User + if err := r.withUser(UserID, func(u *model.User) { + u.GamesSolved++ + if u.LastStartTime != nil { + moveAverage := float32(time.Since(time.Time(*u.LastStartTime)).Seconds() / float64(moves)) + if u.BestResult == nil || moveAverage < *u.BestResult { + ts := model.JSONTimestamp(time.Now().UTC()) + u.BestSolveTime = &ts + u.BestResult = &moveAverage + u.LastStartTime = nil + } + } + result = *u + }); err != nil { + return result, err + } + return result, nil +} + +func (r *FileRepo) Rating() []int { + r.latch.RLock() + defer r.latch.RUnlock() + + return slices.SortedFunc(maps.Keys(r.data.Users), func(a, b int) int { return SortRating(r.data.Users[a], r.data.Users[b]) }) +} + +func SortRating(a, b model.User) int { + return cmp.Or( + compareBest(a.BestResult, b.BestResult), // lower value is higher + compareTs(a.BestSolveTime, b.BestSolveTime), // earlier value is higher + cmp.Compare(b.GamesSolved, a.GamesSolved), // greater value is higher + cmp.Compare(a.UserID, b.UserID), // sadly, compare user ids when nobody has solved just once + ) +} + +func compareBest(a, b *float32) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return +1 + case b == nil: + return -1 + default: + return cmp.Compare(*a, *b) + } +} + +func compareTs(a, b *model.JSONTimestamp) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return +1 + case b == nil: + return -1 + default: + return time.Time(*a).Compare(time.Time(*b)) + } +} + +func (r *FileRepo) withUser(userID int, acceptor func(d *model.User)) error { + return r.withData(func(d *model.Data) { + user, ok := d.Users[userID] + if !ok { + user = model.User{UserID: userID} + } + acceptor(&user) + d.Users[userID] = user + }) +} + +func (r *FileRepo) withData(acceptor func(d *model.Data)) error { + r.latch.Lock() + defer r.latch.Unlock() + + acceptor(r.data) + + b, err := json.Marshal(r.data) + if err != nil { + return fmt.Errorf("data marshall: %s", err) + } + repoDir := path.Dir(r.dataFile) + tf, err := os.CreateTemp(repoDir, "15-puzzle") + if err != nil { + return fmt.Errorf("create temp file at %s: %s", repoDir, err) + } + + if _, err := tf.Write(b); err != nil { + return fmt.Errorf("write %s: %s", tf.Name(), err) + } + + if err := tf.Close(); err != nil { + return fmt.Errorf("close %s to %s: %s", tf.Name(), r.dataFile, err) + } + + if err := os.Rename(tf.Name(), r.dataFile); err != nil { + return fmt.Errorf("rename %s to %s: %s", tf.Name(), r.dataFile, err) + } + + return nil +} diff --git a/internal/repo/repo_test.go b/internal/repo/repo_test.go new file mode 100644 index 0000000..4e5cb96 --- /dev/null +++ b/internal/repo/repo_test.go @@ -0,0 +1,154 @@ +package repo_test + +import ( + "15-puzzle/internal/model" + "15-puzzle/internal/repo" + "context" + "maps" + "math/rand/v2" + "os" + "slices" + "testing" + "time" +) + +func ref(t time.Time) *model.JSONTimestamp { + if t.IsZero() { + return nil + } + r := model.JSONTimestamp(t) + return &r +} + +func TestSortRating(t *testing.T) { + fp := func(v float32) *float32 { return &v } + users := map[int]model.User{ + 1: {UserID: 1, BestResult: fp(5), GamesStarted: 7, GamesSolved: 5, BestSolveTime: ref(time.Now().Add(-time.Second * 3))}, + 2: {UserID: 2, BestResult: fp(5), GamesStarted: 7, GamesSolved: 4, BestSolveTime: ref(time.Now().Add(-time.Second * 3))}, + 3: {UserID: 3, BestResult: fp(5), GamesStarted: 7, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 3))}, + 4: {UserID: 4, BestResult: fp(5), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 2))}, + 5: {UserID: 5, BestResult: fp(5), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 1))}, + 6: {UserID: 6, BestResult: fp(6), GamesStarted: 4, GamesSolved: 3, BestSolveTime: ref(time.Now().Add(-time.Second * 1))}, + 7: {UserID: 7, BestResult: fp(7), GamesStarted: 3, GamesSolved: 0, BestSolveTime: ref(time.Time{})}, + } + keys := slices.Collect(maps.Keys(users)) + rand.Shuffle(len(keys), func(i, j int) { keys[i], keys[j] = keys[j], keys[i] }) + result := slices.SortedFunc(slices.Values(keys), func(a, b int) int { return repo.SortRating(users[a], users[b]) }) + expected := []int{1, 2, 3, 4, 5, 6, 7} + if slices.Compare(expected, result) != 0 { + t.Errorf("rating not correct\nexpected: %v\nactual: %v", expected, result) + } +} + +func TestRepo(t *testing.T) { + testWithNewRepo(t, func(t *testing.T, r *repo.FileRepo) { assertRating(t, []int{}, r) }) + testWithNewRepo(t, testPlayersAndRatings) +} + +func testWithNewRepo(t *testing.T, test func(t *testing.T, r *repo.FileRepo)) { + f, err := os.CreateTemp("", "puzzle15-repo-test") + if err != nil { + t.Fatalf("temporary file create: %s", err) + } + if err := f.Close(); err != nil { + t.Fatalf("temporary file close: %s", err) + } + if err := os.Remove(f.Name()); err != nil { + t.Fatalf("temporary file remove before: %s", err) + } + defer func() { + if err := os.Remove(f.Name()); err != nil { + t.Errorf("temporary file remove after: %s", err) + } + }() + + testWithRepo(t, f.Name(), test) +} + +func testWithRepo(t *testing.T, file string, test func(t *testing.T, r *repo.FileRepo)) { + r, err := repo.NewFileRepo(context.Background(), file) + if err != nil { + t.Fatalf("repo init: %s", err) + } + + test(t, r) +} + +func testPlayersAndRatings(t *testing.T, r *repo.FileRepo) { + assertRating(t, []int{}, r) + + testUserOne := 1 + assertRegisterGameStart(t, testUserOne, r, + model.User{UserID: testUserOne, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})}) + assertRating(t, []int{testUserOne}, r) + + testUserTwo := 2 + assertRegisterGameStart(t, testUserTwo, r, + model.User{UserID: testUserTwo, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})}) + assertRating(t, []int{testUserOne, testUserTwo}, r) + + testUserThree := 3 + assertRegisterGameStart(t, testUserThree, r, + model.User{UserID: testUserThree, GamesStarted: 1, GamesSolved: 0, BestSolveTime: ref(time.Time{})}) + assertRegisterGameSolve(t, testUserThree, 30, r, + model.User{UserID: testUserThree, GamesStarted: 1, GamesSolved: 1, BestSolveTime: ref(time.Now())}) + assertRating(t, []int{testUserThree, testUserOne, testUserTwo}, r) + + assertRegisterGameSolve(t, testUserTwo, 20, r, + model.User{UserID: testUserTwo, GamesStarted: 1, GamesSolved: 1, BestSolveTime: ref(time.Now())}) + assertRating(t, []int{testUserThree, testUserTwo, testUserOne}, r) + + assertRegisterGameStart(t, testUserTwo, r, + model.User{UserID: testUserTwo, GamesStarted: 2, GamesSolved: 1, BestSolveTime: ref(time.Now())}) + assertRegisterGameSolve(t, testUserTwo, 10, r, + model.User{UserID: testUserTwo, GamesStarted: 2, GamesSolved: 2, BestSolveTime: ref(time.Now())}) + assertRating(t, []int{testUserThree, testUserTwo, testUserOne}, r) +} + +func assertUserHaveValues(t *testing.T, expected, actual model.User) { + if expected.UserID != actual.UserID { + t.Errorf("expect UserID=%d, actual: %d", expected.UserID, actual.UserID) + } + if expected.GamesStarted != actual.GamesStarted { + t.Errorf("expect GamesStarted=%d, actual: %d", expected.GamesStarted, actual.GamesStarted) + } + if expected.GamesSolved != actual.GamesSolved { + t.Errorf("expect GamesSolved=%d, actual: %d", expected.GamesSolved, actual.GamesSolved) + } + expectedBst := time.Time{} + if expected.BestSolveTime != nil { + expectedBst = time.Time(*expected.BestSolveTime) + } + actualLst := time.Time{} + if actual.BestSolveTime != nil { + actualLst = time.Time(*actual.BestSolveTime) + } + var delta time.Duration = time.Millisecond * 20 + if actualLst.After(expectedBst.Add(delta*time.Millisecond)) || + actualLst.Before(expectedBst.Add(-delta*time.Millisecond)) { + t.Errorf("expect BestSolveTime=%v, actual: %v, delta: %d", expectedBst, actualLst, delta) + } +} + +func assertRegisterGameStart(t *testing.T, UserID int, r *repo.FileRepo, expected model.User) { + u, err := r.RegisterGameStart(UserID) + if err != nil { + t.Fatalf("RegisterGameStart: %s", err) + } + assertUserHaveValues(t, expected, u) +} + +func assertRegisterGameSolve(t *testing.T, UserID, moves int, r *repo.FileRepo, expected model.User) { + u, err := r.RegisterGameSolve(UserID, moves) + if err != nil { + t.Fatalf("RegisterGameSolve: %s", err) + } + assertUserHaveValues(t, expected, u) +} + +func assertRating(t *testing.T, expected []int, r *repo.FileRepo) { + actual := r.Rating() + if slices.Compare(expected, actual) != 0 { + t.Errorf("get rating: wrong value\nexpected: %#v\nactual: %#v", expected, actual) + } +} diff --git a/internal/tgbot/tgbot.go b/internal/tgbot/tgbot.go new file mode 100644 index 0000000..fb5ff26 --- /dev/null +++ b/internal/tgbot/tgbot.go @@ -0,0 +1,45 @@ +package tgbot + +import ( + "context" + "fmt" + "log/slog" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +type tgBot struct { + ctx context.Context + b *bot.Bot + log *slog.Logger +} + +func NewTgBot(ctx context.Context, token string) (*tgBot, error) { + var err error + + tgBot := &tgBot{ + ctx: ctx, + log: slog.Default(), + } + + tgBot.b, err = bot.New( + token, + bot.WithDefaultHandler(tgBot.updateHandler), + ) + if err != nil { + return tgBot, err + } + + return tgBot, nil +} + +func (b *tgBot) Start() { + go b.b.Start(b.ctx) +} + +func (b *tgBot) updateHandler(ctx context.Context, _ *bot.Bot, update *models.Update) { + if update != nil { + b.log.Info(fmt.Sprintf("update:\n%#v\n", *update)) + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go new file mode 100644 index 0000000..215f5c1 --- /dev/null +++ b/internal/validator/validator.go @@ -0,0 +1,67 @@ +package validator + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "net/url" + "slices" + "strings" +) + +// ValidUser returns validation result of initData with key used for hashing. +// If validation was successfull then value of boolean is true and initData's user.id value is returned as int. +// Otherwise, including when error is not nil, the returned boolean will be false and int will be zero. +// +// Implemented according to https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app +func ValidUser(key []byte, initData string) (bool, int, error) { + m, err := url.ParseQuery(initData) + if err != nil { + return false, 0, fmt.Errorf("parse init data %q: %s", initData, err) + } + var hash []byte + if v, ok := m["hash"]; ok && len(v) > 0 { + hash, err = hex.DecodeString(v[0]) + if err != nil { + return false, 0, fmt.Errorf("decode original hash %q: %s", v[0], err) + } + delete(m, "hash") + } else { + return false, 0, nil + } + + keys := slices.Collect(maps.Keys(m)) + slices.Sort(keys) + + u := new(struct { + ID *int `json:"id"` + }) + fields := make([]string, 0) + for _, k := range keys { + if v, ok := m[k]; ok && len(v) == 1 { + if k == "user" { + if err := json.Unmarshal([]byte(v[0]), &u); err != nil { + return false, 0, fmt.Errorf("unmarshall user data %s: %s", v[0], err) + } + if u.ID == nil { + return false, 0, fmt.Errorf("user.id field not set: %s: ", v[0]) + } + } + fields = append(fields, k+"="+v[0]) + } + } + if slices.Equal(EncodeHmacSha256([]byte(strings.Join(fields, "\n")), key), hash) { + return true, *u.ID, nil + } + + return false, 0, nil +} + +func EncodeHmacSha256(data, key []byte) []byte { + sig := hmac.New(sha256.New, key) + sig.Write(data) + return sig.Sum(nil) +} diff --git a/internal/validator/validator_test.go b/internal/validator/validator_test.go new file mode 100644 index 0000000..73deb0f --- /dev/null +++ b/internal/validator/validator_test.go @@ -0,0 +1,28 @@ +package validator_test + +import ( + "15-puzzle/internal/validator" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + botToken = "example:token" + initData = "auth_date=269666017&chat_instance=6039284203686499081&chat_type=sender&hash=40cde8dc7250ee616cd7d7a090749a9a42cc68f018c969fcb48fdf5e62657ad6&signature=FF5oTJSnmxdqgtNozxsLywXyVKdssh_DbvksGUaQuhkMiRfp10HJmf5o88uokPpqF4yhpHbX1c8uLbrKUuUdAA&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Ilia%22%2C%22id%22%3A303133707%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22Denisov%22%2C%22photo_url%22%3A%22https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%22%7D" + userId = 303133707 +) + +func TestValidUser(t *testing.T) { + valid, id, err := validator.ValidUser(validator.EncodeHmacSha256([]byte(botToken), []byte("WebAppData")), initData) + assert.NoError(t, err) + assert.True(t, valid) + assert.Equal(t, userId, id) + + valid, _, err = validator.ValidUser(validator.EncodeHmacSha256([]byte(botToken), []byte("Web_AppData")), initData) + assert.NoError(t, err) + assert.False(t, valid) + + _, _, err = validator.ValidUser(validator.EncodeHmacSha256([]byte(botToken), []byte("WebAppData")), "param;=value&") + assert.Error(t, err) +} diff --git a/internal/web-service/handler/handler.go b/internal/web-service/handler/handler.go new file mode 100644 index 0000000..e8d1035 --- /dev/null +++ b/internal/web-service/handler/handler.go @@ -0,0 +1,182 @@ +package handler + +import ( + "15-puzzle/internal/model" + "15-puzzle/internal/validator" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "path" + "path/filepath" + "strconv" + "strings" +) + +type ctxUserID string + +const ( + WebAppInitDataHeader = "Web-App-Init-Data" + WebAppExtraCodeHeader = "Web-App-Extra-Code" + WebAppHtmlFile = "tgwebapp.html" + ctxDataUserID ctxUserID = "user_id" +) + +type Repository interface { + RegisterGameStart(UserID int) (model.User, error) + RegisterGameSolve(UserID, moves int) (model.User, error) + Stats(UserID int) (model.User, error) + Monitoring() (model.Monitoring, error) + Rating() []int +} + +func NewHandler(repo Repository, token, code, ctxRoot, staticDir, projectLink string) http.Handler { + mux := http.NewServeMux() + + if abs, err := filepath.Abs(staticDir); err == nil { + staticDir = abs + } + + mux.Handle(http.MethodGet+" /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir)))) + mux.Handle(http.MethodGet+" /puzzle.html", staticFileHandler(path.Join(staticDir, WebAppHtmlFile))) + + apiMux := http.NewServeMux() + apiMux.Handle(http.MethodGet+" /info", apiInfoHandler(model.Info{ProjectLink: projectLink})) + apiMux.Handle(http.MethodPut+" /start", apiStartHandler(repo)) + apiMux.Handle(http.MethodPut+" /solve", apiSolveHandler(repo)) + apiMux.Handle(http.MethodGet+" /stats", apiStatsHandler(repo)) + apiMux.Handle(http.MethodGet+" /monitoring", apiMonitoringHandler(repo, code)) + apiKey := validator.EncodeHmacSha256([]byte(token), []byte("WebAppData")) + mux.Handle("/api/", authHandler(apiKey, http.StripPrefix("/api", apiMux))) + + root := http.NewServeMux() + root.Handle(strings.TrimRight(ctxRoot, "/")+"/", http.StripPrefix(strings.TrimRight(ctxRoot, "/"), mux)) + + return root +} + +func staticFileHandler(name string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, name) + }) +} + +func authHandler(apiKey []byte, h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := apiKey + if v, ok := r.Header[WebAppInitDataHeader]; !ok || len(v) != 1 { + w.WriteHeader(http.StatusUnauthorized) + return + } else if ok, userID, err := validator.ValidUser(key, v[0]); err != nil { + slog.Error(fmt.Sprintf("validator: %s", err)) + w.WriteHeader(http.StatusInternalServerError) + return + } else if !ok { + w.WriteHeader(http.StatusUnauthorized) + return + } else { + h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxDataUserID, userID))) + } + }) +} + +func apiInfoHandler(i model.Info) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeResponse(w, model.ApiResponse{Info: &i}) + }) +} + +func apiStartHandler(repo Repository) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respond(w, r, repo.Rating, repo.RegisterGameStart) + }) +} + +func apiSolveHandler(repo Repository) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + v := r.URL.Query().Get("moves") + if moves, err := strconv.Atoi(r.URL.Query().Get("moves")); err != nil { + errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid value: %q", v)) + } else { + respond(w, r, repo.Rating, func(u int) (model.User, error) { return repo.RegisterGameSolve(u, moves) }) + } + }) +} + +func apiStatsHandler(repo Repository) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + respond(w, r, repo.Rating, repo.Stats) + }) +} + +func apiMonitoringHandler(repo Repository, code string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if code == "" || code != r.Header.Get(WebAppExtraCodeHeader) { + w.WriteHeader(http.StatusForbidden) + return + } + m, err := repo.Monitoring() + if err != nil { + errorResponse(w, http.StatusInternalServerError, fmt.Errorf("fetch monitoring: %s", err)) + return + } + writeResponse(w, model.ApiResponse{Monitoring: &m}) + }) +} + +func respond(w http.ResponseWriter, r *http.Request, rating func() []int, action func(int) (model.User, error)) { + userID, ok := r.Context().Value(ctxDataUserID).(int) + if !ok { + errorResponse(w, http.StatusInternalServerError, fmt.Errorf("unsupported context value type: %T", userID)) + return + } + + u, err := action(userID) + if err != nil { + if u.UserID == 0 { + errorResponse(w, http.StatusNotFound, fmt.Errorf("user_id=%d not found", userID)) + } else { + errorResponse(w, http.StatusInternalServerError, fmt.Errorf("user_id=%d game action: %s", userID, err)) + } + return + } + + stats := &model.Stats{ + GamesStarted: u.GamesStarted, + GamesSolved: u.GamesSolved, + Rank: rankPosition(userID, rating()), + } + + writeResponse(w, model.ApiResponse{Stats: stats, Monitoring: u.Monitoring}) +} + +func rankPosition(UserID int, rating []int) int { + for i, uid := range rating { + if uid == UserID { + return i + 1 + } + } + return -1 +} + +func errorResponse(w http.ResponseWriter, code int, err error) { + slog.Error(err.Error()) + w.WriteHeader(code) + s := err.Error() + writeResponse(w, model.ApiResponse{Err: &s}) +} + +func writeResponse(w http.ResponseWriter, r model.ApiResponse) { + b, err := json.Marshal(&r) + if err != nil { + msg := fmt.Sprintf("response json marshal: %s", err) + slog.Error(msg) + b = []byte(msg) + } else { + w.Header().Set("Content-Type", "application/json") + } + if _, err := w.Write(b); err != nil { + slog.Error(fmt.Sprintf("send json response: %s", err)) + } +} diff --git a/internal/web-service/handler/handler_test.go b/internal/web-service/handler/handler_test.go new file mode 100644 index 0000000..e626311 --- /dev/null +++ b/internal/web-service/handler/handler_test.go @@ -0,0 +1,208 @@ +package handler_test + +import ( + "15-puzzle/internal/model" + "15-puzzle/internal/repo" + "15-puzzle/internal/web-service/handler" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const ( + botToken = "example:token" + initData = "auth_date=269666017&chat_instance=6039284203686499081&chat_type=sender&hash=40cde8dc7250ee616cd7d7a090749a9a42cc68f018c969fcb48fdf5e62657ad6&signature=FF5oTJSnmxdqgtNozxsLywXyVKdssh_DbvksGUaQuhkMiRfp10HJmf5o88uokPpqF4yhpHbX1c8uLbrKUuUdAA&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Ilia%22%2C%22id%22%3A303133707%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22Denisov%22%2C%22photo_url%22%3A%22https%3A%2F%2Fyoutu.be%2FdQw4w9WgXcQ%22%7D" + userId = 303133707 +) + +func TestHandler(t *testing.T) { + // static + testCase(t, testStaticExistingFile) + testCase(t, testStaticWebAppIndex) + // api + testCase(t, testApiInfo) + testCase(t, testApiAuth) + testCase(t, testApiStart) + testCase(t, testApiSolve) + testCase(t, testApiStats) + testCase(t, testApiMonitoring) +} + +func testStaticExistingFile(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/static/tgwebapp.html", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "static file request must succeed") + assert.Contains(t, w.Header().Get("Content-Type"), "text/html") + assert.Contains(t, w.Body.String(), "unit-test-header") +} + +func testStaticWebAppIndex(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/puzzle.html", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "redirect to static file must succeed") + assert.Contains(t, w.Header().Get("Content-Type"), "text/html") + assert.Contains(t, w.Body.String(), "unit-test-header") +} + +func testApiAuth(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/info", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/start", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve?moves=69", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/stats", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func testApiInfo(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/info", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok") + + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var u model.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil { + t.Fatalf("decode json %s: %s", w.Body.String(), err) + } + assert.NotNil(t, u.Info, "response: stats field should be set") +} + +func testApiStart(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, ctxRoot+"/api/start", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok") + + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var u model.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil { + t.Fatalf("decode json %s: %s", w.Body.String(), err) + } + assert.NotNil(t, u.Stats, "response: stats field should be set") + assert.Equal(t, 1, u.Stats.GamesStarted, "user started games should be exactly one") +} + +func testApiSolve(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, ctxRoot+"/api/solve?moves=69", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok") + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var u model.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil { + t.Fatalf("decode json %s: %s", w.Body.String(), err) + } + assert.NotNil(t, u.Stats, "response: stats field should be set") + assert.Equal(t, 1, u.Stats.GamesSolved, "user solved games should be exactly one") +} + +func testApiStats(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/stats", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, "api request with correct auth header should be ok") + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var u model.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil { + fmt.Println(w.Body) + t.Fatalf("decode json %s: %s", w.Body.String(), err) + } + assert.NotNil(t, u.Stats, "response: stats field should be set") +} + +func testApiMonitoring(t *testing.T, ctxRoot string, h http.Handler) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.True(t, w.Body.Len() == 0) + + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, ctxRoot+"/api/monitoring", nil) + req.Header.Add(handler.WebAppInitDataHeader, initData) + req.Header.Add(handler.WebAppExtraCodeHeader, "1234") + h.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code, w.Body.String()) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + var u model.ApiResponse + if err := json.Unmarshal(w.Body.Bytes(), &u); err != nil { + t.Fatalf("decode json %s: %s", w.Body.String(), err) + } + assert.NotNil(t, u.Monitoring, "monitoring field should be an object") + assert.NotNil(t, u.Monitoring.Users) + assert.NotNil(t, u.Monitoring.GamesStarted) + assert.NotNil(t, u.Monitoring.GamesSolved) +} + +func testCase(t *testing.T, tc func(*testing.T, string, http.Handler)) { + testContextRoot(t, "", tc) + testContextRoot(t, "/", tc) + testContextRoot(t, "/15-puzzle/", tc) + testContextRoot(t, "/15-puzzle", tc) +} + +func testContextRoot(t *testing.T, ctxRoot string, tc func(*testing.T, string, http.Handler)) { + f, err := os.CreateTemp("", "puzzle15-handler-test") + if err != nil { + t.Fatalf("temporary file create: %s", err) + } + if err := f.Close(); err != nil { + t.Fatalf("temporary file close: %s", err) + } + if err := os.Remove(f.Name()); err != nil { + t.Fatalf("temporary file remove before: %s", err) + } + defer func() { + if err := os.Remove(f.Name()); err != nil { + t.Errorf("temporary file remove after: %s", err) + } + }() + + r, err := repo.NewFileRepo(context.Background(), f.Name()) + if err != nil { + t.Fatalf("NewFileRepo: %s", err) + } + err = r.AddUser(userId) + if err != nil { + t.Fatalf("RegisterGameStart: %s", err) + } + tc(t, strings.TrimRight(ctxRoot, "/"), handler.NewHandler(r, botToken, "1234", ctxRoot, "testdata", "projectLink")) +} diff --git a/internal/web-service/handler/testdata/tgwebapp.html b/internal/web-service/handler/testdata/tgwebapp.html new file mode 100644 index 0000000..b87642d --- /dev/null +++ b/internal/web-service/handler/testdata/tgwebapp.html @@ -0,0 +1 @@ +unit-test-header \ No newline at end of file diff --git a/internal/web-service/server/server.go b/internal/web-service/server/server.go new file mode 100644 index 0000000..192968d --- /dev/null +++ b/internal/web-service/server/server.go @@ -0,0 +1,30 @@ +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" +) + +func StartServer(ctx context.Context, handler http.Handler) (err error) { + p, ok := os.LookupEnv("SERVER_PORT") + if !ok { + p = "8080" + } + + slog.Info(fmt.Sprintf("starting server on port: %s", p)) + go func() { + err = http.ListenAndServe(":"+p, handler) + if errors.Is(err, http.ErrServerClosed) { + slog.Info("server closed") + } else if err != nil { + err = fmt.Errorf("listen: %s", err) + } + }() + + <-ctx.Done() + return +} diff --git a/web/tgwebapp.html b/web/tgwebapp.html new file mode 100644 index 0000000..468b59f --- /dev/null +++ b/web/tgwebapp.html @@ -0,0 +1,222 @@ + + + + + + + + + + + + + + + + +

WebAssembly is not supported in your browser

+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/web/wasmwebapp.html b/web/wasmwebapp.html new file mode 100644 index 0000000..b497b45 --- /dev/null +++ b/web/wasmwebapp.html @@ -0,0 +1,18 @@ + + + + \ No newline at end of file