initial
This commit is contained in:
+52
@@ -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" ]
|
||||||
@@ -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
|
||||||
@@ -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/).
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Starting app"
|
||||||
|
/bin/sh -c $@
|
||||||
|
exit_code=$?
|
||||||
|
echo "App exit code: ${exit_code}"
|
||||||
|
exit ${exit_code}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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).
|
||||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌
|
||||||
|
▐ ▌
|
||||||
|
▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐ ▌
|
||||||
|
▐▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▌
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
( )
|
||||||
|
|
||||||
|
|
||||||
|
O O
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
oO Oo
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
´.-::::::-.´
|
||||||
|
.:-::::::::::::::-:.
|
||||||
|
´_:: ::_´
|
||||||
|
.: :.
|
||||||
|
´:: ::.
|
||||||
|
´::::::: :::::::´
|
||||||
|
.::::::::::::::::.
|
||||||
|
::::::::::::::::
|
||||||
|
-::::::::::::::::-
|
||||||
|
´::::::::::::::::´
|
||||||
|
.::::::::::::::.
|
||||||
|
:::::::
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
..
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
: :: :
|
||||||
|
( ^ :: ^ )
|
||||||
|
: :
|
||||||
|
UU
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
┌─────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ -> │
|
||||||
|
└─────────────────────────────┘
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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) {}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<html><title>unit-test-header</title></html>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
/* css preloader: https://shihabiiuc.com/preloader/ */
|
||||||
|
#loader-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader-wrapper .loader-section {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 51%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
-webkit-transform: translateX(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader-section {
|
||||||
|
background: #17212B;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader-wrapper .loader-section.section-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader-wrapper .loader-section.section-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
margin: -75px 0 0 -75px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #3498db;
|
||||||
|
-webkit-animation: spin 2s linear infinite;
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #e74c3c;
|
||||||
|
-webkit-animation: spin 3s linear infinite;
|
||||||
|
animation: spin 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loader:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
left: 15px;
|
||||||
|
right: 15px;
|
||||||
|
bottom: 15px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-top-color: #f9c922;
|
||||||
|
-webkit-animation: spin 1.5s linear infinite;
|
||||||
|
animation: spin 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded #loader-wrapper {
|
||||||
|
visibility: hidden;
|
||||||
|
-webkit-transform: translateY(-100%);
|
||||||
|
transform: translateY(-100%);
|
||||||
|
-webkit-transition: all 0.3s 1s ease-out;
|
||||||
|
transition: all 0.3s 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded #loader-wrapper .loader-section.section-left {
|
||||||
|
-webkit-transform: translateX(-100%);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded #loader-wrapper .loader-section.section-right {
|
||||||
|
-webkit-transform: translateX(100%);
|
||||||
|
transform: translateX(100%);
|
||||||
|
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded #loader {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: all 0.3s ease-out;
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#message {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes spin {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<link rel="preload" href="https://telegram.org/js/telegram-web-app.js?59" as="script" />
|
||||||
|
<link rel="preload" href="wasm_exec.js" as="script" />
|
||||||
|
<link rel="preload" href="static/game.wasm" as="fetch" />
|
||||||
|
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
|
||||||
|
<script src="static/wasm_exec.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var startLoad, endLoad;
|
||||||
|
var loading = true
|
||||||
|
if (WebAssembly) {
|
||||||
|
startLoading()
|
||||||
|
const go = new Go();
|
||||||
|
// async wasm loading: https://web.dev/articles/loading-wasm
|
||||||
|
(async () => {
|
||||||
|
const fetchPromise = fetch('static/game.wasm');
|
||||||
|
const { instance } = await WebAssembly.instantiateStreaming(fetchPromise, go.importObject);
|
||||||
|
go.run(instance);
|
||||||
|
onLoaded();
|
||||||
|
})();
|
||||||
|
} else {
|
||||||
|
document.getElementById('message').style.visibility = 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLoading() {
|
||||||
|
startLoad = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLoaded() {
|
||||||
|
window.Telegram.WebApp.ready();
|
||||||
|
Telegram.WebApp.onEvent('activated', onActivated)
|
||||||
|
Telegram.WebApp.onEvent('deactivated', onDeactivated)
|
||||||
|
|
||||||
|
loading = false
|
||||||
|
endLoad = performance.now();
|
||||||
|
var loadMs = endLoad - startLoad;
|
||||||
|
var seconds = loadMs / 1000;
|
||||||
|
wasmOnLoad((endLoad - startLoad) / 1000,
|
||||||
|
window.Telegram.WebApp.themeParams.bg_color,
|
||||||
|
window.Telegram.WebApp.initDataUnsafe?.user?.language_code ?? 'en')
|
||||||
|
|
||||||
|
document.querySelector("body").classList.add("loaded");
|
||||||
|
wasmSetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivated() {
|
||||||
|
wasmSetActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeactivated() {
|
||||||
|
wasmSetActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(url) {
|
||||||
|
window.Telegram.WebApp.openLink(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiRequest(method, url, code = '') {
|
||||||
|
try {
|
||||||
|
await wasmHTTPRequest(method, url, code, window.Telegram.WebApp.initData)
|
||||||
|
} catch (err) {
|
||||||
|
debug('apiRequest: invoke ' + method + ' url ' + url + ' caught exception: ' + err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function debug(msg) {
|
||||||
|
console.log(msg)
|
||||||
|
if (!loading) {
|
||||||
|
wasmDebug(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="message"><p>WebAssembly is not supported in your browser</p></div>
|
||||||
|
<div id="loader-wrapper">
|
||||||
|
<div id="loader"></div>
|
||||||
|
<div class="loader-section section-left"></div>
|
||||||
|
<div class="loader-section section-right"></div>
|
||||||
|
</div>
|
||||||
|
<main id="wasm"></main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<link rel="preload" href="static/game.wasm" as="fetch" />
|
||||||
|
<script src="static/wasm_exec.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
|
||||||
|
if (!WebAssembly.instantiateStreaming) {
|
||||||
|
WebAssembly.instantiateStreaming = async (resp, importObject) => {
|
||||||
|
const source = await (await resp).arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(source, importObject);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = new Go();
|
||||||
|
WebAssembly.instantiateStreaming(fetch("static/game.wasm"), go.importObject).then(result => {
|
||||||
|
go.run(result.instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user