From d787e2f4319c31581d60e8a381a0e9436edb67e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Fri, 15 Dec 2023 08:16:51 +0100 Subject: [PATCH] init --- .commitlintrc.yml | 2 + .dockerignore | 19 + .envrc | 5 + .gitignore | 5 + .golangci.yml | 476 ++++++ .hadolint.yaml | 2 + .pre-commit-config.yaml | 20 + .woodpecker/govulncheck.yml | 41 + .woodpecker/test.yml | 71 + .yamllint.yml | 15 + Makefile | 30 + build/docker/twhelp/dev/Dockerfile | 17 + build/docker/twhelp/prod/Dockerfile | 22 + go.mod | 14 + go.sum | 12 + internal/tw/building_info.go | 42 + internal/tw/client.go | 804 ++++++++++ internal/tw/client_config.go | 61 + internal/tw/client_test.go | 1719 +++++++++++++++++++++ internal/tw/parse_error.go | 15 + internal/tw/server_config.go | 186 +++ internal/tw/server_config_test.go | 65 + internal/tw/testdata/building_info.xml | 241 +++ internal/tw/testdata/get_servers_resp.txt | 1 + internal/tw/testdata/server_config.xml | 125 ++ internal/tw/testdata/unit_info.xml | 123 ++ internal/tw/tw.go | 69 + internal/tw/unit_info.go | 34 + renovate.json | 17 + skaffold.yml | 45 + 30 files changed, 4298 insertions(+) create mode 100644 .commitlintrc.yml create mode 100644 .dockerignore create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .hadolint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/govulncheck.yml create mode 100644 .woodpecker/test.yml create mode 100644 .yamllint.yml create mode 100644 Makefile create mode 100644 build/docker/twhelp/dev/Dockerfile create mode 100644 build/docker/twhelp/prod/Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/tw/building_info.go create mode 100644 internal/tw/client.go create mode 100644 internal/tw/client_config.go create mode 100644 internal/tw/client_test.go create mode 100644 internal/tw/parse_error.go create mode 100644 internal/tw/server_config.go create mode 100644 internal/tw/server_config_test.go create mode 100644 internal/tw/testdata/building_info.xml create mode 100644 internal/tw/testdata/get_servers_resp.txt create mode 100644 internal/tw/testdata/server_config.xml create mode 100644 internal/tw/testdata/unit_info.xml create mode 100644 internal/tw/tw.go create mode 100644 internal/tw/unit_info.go create mode 100644 renovate.json create mode 100644 skaffold.yml diff --git a/.commitlintrc.yml b/.commitlintrc.yml new file mode 100644 index 0000000..9cb74a7 --- /dev/null +++ b/.commitlintrc.yml @@ -0,0 +1,2 @@ +extends: + - "@commitlint/config-conventional" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b978a4b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.env* +README.md +LICENSE +renovate.json +.gitignore +.dockerignore +bin +.golangci.yml +.pre-commit-config.yaml +build +k8s +.commitlintrc.yml +additional_envs.sh +skaffold.yml +.editorconfig +.woodpecker +.kpt-pipeline +tmp diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..b8ac4a8 --- /dev/null +++ b/.envrc @@ -0,0 +1,5 @@ +PATH_add bin +export GOBIN=$PWD/bin +if [ -f "additional_envs.sh" ]; then + source <(cat additional_envs.sh) +fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55a8c6c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +bin/* +additional_envs.sh +.kpt-pipeline +tmp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ce47782 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,476 @@ +run: + tests: true + timeout: 5m + +linters: + disable-all: true + enable: + - gocyclo + - asasalint + - asciicheck + - bodyclose + - bidichk + - exportloopref + - errcheck + - gocritic + - gosec + - gofmt + - goimports + - gosimple + - govet + - ineffassign + - misspell + - nakedret + - prealloc + - staticcheck + - typecheck + - unconvert + - unused + - lll + - nestif + - thelper + - nonamedreturns + - tenv + - testpackage + - noctx + - tparallel + - usestdlibvars + - unconvert + - makezero + - grouper + - errname + - exhaustive + - tagliatelle + - contextcheck + - gocheckcompilerdirectives + - errname + - forcetypeassert + - durationcheck + - predeclared + - promlinter + - wastedassign + - testifylint + - inamedparam + - sloglint + - revive + +linters-settings: + gocyclo: + min-complexity: 10 + tagliatelle: + case: + rules: + json: camel + bun: snake + lll: + line-length: 120 + govet: + enable: + - asmdecl + - assign + - atomic + - atomicalign + - bools + - buildtag + - cgocall + - composites + - copylocks + - deepequalerrors + - errorsas + - findcall + - framepointer + - httpresponse + - ifaceassert + - loopclosure + - lostcancel + - nilfunc + - nilness + - printf + - reflectvaluecompare + - shadow + - shift + - sigchanyzer + - sortslice + - stdmethods + - stringintconv + - structtag + - testinggoroutine + - tests + - unmarshal + - unreachable + - unsafeptr + - unusedresult + - unusedwrite + testifylint: + enable-all: true + sloglint: + attr-only: true + revive: + rules: + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#add-constant + - name: add-constant + severity: warning + disabled: true + arguments: + - maxLitCount: "3" + allowStrs: "\"\"" + ignoreFuncs: os.Exit,wg.Add,make + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#argument-limit + - name: argument-limit + severity: warning + disabled: true + arguments: [4] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#atomic + - name: atomic + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#banned-characters + - name: banned-characters + severity: warning + disabled: true + arguments: [] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#bare-return + - name: bare-return + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#blank-imports + - name: blank-imports + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#bool-literal-in-expr + - name: bool-literal-in-expr + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#call-to-gc + - name: call-to-gc + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#cognitive-complexity + - name: cognitive-complexity + severity: warning + disabled: true + arguments: [7] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#comment-spacings + - name: comment-spacings + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#confusing-naming + - name: confusing-naming + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#confusing-results + - name: confusing-results + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#constant-logical-expr + - name: constant-logical-expr + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#context-as-argument + - name: context-as-argument + severity: error + disabled: false + arguments: + - allowTypesBefore: "*testing.T,testing.TB" + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#context-keys-type + - name: context-keys-type + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#cyclomatic + - name: cyclomatic + severity: warning + disabled: true + arguments: [10] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#datarace + - name: datarace + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#deep-exit + - name: deep-exit + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#defer + - name: defer + severity: error + disabled: false + arguments: + - [call-chain, loop] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#dot-imports + - name: dot-imports + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#duplicated-imports + - name: duplicated-imports + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#early-return + - name: early-return + severity: error + disabled: false + arguments: + - preserveScope + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-block + - name: empty-block + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#empty-lines + - name: empty-lines + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#enforce-map-style + - name: enforce-map-style + severity: error + disabled: false + arguments: + - make + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#error-naming + - name: error-naming + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#error-return + - name: error-return + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#error-strings + - name: error-strings + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#errorf + - name: errorf + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#exported + - name: exported + severity: warning + disabled: true + arguments: + - preserveScope + - checkPrivateReceivers + - sayRepetitiveInsteadOfStutters + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#file-header + - name: file-header + severity: warning + disabled: true + arguments: + - This is the text that must appear at the top of source files. + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#flag-parameter + - name: flag-parameter + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#function-result-limit + - name: function-result-limit + severity: warning + disabled: false + arguments: [4] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#function-length + - name: function-length + severity: warning + disabled: true + arguments: [10, 0] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#get-return + - name: get-return + severity: warning + disabled: true + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#identical-branches + - name: identical-branches + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#if-return + - name: if-return + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#increment-decrement + - name: increment-decrement + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#indent-error-flow + - name: indent-error-flow + severity: warning + disabled: false + arguments: + - preserveScope + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#import-alias-naming + - name: import-alias-naming + severity: warning + disabled: false + arguments: + - ^[a-z][a-z0-9]{0,}$ + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#imports-blacklist + - name: imports-blacklist + severity: error + disabled: false + arguments: + - reflect + - github.com/pkg/errors + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#import-shadowing + - name: import-shadowing + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#line-length-limit + - name: line-length-limit + severity: warning + # lll is enabled + disabled: true + arguments: [80] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#max-public-structs + - name: max-public-structs + severity: warning + disabled: true + arguments: [3] + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#modifies-parameter + - name: modifies-parameter + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#modifies-value-receiver + - name: modifies-value-receiver + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#nested-structs + - name: nested-structs + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#optimize-operands-order + - name: optimize-operands-order + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#package-comments + - name: package-comments + severity: warning + disabled: true + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#range + - name: range + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#range-val-in-closure + - name: range-val-in-closure + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#range-val-address + - name: range-val-address + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#receiver-naming + - name: receiver-naming + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#redundant-import-alias + - name: redundant-import-alias + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#redefines-builtin-id + - name: redefines-builtin-id + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#string-of-int + - name: string-of-int + severity: warning + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#string-format + - name: string-format + severity: warning + disabled: true + arguments: + - - core.WriteError[1].Message + - /^([^A-Z]|$)/ + - must not start with a capital letter + - - fmt.Errorf[0] + - /(^|[^\.!?])$/ + - must not end in punctuation + - - panic + - /^[^\n]*$/ + - must not contain line breaks + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#struct-tag + - name: struct-tag + arguments: + - json,inline + - bson,outline,gnu + severity: warning + # tagliatelle is enabled + disabled: true + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#superfluous-else + - name: superfluous-else + severity: error + disabled: false + arguments: + - preserveScope + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#time-equal + - name: time-equal + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#time-naming + - name: time-naming + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#var-naming + - name: var-naming + severity: error + disabled: false + arguments: + - [] # AllowList + - [] # DenyList + - - upperCaseConst: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#var-declaration + - name: var-declaration + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unconditional-recursion + - name: unconditional-recursion + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unexported-naming + - name: unexported-naming + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unexported-return + - name: unexported-return + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unhandled-error + - name: unhandled-error + severity: warning + disabled: false + arguments: + - fmt.Printf + - fmt.Println + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unnecessary-stmt + - name: unnecessary-stmt + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unreachable-code + - name: unreachable-code + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-parameter + - name: unused-parameter + severity: error + disabled: false + arguments: + - allowRegex: ^_ + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#unused-receiver + - name: unused-receiver + severity: error + disabled: true + arguments: + - allowRegex: ^_ + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#useless-break + - name: useless-break + severity: error + disabled: false + # https://github.com/mgechev/revive/blob/master/RULES_DESCRIPTIONS.md#waitgroup-by-value + - name: waitgroup-by-value + severity: warning + disabled: false + + +issues: + exclude-rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gosec + - gocyclo + - path: _test\.go + text: add-constant + - linters: + - lll + source: "^//go:generate " diff --git a/.hadolint.yaml b/.hadolint.yaml new file mode 100644 index 0000000..2323673 --- /dev/null +++ b/.hadolint.yaml @@ -0,0 +1,2 @@ +failure-threshold: error +format: tty diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ab919de --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.10.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ["@commitlint/config-conventional"] + - repo: https://github.com/golangci/golangci-lint + rev: v1.55.2 + hooks: + - id: golangci-lint + - repo: https://github.com/hadolint/hadolint + rev: v2.12.0 + hooks: + - id: hadolint + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.33.0 + hooks: + - id: yamllint + args: [--strict, -c=./.yamllint.yml] diff --git a/.woodpecker/govulncheck.yml b/.woodpecker/govulncheck.yml new file mode 100644 index 0000000..1e3f3c1 --- /dev/null +++ b/.woodpecker/govulncheck.yml @@ -0,0 +1,41 @@ +when: + - event: [cron] + cron: govulncheck + - event: [pull_request] + - event: push + branch: + - ${CI_REPO_DEFAULT_BRANCH} + +variables: + - &go_image 'golang:1.21' + +steps: + govulncheck: + image: *go_image + pull: true + commands: + - make generate + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck ./... + + notify: + image: deblan/woodpecker-email + settings: + from: + from_secret: email_from + from.name: Woodpecker + host: + from_secret: email_host + username: + from_secret: email_username + password: + from_secret: email_password + recipients: + - notifications@dwysokinski.me + recipients_only: true + subject: + "[govulncheck - {{ build.status }}] {{ repo.owner }}/{{ repo.name }} + ({{ build.branch }} - {{ truncate build.commit 8 }})" + when: + status: [success, failure] + event: cron diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..e889e0c --- /dev/null +++ b/.woodpecker/test.yml @@ -0,0 +1,71 @@ +when: + - event: [pull_request] + - event: push + branch: + - ${CI_REPO_DEFAULT_BRANCH} + +services: + database: + image: postgres:14 + pull: true + environment: + POSTGRES_DB: twhelp + POSTGRES_PASSWORD: twhelp + +variables: + - &go_image 'golang:1.21' + +steps: + generate: + image: *go_image + pull: true + commands: + - go mod download + - make generate + + test: + image: *go_image + group: test + pull: true + environment: + TESTS_POSTGRES_CONNECTION_STRING: + postgres://postgres:twhelp@database:5432/twhelp?sslmode=disable + commands: + - go test -race -coverprofile=coverage.txt -covermode=atomic ./... + + lint: + image: golangci/golangci-lint:v1.55 + pull: true + group: test + commands: + - golangci-lint run + + check-go-mod: + image: *go_image + group: test + pull: true + commands: + - go mod tidy + - git diff --exit-code go.mod + + hadolint: + group: test + image: hadolint/hadolint:2.12.0-debian + commands: + - hadolint build/docker/twhelp/prod/Dockerfile build/docker/twhelp/dev/Dockerfile + when: + - path: + - build/docker/twhelp/prod/Dockerfile + - build/docker/twhelp/dev/Dockerfile + + yamllint: + group: test + image: cytopia/yamllint:1 + pull: true + commands: + - yamllint --strict . + when: + - path: + include: + - "**/*.yaml" + - "**/*.yml" diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..8b5bfd7 --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,15 @@ +extends: default + +rules: + line-length: + max: 120 + level: error + document-start: disable + truthy: + level: error + comments: + min-spaces-from-content: 1 + quoted-strings: + level: warning + required: only-when-needed + quote-type: double diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8cff7f8 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +GOOS=$(shell go env GOOS) +GOARCH=$(shell go env GOARCH) +GOBIN=$(shell go env GOBIN) +ifeq ($(GOBIN),) +GOBIN := $(shell go env GOPATH)/bin +endif +OSARCH=$(shell uname -m) +GOLANGCI_LINT_PATH=$(GOBIN)/golangci-lint + +.PHONY: install-git-hooks +install-git-hooks: + @echo "Installing git hooks..." + pre-commit install --hook-type pre-commit + pre-commit install --hook-type commit-msg + +.PHONY: install-golangci-lint +install-golangci-lint: + @echo "Installing github.com/golangci/golangci-lint..." + @(test -f $(GOLANGCI_LINT_PATH) && echo "github.com/golangci/golangci-lint is already installed. Skipping...") || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v1.55.2 + +.PHONY: install-tools +install-tools: install-golangci-lint + +.PHONY: install +install: install-tools install-git-hooks + +.PHONY: generate +generate: + @echo "Running go generate..." + go generate ./... diff --git a/build/docker/twhelp/dev/Dockerfile b/build/docker/twhelp/dev/Dockerfile new file mode 100644 index 0000000..715f91e --- /dev/null +++ b/build/docker/twhelp/dev/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.21 as builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# `skaffold debug` sets SKAFFOLD_GO_GCFLAGS to disable compiler optimizations +ARG SKAFFOLD_GO_GCFLAGS +RUN go build -gcflags="${SKAFFOLD_GO_GCFLAGS}" -o twhelp ./cmd/twhelp + +FROM ubuntu:22.04 +# Define GOTRACEBACK to mark this container as using the Go language runtime +# for `skaffold debug` (https://skaffold.dev/docs/workflows/debug/). +WORKDIR /root +ENV GOTRACEBACK=single +RUN apt update && apt install -y ca-certificates tzdata +COPY --from=builder /app/twhelp . +ENTRYPOINT ["./twhelp"] diff --git a/build/docker/twhelp/prod/Dockerfile b/build/docker/twhelp/prod/Dockerfile new file mode 100644 index 0000000..25eecb9 --- /dev/null +++ b/build/docker/twhelp/prod/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.21.5-alpine3.19 AS builder + +WORKDIR /twhelp + +COPY ../../../../go.mod go.sum ./ +RUN go mod download && apk --no-cache add make + +COPY ../../../.. . +RUN make generate +ARG CI_COMMIT_TAG="development" +RUN CGO_ENABLED=0 go build -ldflags "-X main.version=${CI_COMMIT_TAG##v}" -trimpath -o twhelp ./cmd/twhelp + +######## Start a new stage from scratch ####### +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates tzdata + +COPY --from=builder /twhelp/twhelp /usr/bin/ + +EXPOSE 9234/tcp + +ENTRYPOINT ["twhelp"] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c01f8b7 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module gitea.dwysokinski.me/twhelp/corev3 + +go 1.21 + +require ( + github.com/elliotchance/phpserialize v1.3.3 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..176eb19 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +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/elliotchance/phpserialize v1.3.3 h1:hV4QVmGdCiYgoBbw+ADt6fNgyZ2mYX0OgpnON1adTCM= +github.com/elliotchance/phpserialize v1.3.3/go.mod h1:gt7XX9+ETUcLXbtTKEuyrqW3lcLUAeS/AnGZ2e49TZs= +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.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/tw/building_info.go b/internal/tw/building_info.go new file mode 100644 index 0000000..fdb5099 --- /dev/null +++ b/internal/tw/building_info.go @@ -0,0 +1,42 @@ +package tw + +import ( + "encoding/xml" +) + +type Building struct { + MaxLevel int `xml:"max_level"` + MinLevel int `xml:"min_level"` + Wood int `xml:"wood"` + Stone int `xml:"stone"` + Iron int `xml:"iron"` + Pop int `xml:"pop"` + WoodFactor float64 `xml:"wood_factor"` + StoneFactor float64 `xml:"stone_factor"` + IronFactor float64 `xml:"iron_factor"` + PopFactor float64 `xml:"pop_factor"` + BuildTime float64 `xml:"build_time"` + BuildTimeFactor float64 `xml:"build_time_factor"` +} + +type BuildingInfo struct { + XMLName xml.Name `xml:"config"` + Text string `xml:",chardata"` + Main Building `xml:"main"` + Barracks Building `xml:"barracks"` + Stable Building `xml:"stable"` + Garage Building `xml:"garage"` + Watchtower Building `xml:"watchtower"` + Snob Building `xml:"snob"` + Smith Building `xml:"smith"` + Place Building `xml:"place"` + Statue Building `xml:"statue"` + Market Building `xml:"market"` + Wood Building `xml:"wood"` + Stone Building `xml:"stone"` + Iron Building `xml:"iron"` + Farm Building `xml:"farm"` + Storage Building `xml:"storage"` + Hide Building `xml:"hide"` + Wall Building `xml:"wall"` +} diff --git a/internal/tw/client.go b/internal/tw/client.go new file mode 100644 index 0000000..a146528 --- /dev/null +++ b/internal/tw/client.go @@ -0,0 +1,804 @@ +package tw + +import ( + "cmp" + "context" + "encoding/csv" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "slices" + "strconv" + "time" + + "github.com/elliotchance/phpserialize" +) + +const ( + endpointPlayers = "/map/player.txt" + endpointTribes = "/map/ally.txt" + endpointVillages = "/map/village.txt" + endpointPlayersODA = "/map/kill_att.txt" + endpointPlayersODD = "/map/kill_def.txt" + endpointPlayersODS = "/map/kill_sup.txt" + endpointPlayersOD = "/map/kill_all.txt" + endpointTribesODA = "/map/kill_att_tribe.txt" + endpointTribesODD = "/map/kill_def_tribe.txt" + endpointTribesOD = "/map/kill_all_tribe.txt" + endpointEnnoblements = "/map/conquer_extended.txt" + endpointInterface = "/interface.php" + endpointGetServers = "/backend/get_servers.php" + endpointGame = "/game.php" +) + +const ( + queryConfig = "func=get_config" + queryUnitInfo = "func=get_unit_info" + queryBuildingInfo = "func=get_building_info" + queryInterfaceEnnoblements = "func=get_conquer_extended&since=%d" + queryPlayerProfile = "screen=info_player&id=%d" + queryTribeProfile = "screen=info_ally&id=%d" + queryVillageProfile = "screen=info_village&id=%d" +) + +type Client struct { + client *http.Client + userAgent string + ennoblementsUseInterfaceFunc func(since time.Time) bool +} + +func NewClient(opts ...ClientOption) *Client { + cfg := newClientConfig(opts...) + return &Client{ + client: cfg.client, + userAgent: cfg.userAgent, + ennoblementsUseInterfaceFunc: cfg.ennoblementsUseInterfaceFunc, + } +} + +var ( + ErrInvalidServerKey = errors.New("invalid server key") + ErrInvalidServerURL = errors.New("invalid server URL") +) + +func (c *Client) GetOpenServers(ctx context.Context, rawBaseURL string) ([]Server, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return nil, err + } + + u := buildURL(baseURL, endpointGetServers) + resp, err := c.get(ctx, u) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%s: couldn't read response body: %w", u, err) + } + + m, err := phpserialize.UnmarshalAssociativeArray(b) + if err != nil { + return nil, fmt.Errorf("%s: couldn't unmarshal response body: %w", u, err) + } + + servers := make([]Server, 0, len(m)) + for key, val := range m { + keyStr, ok := key.(string) + if !ok || keyStr == "" { + return nil, fmt.Errorf("%s: parsing '%v': %w", u, key, ErrInvalidServerKey) + } + + urlStr, ok := val.(string) + if !ok || urlStr == "" { + return nil, fmt.Errorf("%s: parsing '%v': %w", u, val, ErrInvalidServerURL) + } + + servers = append(servers, Server{ + Key: keyStr, + URL: urlStr, + }) + } + + return servers, nil +} + +func (c *Client) GetServerConfig(ctx context.Context, rawBaseURL string) (ServerConfig, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return ServerConfig{}, err + } + + var cfg ServerConfig + + if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryConfig), &cfg); err != nil { + return ServerConfig{}, err + } + + return cfg, nil +} + +func (c *Client) GetBuildingInfo(ctx context.Context, rawBaseURL string) (BuildingInfo, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return BuildingInfo{}, err + } + + var info BuildingInfo + + if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryBuildingInfo), &info); err != nil { + return BuildingInfo{}, err + } + + return info, nil +} + +func (c *Client) GetUnitInfo(ctx context.Context, rawBaseURL string) (UnitInfo, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return UnitInfo{}, err + } + + var info UnitInfo + + if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryUnitInfo), &info); err != nil { + return UnitInfo{}, err + } + + return info, nil +} + +func (c *Client) GetTribes(ctx context.Context, rawBaseURL string) ([]Tribe, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return nil, err + } + + od, err := c.getOD(ctx, baseURL, true) + if err != nil { + return nil, err + } + + resp, err := c.get(ctx, buildURL(baseURL, endpointTribes)) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + return tribeCSVParser{ + r: resp.Body, + baseURL: baseURL, + od: od, + }.parse() +} + +func (c *Client) GetPlayers(ctx context.Context, rawBaseURL string) ([]Player, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return nil, err + } + + od, err := c.getOD(ctx, baseURL, false) + if err != nil { + return nil, err + } + + resp, err := c.get(ctx, buildURL(baseURL, endpointPlayers)) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + return playerCSVParser{ + r: resp.Body, + baseURL: baseURL, + od: od, + }.parse() +} + +func (c *Client) getOD(ctx context.Context, baseURL *url.URL, tribe bool) (map[int]OpponentsDefeated, error) { + m := make(map[int]OpponentsDefeated) + urls := buildODURLs(baseURL, tribe) + + for _, u := range urls { + if u == nil { + continue + } + + records, err := c.getSingleODFile(ctx, u) + if err != nil { + return nil, err + } + + for _, rec := range records { + od := m[rec.ID] + + switch u { + case urls[0]: + od.RankTotal = rec.Rank + od.ScoreTotal = rec.Score + case urls[1]: + od.RankAtt = rec.Rank + od.ScoreAtt = rec.Score + case urls[2]: + od.RankDef = rec.Rank + od.ScoreDef = rec.Score + case urls[3]: + od.RankSup = rec.Rank + od.ScoreSup = rec.Score + } + + m[rec.ID] = od + } + } + + return m, nil +} + +func (c *Client) getSingleODFile(ctx context.Context, u *url.URL) ([]odRecord, error) { + resp, err := c.get(ctx, u) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + return odCSVParser{ + r: resp.Body, + }.parse() +} + +func (c *Client) GetVillages(ctx context.Context, rawBaseURL string) ([]Village, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return nil, err + } + + resp, err := c.get(ctx, buildURL(baseURL, endpointVillages)) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + return villageCSVParser{ + r: resp.Body, + baseURL: baseURL, + }.parse() +} + +func (c *Client) GetEnnoblements(ctx context.Context, rawBaseURL string, since time.Time) ([]Ennoblement, error) { + baseURL, err := url.ParseRequestURI(rawBaseURL) + if err != nil { + return nil, err + } + + u := buildURL(baseURL, endpointEnnoblements) + if c.ennoblementsUseInterfaceFunc(since) { + u = buildURLWithQuery(baseURL, endpointInterface, fmt.Sprintf(queryInterfaceEnnoblements, since.Unix())) + } + + resp, err := c.get(ctx, u) + if err != nil { + return nil, err + } + defer func() { + _ = resp.Body.Close() + }() + + return ennoblementCSVParser{ + r: resp.Body, + since: since, + }.parse() +} + +func (c *Client) getXML(ctx context.Context, u *url.URL, v any) error { + resp, err := c.get(ctx, u) + if err != nil { + return err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if err = xml.NewDecoder(resp.Body).Decode(v); err != nil { + return fmt.Errorf("%s: %w", u, err) + } + + return nil +} + +func (c *Client) get(ctx context.Context, u *url.URL) (*http.Response, error) { + urlStr := u.String() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, fmt.Errorf("%s: %w", urlStr, err) + } + + // headers + req.Header.Set("User-Agent", c.userAgent) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s: %w", urlStr, err) + } + + if resp.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + return nil, fmt.Errorf("%s: got non-ok HTTP status: %d", urlStr, resp.StatusCode) + } + + return resp, nil +} + +type tribeCSVParser struct { + r io.Reader + baseURL *url.URL + od map[int]OpponentsDefeated +} + +const fieldsPerRecordTribe = 8 + +func (t tribeCSVParser) parse() ([]Tribe, error) { + csvR := newCSVReader(t.r, fieldsPerRecordTribe) + + var tribes []Tribe + for { + rec, err := csvR.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + tribe, err := t.parseRecord(rec) + if err != nil { + return nil, err + } + + tribes = append(tribes, tribe) + } + + slices.SortFunc(tribes, func(a, b Tribe) int { + return cmp.Compare(a.ID, b.ID) + }) + + return tribes, nil +} + +func (t tribeCSVParser) parseRecord(record []string) (Tribe, error) { + var err error + var tribe Tribe + + // $id, $name, $tag, $members, $villages, $points, $all_points, $rank + + tribe.ID, err = strconv.Atoi(record[0]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[0], Field: "Tribe.ID"} + } + + tribe.Name, err = url.QueryUnescape(record[1]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[1], Field: "Tribe.Name"} + } + + tribe.Tag, err = url.QueryUnescape(record[2]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[2], Field: "Tribe.Tag"} + } + + tribe.NumMembers, err = strconv.Atoi(record[3]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[3], Field: "Tribe.NumMembers"} + } + + tribe.NumVillages, err = strconv.Atoi(record[4]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[4], Field: "Tribe.NumVillages"} + } + + tribe.Points, err = strconv.Atoi(record[5]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[5], Field: "Tribe.Points"} + } + + tribe.AllPoints, err = strconv.Atoi(record[6]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[6], Field: "Tribe.AllPoints"} + } + + tribe.Rank, err = strconv.Atoi(record[7]) + if err != nil { + return Tribe{}, ParseError{Err: err, Str: record[7], Field: "Tribe.Rank"} + } + + tribe.OpponentsDefeated = t.od[tribe.ID] + + tribe.ProfileURL = buildURLWithQuery(t.baseURL, endpointGame, fmt.Sprintf(queryTribeProfile, tribe.ID)).String() + + return tribe, nil +} + +type playerCSVParser struct { + r io.Reader + od map[int]OpponentsDefeated + baseURL *url.URL +} + +const fieldsPerRecordPlayer = 6 + +func (p playerCSVParser) parse() ([]Player, error) { + csvR := newCSVReader(p.r, fieldsPerRecordPlayer) + + var players []Player + for { + rec, err := csvR.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + player, err := p.parseRecord(rec) + if err != nil { + return nil, err + } + + players = append(players, player) + } + + slices.SortFunc(players, func(a, b Player) int { + return cmp.Compare(a.ID, b.ID) + }) + + return players, nil +} + +func (p playerCSVParser) parseRecord(record []string) (Player, error) { + var err error + var player Player + + // $id, $name, $ally, $villages, $points, $rank + + player.ID, err = strconv.Atoi(record[0]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[0], Field: "Player.ID"} + } + + player.Name, err = url.QueryUnescape(record[1]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[1], Field: "Player.Name"} + } + + player.TribeID, err = strconv.Atoi(record[2]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[2], Field: "Player.TribeID"} + } + + player.NumVillages, err = strconv.Atoi(record[3]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[3], Field: "Player.NumVillages"} + } + + player.Points, err = strconv.Atoi(record[4]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[4], Field: "Player.Points"} + } + + player.Rank, err = strconv.Atoi(record[5]) + if err != nil { + return Player{}, ParseError{Err: err, Str: record[5], Field: "Player.Rank"} + } + + player.OpponentsDefeated = p.od[player.ID] + + player.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryPlayerProfile, player.ID)).String() + + return player, nil +} + +type villageCSVParser struct { + r io.Reader + baseURL *url.URL +} + +const fieldsPerRecordVillage = 7 + +func (v villageCSVParser) parse() ([]Village, error) { + csvR := newCSVReader(v.r, fieldsPerRecordVillage) + + var villages []Village + for { + rec, err := csvR.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + village, err := v.parseRecord(rec) + if err != nil { + return nil, err + } + villages = append(villages, village) + } + + slices.SortFunc(villages, func(a, b Village) int { + return cmp.Compare(a.ID, b.ID) + }) + + return villages, nil +} + +func (v villageCSVParser) parseRecord(record []string) (Village, error) { + var err error + var village Village + + // $id, $name, $x, $y, $player, $points, $bonus_id + + village.ID, err = strconv.Atoi(record[0]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[0], Field: "Village.ID"} + } + + village.Name, err = url.QueryUnescape(record[1]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[1], Field: "Village.Name"} + } + + village.X, err = strconv.Atoi(record[2]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[2], Field: "Village.X"} + } + + village.Y, err = strconv.Atoi(record[3]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[3], Field: "Village.Y"} + } + + village.PlayerID, err = strconv.Atoi(record[4]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[4], Field: "Village.PlayerID"} + } + + village.Points, err = strconv.Atoi(record[5]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[5], Field: "Village.Points"} + } + + village.Bonus, err = strconv.Atoi(record[6]) + if err != nil { + return Village{}, ParseError{Err: err, Str: record[6], Field: "Village.Bonus"} + } + + village.Continent = v.buildContinent(record, village.X, village.Y) + + village.ProfileURL = buildURLWithQuery(v.baseURL, endpointGame, fmt.Sprintf(queryVillageProfile, village.ID)).String() + + return village, nil +} + +func (v villageCSVParser) buildContinent(record []string, x, y int) string { + continent := "K" + + switch { + case x < 100 && y < 100: + continent += "0" + case x > 100 && y < 100: + continent += string(record[2][0]) + case x < 100 && y > 100: + continent += string(record[3][0]) + "0" + default: + continent += string(record[3][0]) + string(record[2][0]) + } + + return continent +} + +type odCSVParser struct { + r io.Reader +} + +type odRecord struct { + ID int + Rank int + Score int +} + +const fieldsPerRecordOD = 3 + +func (o odCSVParser) parse() ([]odRecord, error) { + csvR := newCSVReader(o.r, fieldsPerRecordOD) + + var odRecords []odRecord + for { + rec, err := csvR.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + od, err := o.parseRecord(rec) + if err != nil { + return nil, err + } + + odRecords = append(odRecords, od) + } + + return odRecords, nil +} + +func (o odCSVParser) parseRecord(record []string) (odRecord, error) { + var err error + var rec odRecord + + // $rank, $id, $score + + rec.Rank, err = strconv.Atoi(record[0]) + if err != nil { + return odRecord{}, ParseError{Err: err, Str: record[0], Field: "odRecord.Rank"} + } + + rec.ID, err = strconv.Atoi(record[1]) + if err != nil { + return odRecord{}, ParseError{Err: err, Str: record[1], Field: "odRecord.ID"} + } + + rec.Score, err = strconv.Atoi(record[2]) + if err != nil { + return odRecord{}, ParseError{Err: err, Str: record[2], Field: "odRecord.Score"} + } + + return rec, nil +} + +type ennoblementCSVParser struct { + r io.Reader + since time.Time +} + +const fieldsPerRecordEnnoblement = 7 + +func (e ennoblementCSVParser) parse() ([]Ennoblement, error) { + csvR := newCSVReader(e.r, fieldsPerRecordEnnoblement) + + var ennoblements []Ennoblement + for { + rec, err := csvR.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + ennoblement, err := e.parseRecord(rec) + if err != nil { + return nil, err + } + + if ennoblement.CreatedAt.Before(e.since) { + continue + } + + ennoblements = append(ennoblements, ennoblement) + } + + slices.SortFunc(ennoblements, func(a, b Ennoblement) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + + return ennoblements, nil +} + +func (e ennoblementCSVParser) parseRecord(record []string) (Ennoblement, error) { + var err error + var ennoblement Ennoblement + + // $village_id, $unix_timestamp, $new_owner, $old_owner, $old_tribe_id, $new_tribe_id, $points + + ennoblement.VillageID, err = strconv.Atoi(record[0]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[0], Field: "Ennoblement.VillageID"} + } + + ennoblement.CreatedAt, err = e.parseTimestamp(record[1]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[1], Field: "Ennoblement.CreatedAt"} + } + + ennoblement.NewOwnerID, err = strconv.Atoi(record[2]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[2], Field: "Ennoblement.NewOwnerID"} + } + + ennoblement.OldOwnerID, err = strconv.Atoi(record[3]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[3], Field: "Ennoblement.OldOwnerID"} + } + + ennoblement.OldTribeID, err = strconv.Atoi(record[4]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[4], Field: "Ennoblement.OldTribeID"} + } + + ennoblement.NewTribeID, err = strconv.Atoi(record[5]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[5], Field: "Ennoblement.NewTribeID"} + } + + ennoblement.Points, err = strconv.Atoi(record[6]) + if err != nil { + return Ennoblement{}, ParseError{Err: err, Str: record[6], Field: "Ennoblement.Points"} + } + + return ennoblement, nil +} + +func (e ennoblementCSVParser) parseTimestamp(s string) (time.Time, error) { + timestamp, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return time.Time{}, err + } + return time.Unix(timestamp, 0), nil +} + +func newCSVReader(r io.Reader, fieldsPerRecord int) *csv.Reader { + csvR := csv.NewReader(r) + csvR.Comma = ',' + csvR.FieldsPerRecord = fieldsPerRecord + csvR.ReuseRecord = true + return csvR +} + +//nolint:revive +func buildODURLs(base *url.URL, tribe bool) [4]*url.URL { + // there is no endpoint to get opponents defeated as supporter for tribes :( + if tribe { + return [4]*url.URL{ + buildURL(base, endpointTribesOD), + buildURL(base, endpointTribesODA), + buildURL(base, endpointTribesODD), + nil, + } + } + return [4]*url.URL{ + buildURL(base, endpointPlayersOD), + buildURL(base, endpointPlayersODA), + buildURL(base, endpointPlayersODD), + buildURL(base, endpointPlayersODS), + } +} + +func buildURL(base *url.URL, path string) *url.URL { + return buildURLWithQuery(base, path, "") +} + +func buildURLWithQuery(base *url.URL, path, query string) *url.URL { + return &url.URL{ + Scheme: base.Scheme, + Host: base.Host, + Path: path, + RawQuery: query, + } +} diff --git a/internal/tw/client_config.go b/internal/tw/client_config.go new file mode 100644 index 0000000..98a3879 --- /dev/null +++ b/internal/tw/client_config.go @@ -0,0 +1,61 @@ +package tw + +import ( + "net/http" + "time" +) + +const ( + defaultUserAgent = "tribalwarshelp/development" + defaultTimeout = 10 * time.Second +) + +type EnnoblementsUseInterfaceFunc func(since time.Time) bool + +type clientConfig struct { + userAgent string + client *http.Client + ennoblementsUseInterfaceFunc EnnoblementsUseInterfaceFunc +} + +type ClientOption func(c *clientConfig) + +func newClientConfig(opts ...ClientOption) *clientConfig { + cfg := &clientConfig{ + userAgent: defaultUserAgent, + client: &http.Client{ + Timeout: defaultTimeout, + }, + ennoblementsUseInterfaceFunc: func(since time.Time) bool { + return since.After(time.Now().Add(-23 * time.Hour)) + }, + } + + for _, opt := range opts { + opt(cfg) + } + + return cfg +} + +func WithUserAgent(ua string) ClientOption { + return func(cfg *clientConfig) { + cfg.userAgent = ua + } +} + +func WithHTTPClient(hc *http.Client) ClientOption { + return func(cfg *clientConfig) { + cfg.client = hc + } +} + +// WithEnnoblementsUseInterfaceFunc takes a function that will be called on every Client.GetEnnoblements call +// to determine whether the client should use /interface.php?func=get_conquer_extended or /map/conquer_extended.txt +// The default function checks whether since is after now() - 23h, +// if so, it calls /map/conquer_extended.txt, otherwise /interface.php?func=get_conquer_extended. +func WithEnnoblementsUseInterfaceFunc(f EnnoblementsUseInterfaceFunc) ClientOption { + return func(cfg *clientConfig) { + cfg.ennoblementsUseInterfaceFunc = f + } +} diff --git a/internal/tw/client_test.go b/internal/tw/client_test.go new file mode 100644 index 0000000..7b9ceab --- /dev/null +++ b/internal/tw/client_test.go @@ -0,0 +1,1719 @@ +package tw_test + +import ( + "context" + "encoding/csv" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "gitea.dwysokinski.me/twhelp/corev3/internal/tw" + + "github.com/stretchr/testify/assert" +) + +const testUserAgent = "tribalwarshelp.com/test" + +func TestClient_GetOpenServers(t *testing.T) { + t.Parallel() + + resp, err := os.ReadFile("./testdata/get_servers_resp.txt") + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/backend/get_servers.php", newPlainTextHandler(resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + expectedServerKeys := []string{ + "plp8", + "pl170", + "pl171", + "pl172", + "pl173", + "plc1", + "plp7", + "pl168", + "pl174", + "pl159", + "pl167", + "pl165", + "pl169", + "pls1", + "pl161", + "pl164", + } + + servers, err := newClient(srv.Client()).GetOpenServers(context.Background(), srv.URL) + require.NoError(t, err) + assert.Len(t, servers, len(expectedServerKeys)) + + for _, key := range expectedServerKeys { + found := false + + for _, server := range servers { + if server.Key == key && server.URL == fmt.Sprintf("https://%s.plemiona.pl", key) { + found = true + break + } + } + + assert.True(t, found, "key '%s' not found", key) + } +} + +func TestClient_GetServerConfig(t *testing.T) { + t.Parallel() + + resp, err := os.ReadFile("./testdata/server_config.xml") + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_config", resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + cfg, err := newClient(srv.Client()).GetServerConfig(context.Background(), srv.URL) + require.NoError(t, err) + assert.InEpsilon(t, 4.5, cfg.Speed, 0.01) + assert.InEpsilon(t, 0.5, cfg.UnitSpeed, 0.01) + assert.Equal(t, 1, cfg.Moral) + + assert.Equal(t, 1, cfg.Build.Destroy) + + assert.Equal(t, 2, cfg.Misc.KillRanking) + assert.Equal(t, 0, cfg.Misc.Tutorial) + assert.Equal(t, 300, cfg.Misc.TradeCancelTime) + + assert.Equal(t, 0, cfg.Commands.MillisArrival) + assert.Equal(t, 600, cfg.Commands.CommandCancelTime) + + assert.Equal(t, 3, cfg.Newbie.Days) + assert.Equal(t, 15, cfg.Newbie.RatioDays) + assert.Equal(t, 10, cfg.Newbie.Ratio) + assert.Equal(t, 1, cfg.Newbie.RemoveNewbieVillages) + + assert.Equal(t, 2, cfg.Game.BuildtimeFormula) + assert.Equal(t, 0, cfg.Game.Knight) + assert.Equal(t, 1, cfg.Game.KnightNewItems.Int()) + assert.Equal(t, 1, cfg.Game.Archer) + assert.Equal(t, 2, cfg.Game.Tech) + assert.Equal(t, 0, cfg.Game.FarmLimit) + assert.Equal(t, 0, cfg.Game.Church) + assert.Equal(t, 0, cfg.Game.Watchtower) + assert.Equal(t, 0, cfg.Game.Stronghold) + assert.InEpsilon(t, 0.5, cfg.Game.FakeLimit, 0.01) + assert.InEpsilon(t, 0.001, cfg.Game.BarbarianRise, 0) + assert.Equal(t, 1, cfg.Game.BarbarianShrink) + assert.Equal(t, 800, cfg.Game.BarbarianMaxPoints) + assert.Equal(t, 1, cfg.Game.Scavenging) + assert.Equal(t, 2, cfg.Game.Hauls) + assert.Equal(t, 5000, cfg.Game.HaulsBase) + assert.Equal(t, 1000000, cfg.Game.HaulsMax) + assert.Equal(t, 0, cfg.Game.Event) + assert.Equal(t, 1, cfg.Game.SuppressEvents) + + assert.Equal(t, -1, cfg.Buildings.CustomMain) + assert.Equal(t, -1, cfg.Buildings.CustomFarm) + assert.Equal(t, -1, cfg.Buildings.CustomStorage) + assert.Equal(t, -1, cfg.Buildings.CustomPlace) + assert.Equal(t, -1, cfg.Buildings.CustomBarracks) + assert.Equal(t, -1, cfg.Buildings.CustomChurch) + assert.Equal(t, -1, cfg.Buildings.CustomSmith) + assert.Equal(t, -1, cfg.Buildings.CustomWood) + assert.Equal(t, -1, cfg.Buildings.CustomStone) + assert.Equal(t, -1, cfg.Buildings.CustomIron) + assert.Equal(t, -1, cfg.Buildings.CustomMarket) + assert.Equal(t, -1, cfg.Buildings.CustomStable) + assert.Equal(t, -1, cfg.Buildings.CustomWall) + assert.Equal(t, -1, cfg.Buildings.CustomGarage) + assert.Equal(t, -1, cfg.Buildings.CustomHide) + assert.Equal(t, -1, cfg.Buildings.CustomSnob) + assert.Equal(t, -1, cfg.Buildings.CustomStatue) + assert.Equal(t, -1, cfg.Buildings.CustomWatchtower) + + assert.Equal(t, 1, cfg.Snob.Gold) + assert.Equal(t, 0, cfg.Snob.CheapRebuild) + assert.Equal(t, 2, cfg.Snob.Rise) + assert.Equal(t, 50, cfg.Snob.MaxDist) + assert.InEpsilon(t, 1.0, cfg.Snob.Factor, 0.01) + assert.Equal(t, 28000, cfg.Snob.CoinWood) + assert.Equal(t, 30000, cfg.Snob.CoinStone) + assert.Equal(t, 25000, cfg.Snob.CoinIron) + assert.Equal(t, 0, cfg.Snob.NoBarbConquer) + + assert.Equal(t, 0, cfg.Ally.NoHarm) + assert.Equal(t, 0, cfg.Ally.NoOtherSupport) + assert.Equal(t, 0, cfg.Ally.NoOtherSupportType) + assert.Equal(t, 0, cfg.Ally.AllytimeSupport) + assert.Equal(t, 0, cfg.Ally.NoLeave) + assert.Equal(t, 0, cfg.Ally.NoJoin) + assert.Equal(t, 15, cfg.Ally.Limit) + assert.Equal(t, 0, cfg.Ally.FixedAllies) + assert.Equal(t, 5, cfg.Ally.WarsMemberRequirement) + assert.Equal(t, 15000, cfg.Ally.WarsPointsRequirement) + assert.Equal(t, 7, cfg.Ally.WarsAutoacceptDays) + assert.Equal(t, 1, cfg.Ally.Levels) + assert.Equal(t, "v1", cfg.Ally.XpRequirements) + + assert.Equal(t, 1000, cfg.Coord.MapSize) + assert.Equal(t, 4, cfg.Coord.Func) + assert.Equal(t, 11, cfg.Coord.EmptyVillages) + assert.Equal(t, 1, cfg.Coord.BonusVillages) + assert.Equal(t, 550, cfg.Coord.Inner) + assert.Equal(t, 0, cfg.Coord.SelectStart) + assert.Equal(t, 336, cfg.Coord.VillageMoveWait) + assert.Equal(t, 1, cfg.Coord.NobleRestart) + assert.Equal(t, 1, cfg.Coord.StartVillages) + + assert.Equal(t, 1, cfg.Sitter.Allow) + + assert.Equal(t, 0, cfg.Sleep.Active) + assert.Equal(t, 60, cfg.Sleep.Delay) + assert.Equal(t, 6, cfg.Sleep.Min) + assert.Equal(t, 10, cfg.Sleep.Max) + assert.Equal(t, 12, cfg.Sleep.MinAwake) + assert.Equal(t, 36, cfg.Sleep.MaxAwake) + assert.Equal(t, 10, cfg.Sleep.WarnTime) + + assert.Equal(t, 2, cfg.Night.Active) + assert.Equal(t, 23, cfg.Night.StartHour) + assert.Equal(t, 7, cfg.Night.EndHour) + assert.InEpsilon(t, 3.5, cfg.Night.DefFactor, 0.01) + assert.Equal(t, 14, cfg.Night.Duration) + + assert.Equal(t, 3, cfg.Win.Check) +} + +func TestClient_GetUnitInfo(t *testing.T) { + t.Parallel() + + resp, err := os.ReadFile("./testdata/unit_info.xml") + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_unit_info", resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + unitInfo, err := newClient(srv.Client()).GetUnitInfo(context.Background(), srv.URL) + require.NoError(t, err) + + assert.InEpsilon(t, 226.66666666667, unitInfo.Spear.BuildTime, 0.01) + assert.Equal(t, 1, unitInfo.Spear.Pop) + assert.InEpsilon(t, 8.0, unitInfo.Spear.Speed, 0.01) + assert.Equal(t, 10, unitInfo.Spear.Attack) + assert.Equal(t, 15, unitInfo.Spear.Defense) + assert.Equal(t, 45, unitInfo.Spear.DefenseCavalry) + assert.Equal(t, 20, unitInfo.Spear.DefenseArcher) + assert.Equal(t, 25, unitInfo.Spear.Carry) + + assert.InEpsilon(t, 333.33333333333, unitInfo.Sword.BuildTime, 0.01) + assert.Equal(t, 1, unitInfo.Sword.Pop) + assert.InEpsilon(t, 9.7777777777778, unitInfo.Sword.Speed, 0.01) + assert.Equal(t, 25, unitInfo.Sword.Attack) + assert.Equal(t, 50, unitInfo.Sword.Defense) + assert.Equal(t, 15, unitInfo.Sword.DefenseCavalry) + assert.Equal(t, 40, unitInfo.Sword.DefenseArcher) + assert.Equal(t, 15, unitInfo.Sword.Carry) + + assert.InEpsilon(t, 293.33333333333, unitInfo.Axe.BuildTime, 0.01) + assert.Equal(t, 1, unitInfo.Axe.Pop) + assert.InEpsilon(t, 8.0, unitInfo.Axe.Speed, 0.01) + assert.Equal(t, 40, unitInfo.Axe.Attack) + assert.Equal(t, 10, unitInfo.Axe.Defense) + assert.Equal(t, 5, unitInfo.Axe.DefenseCavalry) + assert.Equal(t, 10, unitInfo.Axe.DefenseArcher) + assert.Equal(t, 10, unitInfo.Axe.Carry) + + assert.InEpsilon(t, 200.0, unitInfo.Spy.BuildTime, 0.01) + assert.Equal(t, 2, unitInfo.Spy.Pop) + assert.InEpsilon(t, 4.0, unitInfo.Spy.Speed, 0.01) + assert.Equal(t, 0, unitInfo.Spy.Attack) + assert.Equal(t, 2, unitInfo.Spy.Defense) + assert.Equal(t, 1, unitInfo.Spy.DefenseCavalry) + assert.Equal(t, 2, unitInfo.Spy.DefenseArcher) + assert.Equal(t, 0, unitInfo.Spy.Carry) + + assert.InEpsilon(t, 400.0, unitInfo.Light.BuildTime, 0.01) + assert.Equal(t, 4, unitInfo.Light.Pop) + assert.InEpsilon(t, 4.4444444444444, unitInfo.Light.Speed, 0.01) + assert.Equal(t, 130, unitInfo.Light.Attack) + assert.Equal(t, 30, unitInfo.Light.Defense) + assert.Equal(t, 40, unitInfo.Light.DefenseCavalry) + assert.Equal(t, 30, unitInfo.Light.DefenseArcher) + assert.Equal(t, 80, unitInfo.Light.Carry) + + assert.InEpsilon(t, 600.0, unitInfo.Marcher.BuildTime, 0.01) + assert.Equal(t, 5, unitInfo.Marcher.Pop) + assert.InEpsilon(t, 4.4444444444444, unitInfo.Marcher.Speed, 0.01) + assert.Equal(t, 120, unitInfo.Marcher.Attack) + assert.Equal(t, 40, unitInfo.Marcher.Defense) + assert.Equal(t, 30, unitInfo.Marcher.DefenseCavalry) + assert.Equal(t, 50, unitInfo.Marcher.DefenseArcher) + assert.Equal(t, 50, unitInfo.Marcher.Carry) + + assert.InEpsilon(t, 800.0, unitInfo.Heavy.BuildTime, 0.01) + assert.Equal(t, 6, unitInfo.Heavy.Pop) + assert.InEpsilon(t, 4.8888888888889, unitInfo.Heavy.Speed, 0.01) + assert.Equal(t, 150, unitInfo.Heavy.Attack) + assert.Equal(t, 200, unitInfo.Heavy.Defense) + assert.Equal(t, 80, unitInfo.Heavy.DefenseCavalry) + assert.Equal(t, 180, unitInfo.Heavy.DefenseArcher) + assert.Equal(t, 50, unitInfo.Heavy.Carry) + + assert.InEpsilon(t, 1066.6666666667, unitInfo.Ram.BuildTime, 0.01) + assert.Equal(t, 5, unitInfo.Ram.Pop) + assert.InEpsilon(t, 13.333333333333, unitInfo.Ram.Speed, 0.01) + assert.Equal(t, 2, unitInfo.Ram.Attack) + assert.Equal(t, 20, unitInfo.Ram.Defense) + assert.Equal(t, 50, unitInfo.Ram.DefenseCavalry) + assert.Equal(t, 20, unitInfo.Ram.DefenseArcher) + assert.Equal(t, 0, unitInfo.Ram.Carry) + + assert.InEpsilon(t, 1600.0, unitInfo.Catapult.BuildTime, 0.01) + assert.Equal(t, 8, unitInfo.Catapult.Pop) + assert.InEpsilon(t, 13.333333333333, unitInfo.Catapult.Speed, 0.01) + assert.Equal(t, 100, unitInfo.Catapult.Attack) + assert.Equal(t, 100, unitInfo.Catapult.Defense) + assert.Equal(t, 50, unitInfo.Catapult.DefenseCavalry) + assert.Equal(t, 100, unitInfo.Catapult.DefenseArcher) + assert.Equal(t, 0, unitInfo.Catapult.Carry) + + assert.InEpsilon(t, 4000.0, unitInfo.Snob.BuildTime, 0.01) + assert.Equal(t, 100, unitInfo.Snob.Pop) + assert.InEpsilon(t, 15.555555555556, unitInfo.Snob.Speed, 0.01) + assert.Equal(t, 30, unitInfo.Snob.Attack) + assert.Equal(t, 100, unitInfo.Snob.Defense) + assert.Equal(t, 50, unitInfo.Snob.DefenseCavalry) + assert.Equal(t, 100, unitInfo.Snob.DefenseArcher) + assert.Equal(t, 0, unitInfo.Snob.Carry) + + assert.InEpsilon(t, 1.0, unitInfo.Militia.BuildTime, 0.01) + assert.Equal(t, 0, unitInfo.Militia.Pop) + assert.InEpsilon(t, 0.016666666666667, unitInfo.Militia.Speed, 0.01) + assert.Equal(t, 0, unitInfo.Militia.Attack) + assert.Equal(t, 15, unitInfo.Militia.Defense) + assert.Equal(t, 45, unitInfo.Militia.DefenseCavalry) + assert.Equal(t, 25, unitInfo.Militia.DefenseArcher) + assert.Equal(t, 0, unitInfo.Militia.Carry) +} + +func TestClient_GetBuildingInfo(t *testing.T) { + t.Parallel() + + resp, err := os.ReadFile("./testdata/building_info.xml") + require.NoError(t, err) + + mux := http.NewServeMux() + mux.HandleFunc("/interface.php", newInterfaceXMLHandler("get_building_info", resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + buildingInfo, err := newClient(srv.Client()).GetBuildingInfo(context.Background(), srv.URL) + require.NoError(t, err) + + assert.Equal(t, 30, buildingInfo.Main.MaxLevel) + assert.Equal(t, 1, buildingInfo.Main.MinLevel) + assert.Equal(t, 90, buildingInfo.Main.Wood) + assert.Equal(t, 80, buildingInfo.Main.Stone) + assert.Equal(t, 70, buildingInfo.Main.Iron) + assert.Equal(t, 5, buildingInfo.Main.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Main.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Main.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Main.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Main.PopFactor, 0.01) + assert.InEpsilon(t, 900.0, buildingInfo.Main.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Main.BuildTimeFactor, 0.01) + + assert.Equal(t, 25, buildingInfo.Barracks.MaxLevel) + assert.Equal(t, 0, buildingInfo.Barracks.MinLevel) + assert.Equal(t, 200, buildingInfo.Barracks.Wood) + assert.Equal(t, 170, buildingInfo.Barracks.Stone) + assert.Equal(t, 90, buildingInfo.Barracks.Iron) + assert.Equal(t, 7, buildingInfo.Barracks.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Barracks.WoodFactor, 0.01) + assert.InEpsilon(t, 1.28, buildingInfo.Barracks.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Barracks.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Barracks.PopFactor, 0.01) + assert.InEpsilon(t, 1800.0, buildingInfo.Barracks.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Barracks.BuildTimeFactor, 0.01) + + assert.Equal(t, 20, buildingInfo.Stable.MaxLevel) + assert.Equal(t, 0, buildingInfo.Stable.MinLevel) + assert.Equal(t, 270, buildingInfo.Stable.Wood) + assert.Equal(t, 240, buildingInfo.Stable.Stone) + assert.Equal(t, 260, buildingInfo.Stable.Iron) + assert.Equal(t, 8, buildingInfo.Stable.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Stable.WoodFactor, 0.01) + assert.InEpsilon(t, 1.28, buildingInfo.Stable.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Stable.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Stable.PopFactor, 0.01) + assert.InEpsilon(t, 6000.0, buildingInfo.Stable.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Stable.BuildTimeFactor, 0.01) + + assert.Equal(t, 15, buildingInfo.Garage.MaxLevel) + assert.Equal(t, 0, buildingInfo.Garage.MinLevel) + assert.Equal(t, 300, buildingInfo.Garage.Wood) + assert.Equal(t, 240, buildingInfo.Garage.Stone) + assert.Equal(t, 260, buildingInfo.Garage.Iron) + assert.Equal(t, 8, buildingInfo.Garage.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Garage.WoodFactor, 0.01) + assert.InEpsilon(t, 1.28, buildingInfo.Garage.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Garage.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Garage.PopFactor, 0.01) + assert.InEpsilon(t, 6000.0, buildingInfo.Garage.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Garage.BuildTimeFactor, 0.01) + + assert.Equal(t, 20, buildingInfo.Watchtower.MaxLevel) + assert.Equal(t, 0, buildingInfo.Watchtower.MinLevel) + assert.Equal(t, 12000, buildingInfo.Watchtower.Wood) + assert.Equal(t, 14000, buildingInfo.Watchtower.Stone) + assert.Equal(t, 10000, buildingInfo.Watchtower.Iron) + assert.Equal(t, 500, buildingInfo.Watchtower.Pop) + assert.InEpsilon(t, 1.17, buildingInfo.Watchtower.WoodFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Watchtower.StoneFactor, 0.01) + assert.InEpsilon(t, 1.18, buildingInfo.Watchtower.IronFactor, 0.01) + assert.InEpsilon(t, 1.18, buildingInfo.Watchtower.PopFactor, 0.01) + assert.InEpsilon(t, 13200.0, buildingInfo.Watchtower.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Watchtower.BuildTimeFactor, 0.01) + + assert.Equal(t, 1, buildingInfo.Snob.MaxLevel) + assert.Equal(t, 0, buildingInfo.Snob.MinLevel) + assert.Equal(t, 15000, buildingInfo.Snob.Wood) + assert.Equal(t, 25000, buildingInfo.Snob.Stone) + assert.Equal(t, 10000, buildingInfo.Snob.Iron) + assert.Equal(t, 80, buildingInfo.Snob.Pop) + assert.InEpsilon(t, 2.0, buildingInfo.Snob.WoodFactor, 0.01) + assert.InEpsilon(t, 2.0, buildingInfo.Snob.StoneFactor, 0.01) + assert.InEpsilon(t, 2.0, buildingInfo.Snob.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Snob.PopFactor, 0.01) + assert.InEpsilon(t, 586800.0, buildingInfo.Snob.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Snob.BuildTimeFactor, 0.01) + + assert.Equal(t, 20, buildingInfo.Smith.MaxLevel) + assert.Equal(t, 0, buildingInfo.Smith.MinLevel) + assert.Equal(t, 220, buildingInfo.Smith.Wood) + assert.Equal(t, 180, buildingInfo.Smith.Stone) + assert.Equal(t, 240, buildingInfo.Smith.Iron) + assert.Equal(t, 20, buildingInfo.Smith.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Smith.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Smith.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Smith.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Smith.PopFactor, 0.01) + assert.InEpsilon(t, 6000.0, buildingInfo.Smith.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Smith.BuildTimeFactor, 0.01) + + assert.Equal(t, 1, buildingInfo.Place.MaxLevel) + assert.Equal(t, 0, buildingInfo.Place.MinLevel) + assert.Equal(t, 10, buildingInfo.Place.Wood) + assert.Equal(t, 40, buildingInfo.Place.Stone) + assert.Equal(t, 30, buildingInfo.Place.Iron) + assert.Equal(t, 0, buildingInfo.Place.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Place.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Place.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Place.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Place.PopFactor, 0.01) + assert.InEpsilon(t, 10860.0, buildingInfo.Place.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Place.BuildTimeFactor, 0.01) + + assert.Equal(t, 1, buildingInfo.Statue.MaxLevel) + assert.Equal(t, 0, buildingInfo.Statue.MinLevel) + assert.Equal(t, 220, buildingInfo.Statue.Wood) + assert.Equal(t, 220, buildingInfo.Statue.Stone) + assert.Equal(t, 220, buildingInfo.Statue.Iron) + assert.Equal(t, 10, buildingInfo.Statue.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Statue.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Statue.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Statue.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Statue.PopFactor, 0.01) + assert.InEpsilon(t, 1500.0, buildingInfo.Statue.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Statue.BuildTimeFactor, 0.01) + + assert.Equal(t, 25, buildingInfo.Market.MaxLevel) + assert.Equal(t, 0, buildingInfo.Market.MinLevel) + assert.Equal(t, 100, buildingInfo.Market.Wood) + assert.Equal(t, 100, buildingInfo.Market.Stone) + assert.Equal(t, 100, buildingInfo.Market.Iron) + assert.Equal(t, 20, buildingInfo.Market.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Market.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Market.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Market.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Market.PopFactor, 0.01) + assert.InEpsilon(t, 2700.0, buildingInfo.Market.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Market.BuildTimeFactor, 0.01) + + assert.Equal(t, 30, buildingInfo.Wood.MaxLevel) + assert.Equal(t, 0, buildingInfo.Wood.MinLevel) + assert.Equal(t, 50, buildingInfo.Wood.Wood) + assert.Equal(t, 60, buildingInfo.Wood.Stone) + assert.Equal(t, 40, buildingInfo.Wood.Iron) + assert.Equal(t, 5, buildingInfo.Wood.Pop) + assert.InEpsilon(t, 1.25, buildingInfo.Wood.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Wood.StoneFactor, 0.01) + assert.InEpsilon(t, 1.245, buildingInfo.Wood.IronFactor, 0.01) + assert.InEpsilon(t, 1.155, buildingInfo.Wood.PopFactor, 0.01) + assert.InEpsilon(t, 900.0, buildingInfo.Wood.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Wood.BuildTimeFactor, 0.01) + + assert.Equal(t, 30, buildingInfo.Stone.MaxLevel) + assert.Equal(t, 0, buildingInfo.Stone.MinLevel) + assert.Equal(t, 65, buildingInfo.Stone.Wood) + assert.Equal(t, 50, buildingInfo.Stone.Stone) + assert.Equal(t, 40, buildingInfo.Stone.Iron) + assert.Equal(t, 10, buildingInfo.Stone.Pop) + assert.InEpsilon(t, 1.27, buildingInfo.Stone.WoodFactor, 0.01) + assert.InEpsilon(t, 1.265, buildingInfo.Stone.StoneFactor, 0.01) + assert.InEpsilon(t, 1.24, buildingInfo.Stone.IronFactor, 0.01) + assert.InEpsilon(t, 1.14, buildingInfo.Stone.PopFactor, 0.01) + assert.InEpsilon(t, 900.0, buildingInfo.Stone.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Stone.BuildTimeFactor, 0.01) + + assert.Equal(t, 30, buildingInfo.Iron.MaxLevel) + assert.Equal(t, 0, buildingInfo.Iron.MinLevel) + assert.Equal(t, 75, buildingInfo.Iron.Wood) + assert.Equal(t, 65, buildingInfo.Iron.Stone) + assert.Equal(t, 70, buildingInfo.Iron.Iron) + assert.Equal(t, 10, buildingInfo.Iron.Pop) + assert.InEpsilon(t, 1.252, buildingInfo.Iron.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Iron.StoneFactor, 0.01) + assert.InEpsilon(t, 1.24, buildingInfo.Iron.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Iron.PopFactor, 0.01) + assert.InEpsilon(t, 1080.0, buildingInfo.Iron.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Iron.BuildTimeFactor, 0.01) + + assert.Equal(t, 30, buildingInfo.Farm.MaxLevel) + assert.Equal(t, 1, buildingInfo.Farm.MinLevel) + assert.Equal(t, 45, buildingInfo.Farm.Wood) + assert.Equal(t, 40, buildingInfo.Farm.Stone) + assert.Equal(t, 30, buildingInfo.Farm.Iron) + assert.Equal(t, 0, buildingInfo.Farm.Pop) + assert.InEpsilon(t, 1.3, buildingInfo.Farm.WoodFactor, 0.01) + assert.InEpsilon(t, 1.32, buildingInfo.Farm.StoneFactor, 0.01) + assert.InEpsilon(t, 1.29, buildingInfo.Farm.IronFactor, 0.01) + assert.InEpsilon(t, 1.0, buildingInfo.Farm.PopFactor, 0.01) + assert.InEpsilon(t, 1200.0, buildingInfo.Farm.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Farm.BuildTimeFactor, 0.01) + + assert.Equal(t, 30, buildingInfo.Storage.MaxLevel) + assert.Equal(t, 1, buildingInfo.Storage.MinLevel) + assert.Equal(t, 60, buildingInfo.Storage.Wood) + assert.Equal(t, 50, buildingInfo.Storage.Stone) + assert.Equal(t, 40, buildingInfo.Storage.Iron) + assert.Equal(t, 0, buildingInfo.Storage.Pop) + assert.InEpsilon(t, 1.265, buildingInfo.Storage.WoodFactor, 0.01) + assert.InEpsilon(t, 1.27, buildingInfo.Storage.StoneFactor, 0.01) + assert.InEpsilon(t, 1.245, buildingInfo.Storage.IronFactor, 0.01) + assert.InEpsilon(t, 1.15, buildingInfo.Storage.PopFactor, 0.01) + assert.InEpsilon(t, 1020.0, buildingInfo.Storage.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Storage.BuildTimeFactor, 0.01) + + assert.Equal(t, 10, buildingInfo.Hide.MaxLevel) + assert.Equal(t, 0, buildingInfo.Hide.MinLevel) + assert.Equal(t, 50, buildingInfo.Hide.Wood) + assert.Equal(t, 60, buildingInfo.Hide.Stone) + assert.Equal(t, 50, buildingInfo.Hide.Iron) + assert.Equal(t, 2, buildingInfo.Hide.Pop) + assert.InEpsilon(t, 1.25, buildingInfo.Hide.WoodFactor, 0.01) + assert.InEpsilon(t, 1.25, buildingInfo.Hide.StoneFactor, 0.01) + assert.InEpsilon(t, 1.25, buildingInfo.Hide.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Hide.PopFactor, 0.01) + assert.InEpsilon(t, 1800.0, buildingInfo.Hide.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Hide.BuildTimeFactor, 0.01) + + assert.Equal(t, 20, buildingInfo.Wall.MaxLevel) + assert.Equal(t, 0, buildingInfo.Wall.MinLevel) + assert.Equal(t, 50, buildingInfo.Wall.Wood) + assert.Equal(t, 100, buildingInfo.Wall.Stone) + assert.Equal(t, 20, buildingInfo.Wall.Iron) + assert.Equal(t, 5, buildingInfo.Wall.Pop) + assert.InEpsilon(t, 1.26, buildingInfo.Wall.WoodFactor, 0.01) + assert.InEpsilon(t, 1.275, buildingInfo.Wall.StoneFactor, 0.01) + assert.InEpsilon(t, 1.26, buildingInfo.Wall.IronFactor, 0.01) + assert.InEpsilon(t, 1.17, buildingInfo.Wall.PopFactor, 0.01) + assert.InEpsilon(t, 3600.0, buildingInfo.Wall.BuildTime, 0.01) + assert.InEpsilon(t, 1.2, buildingInfo.Wall.BuildTimeFactor, 0.01) +} + +func TestClient_GetTribes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + respTribe string + respOD string + respODA string + respODD string + checkErr func(err error) bool + expectedTribes func(url string) []tw.Tribe + }{ + { + name: "OK", + respTribe: "1,name%201,tag%201,100,101,102,103,104\n2,name2,tag2,200,202,202,203,204\n3,name3,tag3,300,303,302,303,304\n4,name4,tag4,400,404,402,403,404", //nolint:lll + respOD: "1,1,1\n2,2,2\n3,3,3", + respODA: "1,1,1\n2,2,2\n3,3,3", + respODD: "1,1,1\n2,2,2\n3,3,3", + expectedTribes: func(url string) []tw.Tribe { + return []tw.Tribe{ + { + ID: 1, + Name: "name 1", + Tag: "tag 1", + ProfileURL: url + "/game.php?screen=info_ally&id=1", + NumMembers: 100, + NumVillages: 101, + Points: 102, + AllPoints: 103, + Rank: 104, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 1, + ScoreAtt: 1, + RankDef: 1, + ScoreDef: 1, + RankSup: 0, + ScoreSup: 0, + RankTotal: 1, + ScoreTotal: 1, + }, + }, + { + ID: 2, + Name: "name2", + Tag: "tag2", + ProfileURL: url + "/game.php?screen=info_ally&id=2", + NumMembers: 200, + NumVillages: 202, + Points: 202, + AllPoints: 203, + Rank: 204, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 2, + ScoreAtt: 2, + RankDef: 2, + ScoreDef: 2, + RankSup: 0, + ScoreSup: 0, + RankTotal: 2, + ScoreTotal: 2, + }, + }, + { + ID: 3, + Name: "name3", + Tag: "tag3", + ProfileURL: url + "/game.php?screen=info_ally&id=3", + NumMembers: 300, + NumVillages: 303, + Points: 302, + AllPoints: 303, + Rank: 304, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 3, + ScoreAtt: 3, + RankDef: 3, + ScoreDef: 3, + RankSup: 0, + ScoreSup: 0, + RankTotal: 3, + ScoreTotal: 3, + }, + }, + { + ID: 4, + Name: "name4", + Tag: "tag4", + ProfileURL: url + "/game.php?screen=info_ally&id=4", + NumMembers: 400, + NumVillages: 404, + Points: 402, + AllPoints: 403, + Rank: 404, + OpponentsDefeated: tw.OpponentsDefeated{}, + }, + } + }, + }, + { + name: "ERR: OD.Rank can't be converted to int64", + respOD: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: OD.ID -can't be converted to int64", + respOD: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: OD.Score can't be converted to int64", + respOD: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.Rank can't be converted to int64", + respODA: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.ID can't be converted to int64", + respODA: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.Score can't be converted to int64", + respODA: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.Rank can't be converted to int64", + respODD: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.ID can't be converted to int64", + respODD: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.Score can't be converted to int64", + respODD: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: too few fields", + respTribe: "1,name%201,tag%201,100,101,102,103", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: too many fields", + respTribe: "1,name%201,tag%201,100,101,102,103,104,105", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: Tribe.ID can't be converted to int64", + respTribe: "1.23,name1,tag1,100,101,102,103,104", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Tribe.NumMembers can't be converted to int64", + respTribe: "1,name1,tag1,100.23,101,102,103,104", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.NumMembers" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Tribe.NumVillages can't be converted to int64", + respTribe: "1,name1,tag1,100,101.23,102,103,104", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.NumVillages" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Tribe.Points can't be converted to int64", + respTribe: "1,name1,tag1,100,101,102.23,103,104", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.Points" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Tribe.AllPoints can't be converted to int64", + respTribe: "1,name1,tag1,100,101,102,103.23,104", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.AllPoints" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Tribe.Rank can't be converted to int64", + respTribe: "1,name1,tag1,100,101,102,103,104.23", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Tribe.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/map/kill_all_tribe.txt", newPlainTextHandlerString(tt.respOD)) + mux.HandleFunc("/map/kill_att_tribe.txt", newPlainTextHandlerString(tt.respODA)) + mux.HandleFunc("/map/kill_def_tribe.txt", newPlainTextHandlerString(tt.respODD)) + mux.HandleFunc("/map/ally.txt", newPlainTextHandlerString(tt.respTribe)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + tribes, err := newClient(srv.Client()).GetTribes(context.Background(), srv.URL) + + checkErr := func(err error) bool { + return errors.Is(err, nil) + } + if tt.checkErr != nil { + checkErr = tt.checkErr + } + + var expectedTribes []tw.Tribe + if tt.expectedTribes != nil { + expectedTribes = tt.expectedTribes(srv.URL) + } + + assert.True(t, checkErr(err)) + require.Len(t, tribes, len(expectedTribes)) + for i, tribe := range tribes { + assert.Equal(t, expectedTribes[i], tribe) + } + }) + } +} + +func TestClient_GetPlayers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + respPlayer string + respOD string + respODA string + respODD string + respODS string + checkErr func(err error) bool + expectedPlayers func(url string) []tw.Player + }{ + { + name: "OK", + respPlayer: "1,name%201,123,124,125,126\n2,name2,256,257,258,259\n3,name3,356,357,358,359\n4,name4,456,457,458,459", + respOD: "1,1,1001\n2,2,252\n3,3,125", + respODA: "1,1,1000\n2,2,253\n3,3,100", + respODD: "1,1,1002\n2,2,251\n3,3,155", + respODS: "1,1,1003\n2,2,250\n3,3,166", + expectedPlayers: func(url string) []tw.Player { + return []tw.Player{ + { + ID: 1, + Name: "name 1", + ProfileURL: url + "/game.php?screen=info_player&id=1", + TribeID: 123, + NumVillages: 124, + Points: 125, + Rank: 126, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 1, + ScoreAtt: 1000, + RankDef: 1, + ScoreDef: 1002, + RankSup: 1, + ScoreSup: 1003, + RankTotal: 1, + ScoreTotal: 1001, + }, + }, + { + ID: 2, + Name: "name2", + ProfileURL: url + "/game.php?screen=info_player&id=2", + TribeID: 256, + NumVillages: 257, + Points: 258, + Rank: 259, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 2, + ScoreAtt: 253, + RankDef: 2, + ScoreDef: 251, + RankSup: 2, + ScoreSup: 250, + RankTotal: 2, + ScoreTotal: 252, + }, + }, + { + ID: 3, + Name: "name3", + ProfileURL: url + "/game.php?screen=info_player&id=3", + TribeID: 356, + NumVillages: 357, + Points: 358, + Rank: 359, + OpponentsDefeated: tw.OpponentsDefeated{ + RankAtt: 3, + ScoreAtt: 100, + RankDef: 3, + ScoreDef: 155, + RankSup: 3, + ScoreSup: 166, + RankTotal: 3, + ScoreTotal: 125, + }, + }, + { + ID: 4, + Name: "name4", + ProfileURL: url + "/game.php?screen=info_player&id=4", + TribeID: 456, + NumVillages: 457, + Points: 458, + Rank: 459, + OpponentsDefeated: tw.OpponentsDefeated{}, + }, + } + }, + }, + { + name: "ERR: OD.Rank can't be converted to int64", + respOD: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: OD.ID can't be converted to int64", + respOD: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: OD.Score can't be converted to int64", + respOD: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.Rank can't be converted to int64", + respODA: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.ID can't be converted to int64", + respODA: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODA.Score can't be converted to int64", + respODA: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.Rank can't be converted to int64", + respODD: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.ID can't be converted to int64", + respODD: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODD.Score can't be converted to int64", + respODD: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODS.Rank can't be converted to int64", + respODS: "test,1,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODS.ID can't be converted to int64", + respODS: "1,test,1001", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: ODS.Score can't be converted to int64", + respODS: "1,1,test", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "odRecord.Score" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: too few fields", + respPlayer: "1,name%201,123,124,125", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: too many fields", + respPlayer: "1,name%201,123,124,125,126,127", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: Player.ID can't be converted to int64", + respPlayer: "1.25,name,123,124,125,126", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Player.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Player.TribeID can't be converted to int64", + respPlayer: "1,name,1.23,124,125,126", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Player.TribeID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Player.NumVillages can't be converted to int64", + respPlayer: "1,name,123,1.24,125,126", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Player.NumVillages" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Player.Points can't be converted to int64", + respPlayer: "1,name,123,124,1.25,126", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Player.Points" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Player.Rank can't be converted to int64", + respPlayer: "1,name,123,124,125,1.26", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Player.Rank" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/map/kill_all.txt", newPlainTextHandlerString(tt.respOD)) + mux.HandleFunc("/map/kill_att.txt", newPlainTextHandlerString(tt.respODA)) + mux.HandleFunc("/map/kill_def.txt", newPlainTextHandlerString(tt.respODD)) + mux.HandleFunc("/map/kill_sup.txt", newPlainTextHandlerString(tt.respODS)) + mux.HandleFunc("/map/player.txt", newPlainTextHandlerString(tt.respPlayer)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + players, err := newClient(srv.Client()).GetPlayers(context.Background(), srv.URL) + + checkErr := func(err error) bool { + return errors.Is(err, nil) + } + if tt.checkErr != nil { + checkErr = tt.checkErr + } + + var expectedPlayers []tw.Player + if tt.expectedPlayers != nil { + expectedPlayers = tt.expectedPlayers(srv.URL) + } + + assert.True(t, checkErr(err)) + require.Len(t, players, len(expectedPlayers)) + for i, player := range players { + assert.Equal(t, expectedPlayers[i], player) + } + }) + } +} + +func TestClient_GetVillages(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resp string + checkErr func(err error) bool + expectedVillages func(url string) []tw.Village + }{ + { + name: "OK", + //nolint:lll + resp: "123,village%201,500,501,502,503,504\n124,village 2,100,201,102,103,104\n125,village%203,1,501,502,503,504\n126,village%204,1,1,502,503,504\n127,village%205,103,1,502,503,504", + expectedVillages: func(url string) []tw.Village { + return []tw.Village{ + { + ID: 123, + Name: "village 1", + ProfileURL: url + "/game.php?screen=info_village&id=123", + X: 500, + Y: 501, + Continent: "K55", + PlayerID: 502, + Points: 503, + Bonus: 504, + }, + { + ID: 124, + Name: "village 2", + ProfileURL: url + "/game.php?screen=info_village&id=124", + X: 100, + Y: 201, + Continent: "K21", + PlayerID: 102, + Points: 103, + Bonus: 104, + }, + { + ID: 125, + Name: "village 3", + ProfileURL: url + "/game.php?screen=info_village&id=125", + X: 1, + Y: 501, + Continent: "K50", + PlayerID: 502, + Points: 503, + Bonus: 504, + }, + { + ID: 126, + Name: "village 4", + ProfileURL: url + "/game.php?screen=info_village&id=126", + X: 1, + Y: 1, + Continent: "K0", + PlayerID: 502, + Points: 503, + Bonus: 504, + }, + { + ID: 127, + Name: "village 5", + ProfileURL: url + "/game.php?screen=info_village&id=127", + X: 103, + Y: 1, + Continent: "K1", + PlayerID: 502, + Points: 503, + Bonus: 504, + }, + } + }, + }, + { + name: "ERR: too few fields", + resp: "123,village 1,500,501,502,503", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: too many fields", + resp: "123,village 1,500,501,502,503,504,505", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: Village.ID can't be converted to int64", + resp: "123.23,village 1,500,501,502,503,504", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.ID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Village.X can't be converted to int64", + resp: "123,village 1,123.23,501,502,503,504", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.X" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Village.Y can't be converted to int64", + resp: "123,village 1,500,123.23,502,503,504", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.Y" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Village.PlayerID can't be converted to int64", + resp: "123,village 1,500,501,123.23,503,504", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.PlayerID" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Village.Points can't be converted to int64", + resp: "123,village 1,500,501,502,123.23,504", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.Points" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Village.Bonus can't be converted to int64", + resp: "123,village 1,500,501,502,503,123.23", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && parseErr.Field == "Village.Bonus" && errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/map/village.txt", newPlainTextHandlerString(tt.resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + villages, err := newClient(srv.Client()).GetVillages(context.Background(), srv.URL) + + checkErr := func(err error) bool { + return errors.Is(err, nil) + } + if tt.checkErr != nil { + checkErr = tt.checkErr + } + + var expectedVillages []tw.Village + if tt.expectedVillages != nil { + expectedVillages = tt.expectedVillages(srv.URL) + } + + assert.True(t, checkErr(err)) + require.Len(t, villages, len(expectedVillages)) + for i, village := range villages { + assert.Equal(t, expectedVillages[i], village) + } + }) + } +} + +func TestClient_GetEnnoblements(t *testing.T) { + t.Parallel() + + t.Run("map/conquer_extended.txt", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + since time.Time + resp string + checkErr func(err error) bool + expectedEnnoblements []tw.Ennoblement + }{ + { + name: "OK", + since: time.Unix(1657842401, 0), + //nolint:lll + resp: "1424,1657842406,698953084,699589674,0,122,380\n3150,1657882879,6461674,0,0,179,111\n1025,1657947400,9157005,698942601,0,52,461\n1025,1657842400,9157005,698942601,0,52,461", + checkErr: nil, + expectedEnnoblements: []tw.Ennoblement{ // the last one should be skipped (1657842401 > 1657842400) + { + VillageID: 1424, + NewOwnerID: 698953084, + NewTribeID: 122, + OldOwnerID: 699589674, + OldTribeID: 0, + Points: 380, + CreatedAt: time.Unix(1657842406, 0), + }, + { + VillageID: 3150, + NewOwnerID: 6461674, + NewTribeID: 179, + OldOwnerID: 0, + OldTribeID: 0, + Points: 111, + CreatedAt: time.Unix(1657882879, 0), + }, + { + VillageID: 1025, + NewOwnerID: 9157005, + NewTribeID: 52, + OldOwnerID: 698942601, + OldTribeID: 0, + Points: 461, + CreatedAt: time.Unix(1657947400, 0), + }, + }, + }, + { + name: "ERR: too few fields", + resp: "1424,1657842406,698953084,699589674,0,122", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: too many fields", + resp: "1424,1657842406,698953084,699589674,0,122,380,0", + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: Ennoblement.VillageID can't be converted to int64", + resp: "asd,1657842406,698953084,699589674,0,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.VillageID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.VillageID can't be converted to int64", + resp: "1424,asd,698953084,699589674,0,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.CreatedAt" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.NewOwnerID can't be converted to int64", + resp: "1424,1657842406,asd,699589674,0,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.NewOwnerID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.OldOwnerID can't be converted to int64", + resp: "1424,1657842406,698953084,asd,0,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.OldOwnerID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.OldTribeID can't be converted to int64", + resp: "1424,1657842406,698953084,699589674,asd,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.OldTribeID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.NewTribeID can't be converted to int64", + resp: "1424,1657842406,698953084,699589674,0,asd,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.NewTribeID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.Points can't be converted to int64", + resp: "1424,1657842406,698953084,699589674,0,122,asd", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.Points" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/map/conquer_extended.txt", newPlainTextHandlerString(tt.resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + ennoblements, err := newClient(srv.Client()).GetEnnoblements(context.Background(), srv.URL, tt.since) + + checkErr := func(err error) bool { + return errors.Is(err, nil) + } + if tt.checkErr != nil { + checkErr = tt.checkErr + } + + assert.True(t, checkErr(err)) + require.Len(t, ennoblements, len(tt.expectedEnnoblements)) + for i, ennoblement := range ennoblements { + assert.Equal(t, tt.expectedEnnoblements[i], ennoblement) + } + }) + } + }) + + t.Run("interface.php?func=get_conquer_extended", func(t *testing.T) { + t.Parallel() + + now := time.Now() + tests := []struct { + name string + since time.Time + resp string + checkErr func(err error) bool + clientOpts []tw.ClientOption + expectedEnnoblements []tw.Ennoblement + }{ + { + name: "OK: without custom ennoblementsUseInterfaceFunc", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf( + "1424,%d,698953084,699589674,0,122,380\n3150,%d,6461674,0,0,179,111\n1025,%d,9157005,698942601,0,52,461", + now.Add(-21*time.Hour).Unix(), + now.Add(-15*time.Minute).Unix(), + now.Add(-15*time.Second).Unix(), + ), + checkErr: nil, + expectedEnnoblements: []tw.Ennoblement{ + { + VillageID: 1424, + NewOwnerID: 698953084, + NewTribeID: 122, + OldOwnerID: 699589674, + OldTribeID: 0, + Points: 380, + CreatedAt: time.Unix(now.Add(-21*time.Hour).Unix(), 0), + }, + { + VillageID: 3150, + NewOwnerID: 6461674, + NewTribeID: 179, + OldOwnerID: 0, + OldTribeID: 0, + Points: 111, + CreatedAt: time.Unix(now.Add(-15*time.Minute).Unix(), 0), + }, + { + VillageID: 1025, + NewOwnerID: 9157005, + NewTribeID: 52, + OldOwnerID: 698942601, + OldTribeID: 0, + Points: 461, + CreatedAt: time.Unix(now.Add(-15*time.Second).Unix(), 0), + }, + }, + }, + { + name: "OK: with custom ennoblementsUseInterfaceFunc", + since: now.Add(-47 * time.Hour), + resp: fmt.Sprintf( + "1424,%d,698953084,699589674,0,122,380\n3150,%d,6461674,0,0,179,111\n1025,%d,9157005,698942601,0,52,461", + now.Add(-44*time.Hour).Unix(), + now.Add(-40*time.Minute).Unix(), + now.Add(-40*time.Second).Unix(), + ), + checkErr: nil, + clientOpts: []tw.ClientOption{ + tw.WithEnnoblementsUseInterfaceFunc(func(since time.Time) bool { + return since.After(time.Now().Add(-48 * time.Hour)) + }), + }, + expectedEnnoblements: []tw.Ennoblement{ + { + VillageID: 1424, + NewOwnerID: 698953084, + NewTribeID: 122, + OldOwnerID: 699589674, + OldTribeID: 0, + Points: 380, + CreatedAt: time.Unix(now.Add(-44*time.Hour).Unix(), 0), + }, + { + VillageID: 3150, + NewOwnerID: 6461674, + NewTribeID: 179, + OldOwnerID: 0, + OldTribeID: 0, + Points: 111, + CreatedAt: time.Unix(now.Add(-40*time.Minute).Unix(), 0), + }, + { + VillageID: 1025, + NewOwnerID: 9157005, + NewTribeID: 52, + OldOwnerID: 698942601, + OldTribeID: 0, + Points: 461, + CreatedAt: time.Unix(now.Add(-40*time.Second).Unix(), 0), + }, + }, + }, + { + name: "ERR: too few fields", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,699589674,0,122", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: too many fields", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,699589674,0,122,380,0", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + return errors.Is(err, csv.ErrFieldCount) + }, + }, + { + name: "ERR: Ennoblement.VillageID can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("asd,%d,698953084,699589674,0,122,380", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.VillageID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.CreatedAt can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: "1424,asd,698953084,699589674,0,122,380", + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.CreatedAt" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.NewOwnerID can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,asd,699589674,0,122,380", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.NewOwnerID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.OldOwnerID can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,asd,0,122,380", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.OldOwnerID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.OldTribeID can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,699589674,asd,122,380", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.OldTribeID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.NewTribeID can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,699589674,0,asd,380", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.NewTribeID" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + { + name: "ERR: Ennoblement.Points can't be converted to int64", + since: now.Add(-22 * time.Hour), + resp: fmt.Sprintf("1424,%d,698953084,699589674,0,122,asd", now.Add(-21*time.Hour).Unix()), + checkErr: func(err error) bool { + var parseErr tw.ParseError + return errors.As(err, &parseErr) && + parseErr.Field == "Ennoblement.Points" && + errors.Is(parseErr, strconv.ErrSyntax) + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/interface.php", newInterfacePlainTextHandlerString("get_conquer_extended", tt.resp)) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + + client := newClient(srv.Client(), tt.clientOpts...) + + ennoblements, err := client.GetEnnoblements(context.Background(), srv.URL, tt.since) + + checkErr := func(err error) bool { + return errors.Is(err, nil) + } + if tt.checkErr != nil { + checkErr = tt.checkErr + } + + assert.True(t, checkErr(err)) + require.Len(t, ennoblements, len(tt.expectedEnnoblements)) + for i, ennoblement := range ennoblements { + assert.Equal(t, tt.expectedEnnoblements[i], ennoblement) + } + }) + } + }) +} + +// user agent and http client can't be overridden via opts +func newClient(hc *http.Client, opts ...tw.ClientOption) *tw.Client { + return tw.NewClient(append(opts, tw.WithHTTPClient(hc), tw.WithUserAgent(testUserAgent))...) +} + +func newInterfaceXMLHandler(funcName string, result []byte) func(w http.ResponseWriter, r *http.Request) { + return newInterfaceHandler(funcName, "text/xml;charset=utf-8", result) +} + +func newInterfacePlainTextHandler(funcName string, result []byte) func(w http.ResponseWriter, r *http.Request) { + return newInterfaceHandler(funcName, "text/plain", result) +} + +func newInterfacePlainTextHandlerString(funcName, result string) func(w http.ResponseWriter, r *http.Request) { + return newInterfacePlainTextHandler(funcName, []byte(result)) +} + +func newInterfaceHandler(funcName, contentType string, result []byte) func(w http.ResponseWriter, r *http.Request) { + next := newHandler(contentType, result) + + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("func") != funcName { + w.WriteHeader(http.StatusNotFound) + return + } + + next(w, r) + } +} + +func newPlainTextHandler(result []byte) func(w http.ResponseWriter, r *http.Request) { + return newHandler("text/plain", result) +} + +func newPlainTextHandlerString(result string) func(w http.ResponseWriter, r *http.Request) { + return newPlainTextHandler([]byte(result)) +} + +func newHandler(contentType string, result []byte) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if r.Header.Get("User-Agent") != testUserAgent { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", contentType) + _, _ = w.Write(result) + } +} diff --git a/internal/tw/parse_error.go b/internal/tw/parse_error.go new file mode 100644 index 0000000..7aaa0af --- /dev/null +++ b/internal/tw/parse_error.go @@ -0,0 +1,15 @@ +package tw + +type ParseError struct { + Field string + Str string + Err error +} + +func (e ParseError) Error() string { + return e.Field + ": parsing '" + e.Str + "': " + e.Err.Error() +} + +func (e ParseError) Unwrap() error { + return e.Err +} diff --git a/internal/tw/server_config.go b/internal/tw/server_config.go new file mode 100644 index 0000000..8cb65e2 --- /dev/null +++ b/internal/tw/server_config.go @@ -0,0 +1,186 @@ +package tw + +import ( + "encoding/xml" + "fmt" + "strings" +) + +type ServerConfigBuild struct { + Destroy int `xml:"destroy"` +} + +type ServerConfigMisc struct { + KillRanking int `xml:"kill_ranking"` + Tutorial int `xml:"tutorial"` + TradeCancelTime int `xml:"trade_cancel_time"` +} + +type ServerConfigCommands struct { + MillisArrival int `xml:"millis_arrival"` + CommandCancelTime int `xml:"command_cancel_time"` +} + +type ServerConfigNewbie struct { + Days int `xml:"days"` + RatioDays int `xml:"ratio_days"` + Ratio int `xml:"ratio"` + RemoveNewbieVillages int `xml:"removeNewbieVillages"` +} + +type KnightNewItems int + +func (k KnightNewItems) Int() int { + return int(k) +} + +func (k *KnightNewItems) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var s string + + if err := d.DecodeElement(&s, &start); err != nil { + return err + } + + switch strings.ToUpper(s) { + case "ON", "1": + *k = 1 + case "OFF", "0", "": + *k = 0 + default: + return xml.UnmarshalError(fmt.Sprintf(`KnightNewItems: parsing "%s": invalid syntax`, s)) + } + + return nil +} + +type ServerConfigGame struct { + BuildtimeFormula int `xml:"buildtime_formula"` + Knight int `xml:"knight"` + KnightNewItems KnightNewItems `xml:"knight_new_items"` + Archer int `xml:"archer"` + Tech int `xml:"tech"` + FarmLimit int `xml:"farm_limit"` + Church int `xml:"church"` + Watchtower int `xml:"watchtower"` + Stronghold int `xml:"stronghold"` + FakeLimit float64 `xml:"fake_limit"` + BarbarianRise float64 `xml:"barbarian_rise"` + BarbarianShrink int `xml:"barbarian_shrink"` + BarbarianMaxPoints int `xml:"barbarian_max_points"` + Scavenging int `xml:"scavenging"` + Hauls int `xml:"hauls"` + HaulsBase int `xml:"hauls_base"` + HaulsMax int `xml:"hauls_max"` + BaseProduction int `xml:"base_production"` + Event int `xml:"event"` + SuppressEvents int `xml:"suppress_events"` +} + +type ServerConfigBuildings struct { + CustomMain int `xml:"custom_main"` + CustomFarm int `xml:"custom_farm"` + CustomStorage int `xml:"custom_storage"` + CustomPlace int `xml:"custom_place"` + CustomBarracks int `xml:"custom_barracks"` + CustomChurch int `xml:"custom_church"` + CustomSmith int `xml:"custom_smith"` + CustomWood int `xml:"custom_wood"` + CustomStone int `xml:"custom_stone"` + CustomIron int `xml:"custom_iron"` + CustomMarket int `xml:"custom_market"` + CustomStable int `xml:"custom_stable"` + CustomWall int `xml:"custom_wall"` + CustomGarage int `xml:"custom_garage"` + CustomHide int `xml:"custom_hide"` + CustomSnob int `xml:"custom_snob"` + CustomStatue int `xml:"custom_statue"` + CustomWatchtower int `xml:"custom_watchtower"` +} + +type ServerConfigSnob struct { + Gold int `xml:"gold"` + CheapRebuild int `xml:"cheap_rebuild"` + Rise int `xml:"rise"` + MaxDist int `xml:"max_dist"` + Factor float64 `xml:"factor"` + CoinWood int `xml:"coin_wood"` + CoinStone int `xml:"coin_stone"` + CoinIron int `xml:"coin_iron"` + NoBarbConquer int `xml:"no_barb_conquer"` +} + +type ServerConfigAlly struct { + NoHarm int `xml:"no_harm"` + NoOtherSupport int `xml:"no_other_support"` + NoOtherSupportType int `xml:"no_other_support_type"` + AllytimeSupport int `xml:"allytime_support"` + NoLeave int `xml:"no_leave"` + NoJoin int `xml:"no_join"` + Limit int `xml:"limit"` + FixedAllies int `xml:"fixed_allies"` + PointsMemberCount int `xml:"points_member_count"` + WarsMemberRequirement int `xml:"wars_member_requirement"` + WarsPointsRequirement int `xml:"wars_points_requirement"` + WarsAutoacceptDays int `xml:"wars_autoaccept_days"` + Levels int `xml:"levels"` + XpRequirements string `xml:"xp_requirements"` +} + +type ServerConfigCoord struct { + MapSize int `xml:"map_size"` + Func int `xml:"func"` + EmptyVillages int `xml:"empty_villages"` + BonusVillages int `xml:"bonus_villages"` + BonusNew int `xml:"bonus_new"` + Inner int `xml:"inner"` + SelectStart int `xml:"select_start"` + VillageMoveWait int `xml:"village_move_wait"` + NobleRestart int `xml:"noble_restart"` + StartVillages int `xml:"start_villages"` +} + +type ServerConfigSitter struct { + Allow int `xml:"allow"` +} + +type ServerConfigSleep struct { + Active int `xml:"active"` + Delay int `xml:"delay"` + Min int `xml:"min"` + Max int `xml:"max"` + MinAwake int `xml:"min_awake"` + MaxAwake int `xml:"max_awake"` + WarnTime int `xml:"warn_time"` +} + +type ServerConfigNight struct { + Active int `xml:"active"` + StartHour int `xml:"start_hour"` + EndHour int `xml:"end_hour"` + DefFactor float64 `xml:"def_factor"` + Duration int `xml:"duration"` +} + +type ServerConfigWin struct { + Check int `xml:"check"` +} + +type ServerConfig struct { + XMLName xml.Name `xml:"config"` + Speed float64 `xml:"speed"` + UnitSpeed float64 `xml:"unit_speed"` + Moral int `xml:"moral"` + Build ServerConfigBuild `xml:"build"` + Misc ServerConfigMisc `xml:"misc"` + Commands ServerConfigCommands `xml:"commands"` + Newbie ServerConfigNewbie `xml:"newbie"` + Game ServerConfigGame `xml:"game"` + Buildings ServerConfigBuildings `xml:"buildings"` + Snob ServerConfigSnob `xml:"snob"` + Ally ServerConfigAlly `xml:"ally"` + Coord ServerConfigCoord `xml:"coord"` + Sitter ServerConfigSitter `xml:"sitter"` + Sleep ServerConfigSleep `xml:"sleep"` + Night ServerConfigNight `xml:"night"` + Win ServerConfigWin `xml:"win"` +} diff --git a/internal/tw/server_config_test.go b/internal/tw/server_config_test.go new file mode 100644 index 0000000..85aebf6 --- /dev/null +++ b/internal/tw/server_config_test.go @@ -0,0 +1,65 @@ +package tw_test + +import ( + "encoding/xml" + "testing" + + "gitea.dwysokinski.me/twhelp/corev3/internal/tw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKnightNewItems_UnmarshalXML(t *testing.T) { + t.Parallel() + + tests := []struct { + data string + expected tw.KnightNewItems + expectedErr error + }{ + { + data: "", + expected: 0, + }, + { + data: "Off", + expected: 0, + }, + { + data: "0", + expected: 0, + }, + { + data: "On", + expected: 1, + }, + { + data: "ON", + expected: 1, + }, + { + data: "on", + expected: 1, + }, + { + data: "1", + expected: 1, + }, + { + data: "invalid value", + expectedErr: xml.UnmarshalError(`KnightNewItems: parsing "invalid value": invalid syntax`), + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.data, func(t *testing.T) { + t.Parallel() + + var res tw.KnightNewItems + require.ErrorIs(t, tt.expectedErr, xml.Unmarshal([]byte(tt.data), &res)) + assert.Equal(t, tt.expected, res) + }) + } +} diff --git a/internal/tw/testdata/building_info.xml b/internal/tw/testdata/building_info.xml new file mode 100644 index 0000000..fea14eb --- /dev/null +++ b/internal/tw/testdata/building_info.xml @@ -0,0 +1,241 @@ + + +
+ 30 + 1 + 90 + 80 + 70 + 5 + 1.26 + 1.275 + 1.26 + 1.17 + 900 + 1.2 +
+ + 25 + 0 + 200 + 170 + 90 + 7 + 1.26 + 1.28 + 1.26 + 1.17 + 1800 + 1.2 + + + 20 + 0 + 270 + 240 + 260 + 8 + 1.26 + 1.28 + 1.26 + 1.17 + 6000 + 1.2 + + + 15 + 0 + 300 + 240 + 260 + 8 + 1.26 + 1.28 + 1.26 + 1.17 + 6000 + 1.2 + + + 20 + 0 + 12000 + 14000 + 10000 + 500 + 1.17 + 1.17 + 1.18 + 1.18 + 13200 + 1.2 + + + 1 + 0 + 15000 + 25000 + 10000 + 80 + 2 + 2 + 2 + 1.17 + 586800 + 1.2 + + + 20 + 0 + 220 + 180 + 240 + 20 + 1.26 + 1.275 + 1.26 + 1.17 + 6000 + 1.2 + + + 1 + 0 + 10 + 40 + 30 + 0 + 1.26 + 1.275 + 1.26 + 1.17 + 10860 + 1.2 + + + 1 + 0 + 220 + 220 + 220 + 10 + 1.26 + 1.275 + 1.26 + 1.17 + 1500 + 1.2 + + + 25 + 0 + 100 + 100 + 100 + 20 + 1.26 + 1.275 + 1.26 + 1.17 + 2700 + 1.2 + + + 30 + 0 + 50 + 60 + 40 + 5 + 1.25 + 1.275 + 1.245 + 1.155 + 900 + 1.2 + + + 30 + 0 + 65 + 50 + 40 + 10 + 1.27 + 1.265 + 1.24 + 1.14 + 900 + 1.2 + + + 30 + 0 + 75 + 65 + 70 + 10 + 1.252 + 1.275 + 1.24 + 1.17 + 1080 + 1.2 + + + 30 + 1 + 45 + 40 + 30 + 0 + 1.3 + 1.32 + 1.29 + 1 + 1200 + 1.2 + + + 30 + 1 + 60 + 50 + 40 + 0 + 1.265 + 1.27 + 1.245 + 1.15 + 1020 + 1.2 + + + 10 + 0 + 50 + 60 + 50 + 2 + 1.25 + 1.25 + 1.25 + 1.17 + 1800 + 1.2 + + + 20 + 0 + 50 + 100 + 20 + 5 + 1.26 + 1.275 + 1.26 + 1.17 + 3600 + 1.2 + +
diff --git a/internal/tw/testdata/get_servers_resp.txt b/internal/tw/testdata/get_servers_resp.txt new file mode 100644 index 0000000..c4b42c9 --- /dev/null +++ b/internal/tw/testdata/get_servers_resp.txt @@ -0,0 +1 @@ +a:16:{s:5:"pl159";s:25:"https://pl159.plemiona.pl";s:5:"pl161";s:25:"https://pl161.plemiona.pl";s:5:"pl164";s:25:"https://pl164.plemiona.pl";s:4:"plc1";s:24:"https://plc1.plemiona.pl";s:4:"pls1";s:24:"https://pls1.plemiona.pl";s:4:"plp7";s:24:"https://plp7.plemiona.pl";s:5:"pl165";s:25:"https://pl165.plemiona.pl";s:5:"pl167";s:25:"https://pl167.plemiona.pl";s:5:"pl168";s:25:"https://pl168.plemiona.pl";s:4:"plp8";s:24:"https://plp8.plemiona.pl";s:5:"pl169";s:25:"https://pl169.plemiona.pl";s:5:"pl170";s:25:"https://pl170.plemiona.pl";s:5:"pl171";s:25:"https://pl171.plemiona.pl";s:5:"pl172";s:25:"https://pl172.plemiona.pl";s:5:"pl173";s:25:"https://pl173.plemiona.pl";s:5:"pl174";s:25:"https://pl174.plemiona.pl";} diff --git a/internal/tw/testdata/server_config.xml b/internal/tw/testdata/server_config.xml new file mode 100644 index 0000000..0e853cc --- /dev/null +++ b/internal/tw/testdata/server_config.xml @@ -0,0 +1,125 @@ + + + 4.5 + 0.5 + 1 + + 1 + + + 2 + 0 + 300 + + + 0 + 600 + + + 3 + 15 + 10 + 1 + + + 2 + 0 + On + 1 + 2 + 0 + 0 + 0 + 0 + 0.5 + 0.001 + 1 + 800 + 1 + 2 + 5000 + 1000000 + 75 + 0 + 1 + + + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + -1 + + + 1 + 0 + 2 + 50 + 1 + 28000 + 30000 + 25000 + 0 + + + 0 + 0 + 0 + 0 + + + 15 + 0 + 5 + 15000 + 7 + 1 + v1 + + + 1000 + 4 + 11 + 1 + 550 + 0 + 336 + 1 + 1 + + + 1 + + + 0 + 60 + 6 + 10 + 12 + 36 + 10 + + + 2 + 23 + 7 + 3.5 + 14 + + + 3 + + diff --git a/internal/tw/testdata/unit_info.xml b/internal/tw/testdata/unit_info.xml new file mode 100644 index 0000000..db34e78 --- /dev/null +++ b/internal/tw/testdata/unit_info.xml @@ -0,0 +1,123 @@ + + + + 226.66666666667 + 1 + 8 + 10 + 15 + 45 + 20 + 25 + + + 333.33333333333 + 1 + 9.7777777777778 + 25 + 50 + 15 + 40 + 15 + + + 293.33333333333 + 1 + 8 + 40 + 10 + 5 + 10 + 10 + + + 400 + 1 + 8 + 15 + 50 + 40 + 5 + 10 + + + 200 + 2 + 4 + 0 + 2 + 1 + 2 + 0 + + + 400 + 4 + 4.4444444444444 + 130 + 30 + 40 + 30 + 80 + + + 600 + 5 + 4.4444444444444 + 120 + 40 + 30 + 50 + 50 + + + 800 + 6 + 4.8888888888889 + 150 + 200 + 80 + 180 + 50 + + + 1066.6666666667 + 5 + 13.333333333333 + 2 + 20 + 50 + 20 + 0 + + + 1600 + 8 + 13.333333333333 + 100 + 100 + 50 + 100 + 0 + + + 4000 + 100 + 15.555555555556 + 30 + 100 + 50 + 100 + 0 + + + 1 + 0 + 0.016666666666667 + 0 + 15 + 45 + 25 + 0 + + diff --git a/internal/tw/tw.go b/internal/tw/tw.go new file mode 100644 index 0000000..db7a6dd --- /dev/null +++ b/internal/tw/tw.go @@ -0,0 +1,69 @@ +package tw + +import ( + "time" +) + +type Server struct { + Key string + URL string +} + +type OpponentsDefeated struct { + RankAtt int + ScoreAtt int + RankDef int + ScoreDef int + RankSup int + ScoreSup int + RankTotal int + ScoreTotal int +} + +type Tribe struct { + OpponentsDefeated + + ID int + Name string + Tag string + NumMembers int + NumVillages int + Points int + AllPoints int + Rank int + ProfileURL string +} + +type Player struct { + OpponentsDefeated + + ID int + Name string + NumVillages int + Points int + Rank int + TribeID int + ProfileURL string +} + +type Village struct { + ID int + Name string + Points int + X int + Y int + Continent string + Bonus int + PlayerID int + ProfileURL string +} + +type Ennoblement struct { + VillageID int + NewOwnerID int + NewTribeID int + OldOwnerID int + OldTribeID int + Points int + CreatedAt time.Time +} diff --git a/internal/tw/unit_info.go b/internal/tw/unit_info.go new file mode 100644 index 0000000..852f4b6 --- /dev/null +++ b/internal/tw/unit_info.go @@ -0,0 +1,34 @@ +package tw + +import ( + "encoding/xml" +) + +type Unit struct { + BuildTime float64 `xml:"build_time"` + Pop int `xml:"pop"` + Speed float64 `xml:"speed"` + Attack int `xml:"attack"` + Defense int `xml:"defense"` + DefenseCavalry int `xml:"defense_cavalry"` + DefenseArcher int `xml:"defense_archer"` + Carry int `xml:"carry"` +} + +type UnitInfo struct { + XMLName xml.Name `xml:"config"` + Text string `xml:",chardata"` + Spear Unit `xml:"spear"` + Sword Unit `xml:"sword"` + Axe Unit `xml:"axe"` + Archer Unit `xml:"archer"` + Spy Unit `xml:"spy"` + Light Unit `xml:"light"` + Marcher Unit `xml:"marcher"` + Heavy Unit `xml:"heavy"` + Ram Unit `xml:"ram"` //nolint:revive + Catapult Unit `xml:"catapult"` + Knight Unit `xml:"knight"` + Snob Unit `xml:"snob"` + Militia Unit `xml:"militia"` +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..0805868 --- /dev/null +++ b/renovate.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "labels": [ + "dependencies" + ], + "extends": [ + "config:base", + ":semanticCommits", + ":semanticCommitTypeAll(chore)" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "ignorePaths": [ + ".woodpecker/*" + ] +} diff --git a/skaffold.yml b/skaffold.yml new file mode 100644 index 0000000..a5f291d --- /dev/null +++ b/skaffold.yml @@ -0,0 +1,45 @@ +apiVersion: skaffold/v3 +kind: Config +profiles: + - name: dev + build: + tagPolicy: + customTemplate: + template: latest + artifacts: + - image: twhelp + hooks: + before: + - command: [ "sh", "-c", "make generate" ] + os: [ darwin, linux ] + context: . + docker: + dockerfile: ./build/docker/twhelp/dev/Dockerfile + manifests: + kustomize: + paths: + - k8s/overlays/dev + deploy: + kubectl: {} + - name: resources + deploy: + helm: + releases: + - name: twhelpdb + repo: https://charts.bitnami.com/bitnami + remoteChart: postgresql + version: 13.2.24 + wait: true + setValues: + image.tag: 14.8.0-debian-11-r4 + auth.username: twhelp + auth.password: twhelp + auth.database: twhelp + - name: twhelprmq + repo: https://charts.bitnami.com/bitnami + remoteChart: rabbitmq + version: 12.5.6 + wait: true + setValues: + auth.username: twhelp + auth.password: twhelp