commit 31957206b8f3e2a1a55652e36fb89b8717fd9e1d Author: Ilia Denisov Date: Wed Sep 3 18:43:28 2025 +0300 initial 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 0000000..ceeeed4 Binary files /dev/null and b/internal/puzzle/assets/audio/click.mp3 differ diff --git a/internal/puzzle/assets/fonts/README.md b/internal/puzzle/assets/fonts/README.md new file mode 100644 index 0000000..743c5ea --- /dev/null +++ b/internal/puzzle/assets/fonts/README.md @@ -0,0 +1,6 @@ +# Retro VGA Font + +Credits: + +- [VileR](http://int10h.org/). +- [fly_indiz](http://old-dos.ru/index.php?page=files&mode=files&do=show&id=102798). diff --git a/internal/puzzle/assets/fonts/font9x16.ttf b/internal/puzzle/assets/fonts/font9x16.ttf new file mode 100644 index 0000000..ddee998 Binary files /dev/null and b/internal/puzzle/assets/fonts/font9x16.ttf differ 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