11 Commits

Author SHA1 Message Date
morbalint effcdac533 add woodpecker ci pipeline (#1)
ci/woodpecker/manual/build-frontend Pipeline was successful
ci/woodpecker/manual/e2e Pipeline failed
ci/woodpecker/manual/unit-lint Pipeline was successful
Reviewed-on: #1
Co-authored-by: morbalint <balint@morasz.hu>
Co-committed-by: morbalint <balint@morasz.hu>
2026-06-21 18:32:03 +00:00
morbalint a6ecbfe927 fix in increase gives kepzettseg (#87)
CI / unit-test-and-lint (push) Has been cancelled
CI / build-fe (push) Has been cancelled
CI / build-e2e (push) Has been cancelled
CI / run-e2e (push) Has been cancelled
* fix in increase gives kepzettseg

* Update frontend/src/second-edition/domain-models/kepzettsegek2E.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-05 13:22:00 +02:00
morbalint 588de98262 add some level up tests and maybe fixes (#86)
* add some level up tests and maybe fixes

* address review comments

* fix tolvaj multiclass

* tolvaj level 9 kepzettseg
2026-04-05 12:50:15 +02:00
morbalint d6061ecb38 rename docker compose (#85)
* rename docker compose

* rename nginx conf too
2026-04-05 10:45:39 +02:00
morbalint d04e7e6cef Add e2e tests (#84)
* add basic e2e tests

* add e2e to ci

* fix docker pull

* fix images in ci

* maybe this will help

* push latest on main for e2e

* fix e2e on main

* rename jobs
2026-04-05 10:03:13 +02:00
dependabot[bot] 6e72f8b6ca Bump the npm_and_yarn group across 1 directory with 4 updates (#83)
Bumps the npm_and_yarn group with 3 updates in the /frontend directory: [axios](https://github.com/axios/axios), [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) and [lodash](https://github.com/lodash/lodash).


Updates `axios` from 1.7.4 to 1.13.5
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.4...v1.13.5)

Updates `@babel/runtime` from 7.24.8 to 7.29.2
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.2/packages/babel-runtime)

Updates `form-data` from 4.0.0 to 4.0.5
- [Release notes](https://github.com/form-data/form-data/releases)
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.0...v4.0.5)

Updates `lodash` from 4.17.21 to 4.18.1
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: "@babel/runtime"
  dependency-version: 7.29.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: form-data
  dependency-version: 4.0.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-04 19:19:24 +02:00
morbalint 94fb38e038 refactor 2026-04-04 18:59:34 +02:00
morbalint 4c989928f1 add linting and some lazy loading 2026-04-04 18:43:54 +02:00
morbalint c14864aa29 refactor API calls and routers 2026-04-04 18:27:45 +02:00
morbalint 229edd42e9 optimize react state handling (#82) 2026-04-04 18:16:36 +02:00
morbalint 7c682373a5 refactor: use vite instead of CRA (#81)
* refactor: use vite instead of CRA

* skip deployments for now

* local env fixes
2026-04-04 16:21:09 +02:00
43 changed files with 2444 additions and 270 deletions
+140 -39
View File
@@ -1,16 +1,21 @@
name: CI compile & test & build name: CI
on: on:
push: push:
branches: [ "main" ] branches: [ "main" ]
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
workflow_dispatch:
inputs:
backend_image:
description: 'Backend image to use in E2E tests (optional, default: ghcr.io/morbalint/kemkas-backend:b8565f8e)'
required: false
jobs: jobs:
ci-fe: unit-test-and-lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup Node.js 24.x - name: Setup Node.js 24.x
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -27,27 +32,53 @@ jobs:
- name: Yarn Install - name: Yarn Install
run: | run: |
cd ./frontend cd ./frontend
yarn install yarn install --frozen-lockfile
- name: Yarn Test - name: Yarn Test
run: | run: |
cd ./frontend cd ./frontend
yarn run test yarn run test
- name: Yarn Lint
docker-fe-build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.get_image_tag.outputs.image_tag }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- id: get_image_tag
run: | run: |
echo "GITHUB_SHA_SHORT=$(echo ${GITHUB_SHA::8})" >> $GITHUB_ENV cd ./frontend
echo "ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${GITHUB_SHA::8}" yarn run lint
echo "image_tag=ghcr.io/${{ github.repository_owner }}/kemkas-fe:${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v5 build-fe:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
outputs:
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Compute frontend image tag
id: image-tag
run: |
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-frontend"
sha_tag="${image_repo}:${GITHUB_SHA::8}"
tags="$sha_tag"
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=$'\n'"${image_repo}:latest"
fi
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
{
echo "tags<<EOF"
echo "$tags"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push frontend image
uses: docker/build-push-action@v7
with: with:
context: ./frontend/ context: ./frontend/
file: ./frontend/Dockerfile file: ./frontend/Dockerfile
@@ -55,24 +86,94 @@ jobs:
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${{ env.GITHUB_SHA_SHORT }} ${{ steps.image-tag.outputs.tags }}
# No longer used, and token expired. build-e2e:
# deploy-to-beta: runs-on: ubuntu-latest
# runs-on: ubuntu-latest permissions:
# needs: contents: read
# - docker-fe-build packages: write
# steps: attestations: write
# - uses: actions/checkout@v4 id-token: write
# with: outputs:
# repository: ${{ github.repository_owner }}/kemkas-deployment image_sha_tag: ${{ steps.image-tag.outputs.image_sha_tag }}
# ref: refs/heads/main steps:
# ssh-key: ${{ secrets.DEPLOYMENT_REPO_DEPLOY_KEY }} - uses: actions/checkout@v6
# - uses: mikefarah/yq@v4 - uses: docker/setup-buildx-action@v4
# with: - name: Log in to GitHub Container Registry
# cmd: yq -i '.spec.template.spec.containers[0].image = "${{ needs.docker-fe-build.outputs.image_tag }}"' ./kemkas-beta/kemkas-fe-deployment.yaml uses: docker/login-action@v4
# - run: | with:
# git config --add user.email "beta-fe-deployment-bot@kemkas.hu" registry: ghcr.io
# git config --add user.name "Deployment Bot Beta FE" username: ${{ github.actor }}
# git commit -a -m "deploy ${{ needs.docker-fe-build.outputs.image_tag }}" password: ${{ secrets.GITHUB_TOKEN }}
# git push - name: Compute e2e image tag
id: image-tag
run: |
image_repo="ghcr.io/${{ github.repository_owner }}/kemkas-e2e"
sha_tag="${image_repo}:${GITHUB_SHA::8}"
tags="$sha_tag"
if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
tags+=$'\n'"${image_repo}:latest"
fi
echo "image_sha_tag=${sha_tag}" >> "$GITHUB_OUTPUT"
{
echo "tags<<EOF"
echo "$tags"
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Build and push e2e image
uses: docker/build-push-action@v7
with:
context: ./e2e/
file: ./e2e/Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: |
${{ steps.image-tag.outputs.tags }}
run-e2e:
runs-on: ubuntu-latest
needs:
- build-fe
- build-e2e
permissions:
contents: read
packages: read
env:
BACKEND_IMAGE: ${{ github.event.inputs.backend_image || 'ghcr.io/morbalint/kemkas-backend:b8565f8e' }}
FRONTEND_IMAGE: ${{ needs.build-fe.outputs.image_sha_tag }}
E2E_IMAGE: ${{ needs.build-e2e.outputs.image_sha_tag }}
steps:
- uses: actions/checkout@v6
- name: Setup docker compose
uses: docker/setup-compose-action@v2
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull images for test run
run: docker compose -f e2e.docker-compose.yaml pull
- name: Run Docker Compose E2E tests
run: docker compose -f e2e.docker-compose.yaml up --no-build --abort-on-container-exit --exit-code-from e2e e2e
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: e2e/playwright-report
if-no-files-found: ignore
- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-test-results
path: e2e/test-results
if-no-files-found: ignore
- name: Tear down Docker Compose stack
if: always()
run: docker compose -f e2e.docker-compose.yaml down -v --remove-orphans
+8
View File
@@ -32,3 +32,11 @@ yarn-error.log*
obj/ obj/
bin/ bin/
dist/ dist/
# IDEs and editors
.idea/
.vscode/
# playwright
/e2e/playwright-report/
/e2e/test-results/
+27
View File
@@ -0,0 +1,27 @@
when:
- event: [push, manual]
branch: [main]
steps:
prepare-fe-image-tags:
image: alpine:3.21
commands:
- SHORT_SHA="$(echo "$CI_COMMIT_SHA" | cut -c1-8)"
- printf '%s\n' "$SHORT_SHA" > .fe-tags
- printf '%s\n' "latest" >> .fe-tags
build-and-push-fe-image:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
privileged: true
depends_on:
- prepare-fe-image-tags
settings:
registry: git.cluster.lab.kemkas.hu
repo: git.cluster.lab.kemkas.hu/${CI_REPO}
context: frontend
dockerfile: frontend/Dockerfile
tags_file: .fe-tags
username:
from_secret: gitea_packages_username
password:
from_secret: gitea_packages_token
+60
View File
@@ -0,0 +1,60 @@
when:
- event: [push, manual]
branch: [main]
steps:
prepare-e2e-image-tags:
image: alpine:3.21
commands:
- SHORT_SHA="$(echo "$CI_COMMIT_SHA" | cut -c1-8)"
- printf '%s\n' "$SHORT_SHA" > .e2e-tags
- printf '%s\n' "latest" >> .e2e-tags
build-and-push-e2e-image:
image: docker.io/woodpeckerci/plugin-docker-buildx:latest
privileged: true
depends_on:
- prepare-e2e-image-tags
settings:
registry: git.cluster.lab.kemkas.hu
repo: git.cluster.lab.kemkas.hu/${CI_REPO}-e2e
context: e2e
dockerfile: e2e/Dockerfile
tags_file: .e2e-tags
username:
from_secret: gitea_packages_username
password:
from_secret: gitea_packages_token
run-e2e:
image: docker:28-cli
privileged: true
depends_on:
- build-and-push-e2e-image
environment:
GITEA_PACKAGES_USERNAME:
from_secret: gitea_packages_username
GITEA_PACKAGES_TOKEN:
from_secret: gitea_packages_token
commands:
- SHORT_SHA="$(echo "$CI_COMMIT_SHA" | cut -c1-8)"
- export FRONTEND_IMAGE="git.cluster.lab.kemkas.hu/${CI_REPO}:${SHORT_SHA}"
- export E2E_IMAGE="git.cluster.lab.kemkas.hu/${CI_REPO}-e2e:${SHORT_SHA}"
- docker login git.cluster.lab.kemkas.hu -u "$GITEA_PACKAGES_USERNAME" -p "$GITEA_PACKAGES_TOKEN"
- |
for i in $(seq 1 30); do
if docker manifest inspect "$FRONTEND_IMAGE" >/dev/null 2>&1; then
break
fi
if [ "$i" -eq 30 ]; then
echo "Frontend image not available: $FRONTEND_IMAGE"
exit 1
fi
echo "Waiting for frontend image: $FRONTEND_IMAGE"
sleep 10
done
- trap 'docker compose -f e2e.docker-compose.yaml down -v --remove-orphans' EXIT
- docker compose -f e2e.docker-compose.yaml pull
- docker compose -f e2e.docker-compose.yaml up --no-build --abort-on-container-exit --exit-code-from e2e e2e
+14
View File
@@ -0,0 +1,14 @@
when:
- event: [push, pull_request, manual]
branch: [main]
steps:
unit-test-and-lint:
image: node:24
commands:
- corepack enable
- corepack prepare yarn@1.22.22 --activate
- cd frontend
- yarn install --frozen-lockfile
- yarn run test
- yarn run lint
+10
View File
@@ -25,3 +25,13 @@ dotnet dev-certs https --format PEM --no-password -ep ~/.aspnet/https/kemkas.pem
``` ```
Later start the docker compose first and the project launch settings after the DB is up and running. Later start the docker compose first and the project launch settings after the DB is up and running.
## E2E with Docker Compose
For a fully containerized end-to-end run (tagged backend image + freshly built frontend/e2e images):
```shell
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
```
Use `BACKEND_IMAGE` to select a specific backend tag, and note this compose stack auto-generates TLS certificates for the nginx proxy.
+82
View File
@@ -0,0 +1,82 @@
services:
certs:
image: alpine:3.21
volumes:
- tls-certs:/certs
- ./scripts/generate-e2e-certs.sh:/scripts/generate-e2e-certs.sh:ro
command: ["sh", "/scripts/generate-e2e-certs.sh"]
db:
image: postgres:16.13-alpine3.23
environment:
POSTGRES_USER: "kemkas"
POSTGRES_PASSWORD: "kemkas-dev42"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kemkas -d postgres"]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
backend:
image: ${BACKEND_IMAGE:-ghcr.io/morbalint/kemkas-backend:b8565f8e}
environment:
ConnectionStrings__DefaultConnection: "Server=db;User Id=kemkas;Password=kemkas-dev42"
Email__DomainName: "mail.kemkas.hu"
Email__ApiKey: ""
depends_on:
db:
condition: service_healthy
frontend:
image: ${FRONTEND_IMAGE:-ghcr.io/morbalint/kemkas-frontend:local}
build:
context: ./frontend
dockerfile: Dockerfile
proxy:
image: nginx:1.29.7-alpine3.23
ports:
- "8000:8000"
volumes:
- ./e2e.nginx.conf:/etc/nginx/nginx.conf:ro
- tls-certs:/etc/nginx/certs:ro
depends_on:
certs:
condition: service_completed_successfully
backend:
condition: service_started
frontend:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q --spider --no-check-certificate https://127.0.0.1:8000 || exit 1"]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
e2e:
image: ${E2E_IMAGE:-ghcr.io/morbalint/kemkas-e2e:local}
build:
context: ./e2e
dockerfile: Dockerfile
environment:
E2E_BASE_URL: https://proxy:8000
E2E_USE_WEBSERVER: "false"
E2E_TRUSTED_CERT_PATH: /certs/rootCA.pem
E2E_WAIT_FOR_URL: https://proxy:8000
volumes:
- tls-certs:/certs:ro
- ./e2e/playwright-report:/work/playwright-report
- ./e2e/test-results:/work/test-results
depends_on:
certs:
condition: service_completed_successfully
proxy:
condition: service_healthy
volumes:
db-data:
tls-certs:
+96
View File
@@ -0,0 +1,96 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
upstream frontend {
server frontend:80;
}
upstream backend {
server backend:8080;
}
server {
listen 8000 ssl;
server_name localhost;
ssl_certificate /etc/nginx/certs/kemkas.pem;
ssl_certificate_key /etc/nginx/certs/kemkas.key;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://frontend;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
location /Identity/ {
proxy_pass_request_headers on;
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
proxy_set_header X-Forwarded-Proto "$scheme";
proxy_set_header X-Forwarded-Host "$http_host";
proxy_pass http://backend;
}
location /api/ {
proxy_pass_request_headers on;
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
proxy_set_header X-Forwarded-Proto "$scheme";
proxy_set_header X-Forwarded-Host "$http_host";
proxy_pass http://backend;
}
location /signin- {
proxy_pass_request_headers on;
proxy_set_header X-Forwarded-For "$proxy_add_x_forwarded_for";
proxy_set_header X-Forwarded-Proto "$scheme";
proxy_set_header X-Forwarded-Host "$http_host";
proxy_pass http://backend;
}
location /lib/ {
proxy_pass_request_headers on;
proxy_pass http://backend;
}
location /js/ {
proxy_pass_request_headers on;
proxy_pass http://backend;
}
location /css/ {
proxy_pass_request_headers on;
proxy_pass http://backend;
}
location /img/ {
proxy_pass_request_headers on;
proxy_pass http://backend;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
playwright-report
test-results
.vscode
.git
+18
View File
@@ -0,0 +1,18 @@
FROM mcr.microsoft.com/playwright:v1.59.1-noble
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
WORKDIR /work
RUN apt-get update \
&& apt-get install -y --no-install-recommends libnss3-tools \
&& rm -rf /var/lib/apt/lists/*
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
RUN chmod +x /work/docker-entrypoint.sh
ENTRYPOINT ["/work/docker-entrypoint.sh"]
CMD ["yarn", "test"]
+57
View File
@@ -0,0 +1,57 @@
# E2E tests (Playwright)
This folder contains browser-level end-to-end tests for public, backend-independent user flows.
## Current scope
- Guest redirect from `/` to `/2e/karakter`
- Guest header links (`Register`, `Login`)
- Public legal pages (`/privacy.html`, `/ogl.html`, `/aelf.html`)
## Run locally
From `frontend/`:
- Install Playwright browser binaries:
- `yarn test:e2e:install`
- Run tests:
- `yarn test:e2e`
- Run headed mode:
- `yarn test:e2e:headed`
- Run interactive UI mode:
- `yarn test:e2e:ui`
## Notes
- Tests use the config in `../e2e/playwright.config.ts`.
- The Playwright web server starts Vite on `http://127.0.0.1:4173`.
- Tests mock `GET /api/User/me` so they stay deterministic without the backend.
## Run with Docker Compose
This mode runs against a full containerized stack:
- tagged backend image (`BACKEND_IMAGE`)
- supporting services (`postgres`, `nginx` proxy)
- freshly built `frontend` image
- freshly built `e2e` image
Prerequisites:
- Docker with Compose
Run:
```shell
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
```
Run with a different backend tag:
```shell
BACKEND_IMAGE=ghcr.io/morbalint/kemkas-backend:<tag> \
docker compose -f e2e.docker-compose.yaml up --build --abort-on-container-exit --exit-code-from e2e e2e
```
The `certs` service generates a local CA and nginx server certificate in a shared volume.
The `e2e` image imports that CA (`/certs/rootCA.pem`) into both the system trust store and Chromium's NSS store before running tests.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
trusted_cert_path="${E2E_TRUSTED_CERT_PATH:-}"
if [[ -n "$trusted_cert_path" ]]; then
if [[ ! -f "$trusted_cert_path" ]]; then
echo "Trusted certificate not found at: $trusted_cert_path" >&2
exit 1
fi
install -m 0644 -D "$trusted_cert_path" /usr/local/share/ca-certificates/kemkas-proxy.crt
update-ca-certificates
if command -v certutil >/dev/null 2>&1; then
for candidate_home in /root /home/pwuser; do
if [[ -d "$candidate_home" ]]; then
nss_db_dir="$candidate_home/.pki/nssdb"
mkdir -p "$nss_db_dir"
certutil -d "sql:$nss_db_dir" -N --empty-password >/dev/null 2>&1 || true
certutil -d "sql:$nss_db_dir" -D -n kemkas-proxy >/dev/null 2>&1 || true
certutil -d "sql:$nss_db_dir" -A -t "C,," -n kemkas-proxy -i "$trusted_cert_path"
fi
done
fi
fi
if [[ -n "${E2E_WAIT_FOR_URL:-}" ]]; then
echo "Waiting for target URL: ${E2E_WAIT_FOR_URL}"
for attempt in {1..60}; do
if curl --silent --show-error --fail "${E2E_WAIT_FOR_URL}" >/dev/null; then
break
fi
if [[ "$attempt" -eq 60 ]]; then
echo "Timed out waiting for ${E2E_WAIT_FOR_URL}" >&2
exit 1
fi
sleep 2
done
fi
exec "$@"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "kemkas-e2e",
"version": "0.1.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:ui": "playwright test --ui",
"install:browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "1.59.1"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';
const port = Number(process.env.E2E_PORT ?? 4173);
const baseURL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${port}`;
const useWebServer = process.env.E2E_USE_WEBSERVER !== 'false';
export default defineConfig({
testDir: path.join(__dirname, 'tests'),
outputDir: path.join(__dirname, 'test-results'),
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
timeout: 30_000,
reporter: [
['list'],
['html', { outputFolder: path.join(__dirname, 'playwright-report'), open: 'never' }],
],
use: {
baseURL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
...(useWebServer
? {
webServer: {
command: `node ./node_modules/vite/bin/vite.js --host 127.0.0.1 --port ${port}`,
cwd: path.resolve(__dirname, '../frontend'),
port,
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
}
: {}),
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
async function mockGuestSession(page: import('@playwright/test').Page): Promise<void> {
await page.route('**/api/User/me', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/plain; charset=utf-8',
body: '',
});
});
}
test('guest users are redirected to 2e character creation', async ({ page }) => {
await mockGuestSession(page);
await page.goto('/');
await expect(page).toHaveURL(/\/2e\/karakter$/);
await expect(page.getByRole('heading', { name: 'Karakter létrehozása' })).toBeVisible();
await expect(page.getByRole('button', { name: /Mentés/ })).toBeVisible();
await expect(page.getByRole('button', { name: /PDF/ })).toBeVisible();
});
test('guest navigation shows Register and Login links', async ({ page }) => {
await mockGuestSession(page);
await page.goto('/2e/karakter');
const registerLink = page.getByRole('link', { name: 'Register' });
const loginLink = page.getByRole('link', { name: 'Login' });
await expect(registerLink).toBeVisible();
await expect(registerLink).toHaveAttribute('href', '/Identity/Account/Register');
await expect(loginLink).toBeVisible();
await expect(loginLink).toHaveAttribute('href', '/Identity/Account/Login');
});
test('public legal pages are served', async ({ page }) => {
await page.goto('/privacy.html');
await expect(page.getByRole('heading', { name: 'Privacy Policy', level: 1 })).toBeVisible();
await page.goto('/ogl.html');
await expect(page).toHaveTitle(/Open Game License v1\.0a/);
await expect(page.locator('body')).toContainText('OPEN GAME LICENSE Version 1.0a');
await page.goto('/aelf.html');
await expect(page).toHaveTitle(/AELF Open License version 1\.0a/);
await expect(page.locator('body')).toContainText('AELF OPEN LICENSE VERSION 1.0a');
});
+29
View File
@@ -0,0 +1,29 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@1.59.1":
version "1.59.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
dependencies:
playwright "1.59.1"
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
playwright@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
dependencies:
playwright-core "1.59.1"
optionalDependencies:
fsevents "2.3.2"
+2
View File
@@ -12,5 +12,7 @@ RUN yarn build
FROM nginx:1.29.7-alpine3.23 AS proxy FROM nginx:1.29.7-alpine3.23 AS proxy
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
COPY --from=builder "/src/dist" "/usr/share/nginx/html" COPY --from=builder "/src/dist" "/usr/share/nginx/html"
COPY ./nginx.conf /etc/nginx/nginx.conf COPY ./nginx.conf /etc/nginx/nginx.conf
+23
View File
@@ -0,0 +1,23 @@
import tsParser from '@typescript-eslint/parser';
export default [
{
ignores: ['dist/**', 'build/**', 'node_modules/**'],
},
{
files: ['src/**/*.{ts,tsx}', 'vite.config.mts', 'vitest.setup.ts'],
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
'no-debugger': 'error',
},
},
];
+9 -2
View File
@@ -15,7 +15,7 @@
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/react": "^18.3.2", "@types/react": "^18.3.2",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"axios": "^1.7.4", "axios": "^1.13.5",
"bootstrap": "^5.3.2", "bootstrap": "^5.3.2",
"downloadjs": "^1.4.7", "downloadjs": "^1.4.7",
"merge": "^2.1.1", "merge": "^2.1.1",
@@ -40,8 +40,10 @@
"workbox-streams": "^7.1.0" "workbox-streams": "^7.1.0"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/parser": "^8.31.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@types/downloadjs": "^1.4.4", "@types/downloadjs": "^1.4.4",
"eslint": "^9.24.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"less": "^4.2.0", "less": "^4.2.0",
"vite": "^5.4.10", "vite": "^5.4.10",
@@ -51,9 +53,14 @@
"dev": "vite", "dev": "vite",
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.mts vitest.setup.ts",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "yarn --cwd ../e2e test",
"test:e2e:headed": "yarn --cwd ../e2e test:headed",
"test:e2e:ui": "yarn --cwd ../e2e test:ui",
"test:e2e:install": "yarn --cwd ../e2e install:browsers"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [
+6 -1
View File
@@ -3,6 +3,11 @@ import { render } from '@testing-library/react';
import { vi } from 'vitest'; import { vi } from 'vitest';
import App from './App'; import App from './App';
vi.mock('./shared/api/http', () => ({
getText: vi.fn(async () => ''),
getJson: vi.fn(),
}));
vi.mock('react-router-dom', async () => { vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom'); const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
@@ -14,5 +19,5 @@ vi.mock('react-router-dom', async () => {
}); });
test('renders App', () => { test('renders App', () => {
render(<App faro={undefined}/>); render(<App />);
}); });
+94 -61
View File
@@ -1,115 +1,148 @@
import * as React from "react"; import * as React from "react";
import "./App.css"; import "./App.css";
import {Faro} from "@grafana/faro-web-sdk"; import {Faro} from "@grafana/faro-web-sdk";
import {createBrowserRouter, redirect, RouterProvider} from "react-router-dom"; import {createBrowserRouter, LoaderFunctionArgs, Navigate, Outlet, redirect, RouterProvider} from "react-router-dom";
import CreateCharacter from "./first-edition/pages/CreateCharacter"; import CharacterList, {CharacterListItemDto} from "./shared/pages/CharacterList";
import CharacterList from "./shared/pages/CharacterList";
import ErrorBoundary from "./shared/ErrorBoundary"; import ErrorBoundary from "./shared/ErrorBoundary";
import Header from "./shared/Header"; import Header from "./shared/Header";
import Footer from "./shared/Footer"; import Footer from "./shared/Footer";
import CreateCharacter2E from "./second-edition/pages/CreateCharacter2E";
import store, {AppDispatch, RootState} from './store' import store, {AppDispatch, RootState} from './store'
import { Provider } from "react-redux" import { Provider } from "react-redux"
import { useSelector, useDispatch } from 'react-redux' import { useSelector, useDispatch } from 'react-redux'
import {load, setUser, unsetUser, userSelector} from './shared/domain-models/userSlice' import {fetchCurrentUser, userSelector} from './shared/domain-models/userSlice'
import {setCharacter} from "./second-edition/domain-models/characterSlice"; import {setCharacter} from "./second-edition/domain-models/characterSlice";
import {getJson} from "./shared/api/http";
import {KarakterInputs} from "./first-edition/domain-models/karakter";
import {Karakter2E} from "./second-edition/domain-models/karakter2E";
const CreateCharacter = React.lazy(() => import("./first-edition/pages/CreateCharacter"))
const CreateCharacter2E = React.lazy(() => import("./second-edition/pages/CreateCharacter2E"))
function Router(props: {faro?: Faro}) { const FaroContext = React.createContext<Faro | undefined>(undefined)
const dispatch = useDispatch.withTypes<AppDispatch>()()
function RootLayout() {
return (
<>
<Header />
<div className='container'>
<Outlet />
</div>
<Footer />
</>
)
}
function IndexRedirect() {
const fetchedUser = useSelector.withTypes<RootState>()(userSelector) const fetchedUser = useSelector.withTypes<RootState>()(userSelector)
if (fetchedUser.state === "not-started") { if (fetchedUser.state !== "finished") {
dispatch(load()) return null;
fetch(`${window.location.origin}/api/User/me`)
.then(response => {
if (response.ok) {
return response.text();
}
console.log(response)
})
.then(userNameResponse => {
if (userNameResponse && userNameResponse.length > 0) {
dispatch(setUser(userNameResponse))
} else {
dispatch(unsetUser())
} }
}) return <Navigate replace to={fetchedUser.email == null ? "/2e/karakter" : "/karaktereim"} />
.catch(() => {
dispatch(unsetUser())
})
} }
function CreateCharacter1ERoute() {
const faro = React.useContext(FaroContext)
return (
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
{faro ? <CreateCharacter faro={faro} /> : <CreateCharacter />}
</React.Suspense>
)
}
function CreateCharacter2ERoute() {
const faro = React.useContext(FaroContext)
return (
<React.Suspense fallback={<div className="p-3">Betoltes...</div>}>
{faro ? <CreateCharacter2E faro={faro} /> : <CreateCharacter2E />}
</React.Suspense>
)
}
function UserBootstrap() {
const dispatch = useDispatch.withTypes<AppDispatch>()()
React.useEffect(() => {
void dispatch(fetchCurrentUser())
}, [dispatch])
return null;
}
const loadCharacterList = () => getJson<CharacterListItemDto[]>("/api/Character")
const loadCharacter1E = ({params}: LoaderFunctionArgs) => getJson<KarakterInputs & { isPublic: boolean }>(`/api/Character1E/${params.id}`)
const loadCharacter2E = async ({params}: LoaderFunctionArgs) => {
try {
const loaded2Echaracter = await getJson<Karakter2E & { isPublic: boolean }>(`/api/Character2E/${params.id}`)
store.dispatch(setCharacter(loaded2Echaracter))
return loaded2Echaracter;
} catch (error) {
return {
isPublic: false,
error: error instanceof Error ? error.message : "Failed to load character",
};
}
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: "/", path: "/",
loader: () => fetchedUser.email == null ? redirect("/2e/karakter") : redirect("/karaktereim") element: <RootLayout />,
children: [
{
index: true,
element: <IndexRedirect />,
}, },
{ {
path: "/karaktereim", path: "karaktereim",
element: <CharacterList />, element: <CharacterList />,
loader: () => fetch(`${window.location.origin}/api/Character`), loader: loadCharacterList,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
}, },
{ {
path: "/1e", path: "1e",
loader: () => redirect("/1e/karakter") loader: () => redirect("/1e/karakter")
}, },
{ {
path: "/1e/karakter", path: "1e/karakter",
element: <CreateCharacter faro={props.faro} />, element: <CreateCharacter1ERoute />,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
}, },
{ {
path: "/1e/karakter/:id", path: "1e/karakter/:id",
element: <CreateCharacter faro={props.faro}/>, element: <CreateCharacter1ERoute />,
loader: args => fetch(`${window.location.origin}/api/Character1E/${args.params.id}`), loader: loadCharacter1E,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
}, },
{ {
path: "/2e", path: "2e",
loader: () => redirect("/2e/karakter") loader: () => redirect("/2e/karakter")
}, },
{ {
path: "/2e/karakter", path: "2e/karakter",
element: <CreateCharacter2E faro={props.faro}/>, element: <CreateCharacter2ERoute />,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
}, },
{ {
path: "/2e/karakter/:id", path: "2e/karakter/:id",
element: <CreateCharacter2E faro={props.faro}/>, element: <CreateCharacter2ERoute />,
loader: async args => { loader: loadCharacter2E,
const response = await fetch(`${window.location.origin}/api/Character2E/${args.params.id}`)
if (response.ok) {
const loaded2Echaracter = await response.json()
dispatch(setCharacter(loaded2Echaracter))
return loaded2Echaracter;
}
return {
isPublic: false,
error: response.statusText,
};
},
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
}, },
],
},
]); ]);
return (
<div className='container'>
<RouterProvider router={router}/>
</div>
)
}
function App(props: { faro?: Faro }) { function App(props: { faro?: Faro }) {
return ( return (
<Provider store={store}> <Provider store={store}>
<Header/> <FaroContext.Provider value={props.faro}>
<Router faro={props.faro} /> <UserBootstrap />
<Footer /> <RouterProvider router={router}/>
</FaroContext.Provider>
</Provider> </Provider>
); );
} }
@@ -1,32 +1,18 @@
import {KarakterInputs} from "../domain-models/karakter"; import {KarakterInputs} from "../domain-models/karakter";
import axios from "axios"; import {postJson} from "../../shared/api/http";
export async function StoreNewCharacter(karakter: KarakterInputs, isPublic: boolean = false) { export async function StoreNewCharacter(karakter: KarakterInputs, isPublic: boolean = false) {
let response = await axios.post(`${window.location.origin}/api/Character1E/`, karakter, { return postJson<KarakterInputs, string>("/api/Character1E/", karakter, {
withCredentials: true,
params: { params: {
isPublic, isPublic,
} }
}) })
if (response.status < 300){
return response.data as string
}
else {
throw Error(response.statusText)
}
} }
export async function UpdateCharacter(id: string, karakter: KarakterInputs, isPublic: boolean = false) { export async function UpdateCharacter(id: string, karakter: KarakterInputs, isPublic: boolean = false) {
let response = await axios.post(`${window.location.origin}/api/Character1E/${id}`, karakter, { return postJson<KarakterInputs, string>(`/api/Character1E/${id}`, karakter, {
withCredentials: true,
params: { params: {
isPublic, isPublic,
} }
}) })
if (response.status < 300){
return response.data as string
}
else {
throw Error(response.statusText)
}
} }
@@ -1,4 +1,4 @@
import React, {useState} from 'react'; import React, {useCallback, useEffect, useMemo, useState} from 'react';
import './CreateCharacter.less' import './CreateCharacter.less'
import {Faj, TulajdonsagokFajjal} from "../domain-models/faj"; import {Faj, TulajdonsagokFajjal} from "../domain-models/faj";
import {Osztaly, SetFelszerelesForChangedOsztaly} from "../domain-models/osztaly" import {Osztaly, SetFelszerelesForChangedOsztaly} from "../domain-models/osztaly"
@@ -56,15 +56,43 @@ function CreateCharacterPage(props: {
let [karakter, changeKarakter] = useState(initialKarakterInputs) let [karakter, changeKarakter] = useState(initialKarakterInputs)
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj) const tulajdonsagokFajjal = useMemo(() => TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj), [karakter.tulajdonsagok, karakter.faj])
const changeKepzettseg = (k: KepzettsegId[]) => changeKarakter({...karakter, kepzettsegek: k}) const changeKepzettseg = useCallback((k: KepzettsegId[]) => {
const changeTolvajKepzettseg = (tk?: KepzettsegId[]) => changeKarakter({...karakter, tolvajKepzettsegek: tk}) changeKarakter(prev => ({...prev, kepzettsegek: k}))
SetDefaultTolvajKepzettsegek(karakter, changeTolvajKepzettseg) }, [])
SetDefaultKepzettsegek({...karakter, tulajdonsagok: tulajdonsagokFajjal}, changeKepzettseg) const changeTolvajKepzettseg = useCallback((tk?: KepzettsegId[]) => {
const availableKepzettsegList = AvailableKezpettsegList(karakter.osztaly) changeKarakter(prev => ({...prev, tolvajKepzettsegek: tk}))
const setFelszereles = (f: KarakterFelszereles) => changeKarakter({...karakter, felszereles: f}) }, [])
const availableKepzettsegList = useMemo(() => AvailableKezpettsegList(karakter.osztaly), [karakter.osztaly])
const setFelszereles = useCallback((f: KarakterFelszereles) => {
changeKarakter(prev => ({...prev, felszereles: f}))
}, [])
const tolvajDefaultsInput = useMemo(() => ({
osztaly: karakter.osztaly,
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
}), [karakter.osztaly, karakter.tolvajKepzettsegek])
const kepzettsegDefaultsInput = useMemo(() => ({
osztaly: karakter.osztaly,
faj: karakter.faj,
tulajdonsagok: tulajdonsagokFajjal,
kepzettsegek: karakter.kepzettsegek,
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
}), [karakter.osztaly, karakter.faj, tulajdonsagokFajjal, karakter.kepzettsegek, karakter.tolvajKepzettsegek])
useEffect(() => {
SetDefaultTolvajKepzettsegek(tolvajDefaultsInput, changeTolvajKepzettseg)
}, [tolvajDefaultsInput, changeTolvajKepzettseg])
useEffect(() => {
SetDefaultKepzettsegek(kepzettsegDefaultsInput, changeKepzettseg)
}, [kepzettsegDefaultsInput, changeKepzettseg])
useEffect(() => {
SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles) SetFelszerelesForChangedOsztaly(karakter.osztaly, karakter.felszereles, setFelszereles)
}, [karakter.osztaly, karakter.felszereles, setFelszereles])
const levelUp = () => LevelUp(karakter, changeKarakter) const levelUp = () => LevelUp(karakter, changeKarakter)
const levelDown = () => LevelDown(karakter, changeKarakter) const levelDown = () => LevelDown(karakter, changeKarakter)
@@ -1,32 +1,18 @@
import axios from "axios"; import {postJson} from "../../shared/api/http";
import {Karakter2E} from "../domain-models/karakter2E"; import {Karakter2E} from "../domain-models/karakter2E";
export async function StoreNewCharacter2E(karakter: Karakter2E, isPublic: boolean = false) { export async function StoreNewCharacter2E(karakter: Karakter2E, isPublic: boolean = false) {
let response = await axios.post(`${window.location.origin}/api/Character2E/`, karakter, { return postJson<Karakter2E, string>("/api/Character2E/", karakter, {
withCredentials: true,
params: { params: {
isPublic, isPublic,
} }
}) })
if (response.status < 300){
return response.data as string
}
else {
throw Error(response.statusText)
}
} }
export async function UpdateCharacter2E(id: string, karakter: Karakter2E, isPublic: boolean = false) { export async function UpdateCharacter2E(id: string, karakter: Karakter2E, isPublic: boolean = false) {
let response = await axios.post(`${window.location.origin}/api/Character2E/${id}`, karakter, { return postJson<Karakter2E, string>(`/api/Character2E/${id}`, karakter, {
withCredentials: true,
params: { params: {
isPublic, isPublic,
} }
}) })
if (response.status < 300){
return response.data as string
}
else {
throw Error(response.statusText)
}
} }
@@ -8,21 +8,23 @@ import {AppDispatch, RootState} from "../../store";
import {characterSelector, setHarcosSpecialization} from "../domain-models/characterSlice"; import {characterSelector, setHarcosSpecialization} from "../domain-models/characterSlice";
function HarcosFegyverSpecializacio(props: { function HarcosFegyverSpecializacio(props: {
szint: number harcosSzint: number,
karakterSzint: number,
}) { }) {
const { const {
szint harcosSzint,
karakterSzint,
} = props } = props
const dispatch = useDispatch.withTypes<AppDispatch>()() const dispatch = useDispatch.withTypes<AppDispatch>()()
const character = useSelector.withTypes<RootState>()(characterSelector) const character = useSelector.withTypes<RootState>()(characterSelector)
const specialization = character.szintlepesek[szint-1].harcosFegyver || 'kard_hosszu' const specialization = character.szintlepesek[karakterSzint-1]?.harcosFegyver || 'kard_hosszu'
const existingSpecializations = character.szintlepesek.filter(x => x.harcosFegyver != null).map(x => x.harcosFegyver! as string) const existingSpecializations = character.szintlepesek.filter(x => x.harcosFegyver != null).map(x => x.harcosFegyver! as string)
const changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint, fegyver: specialization})) const changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint: karakterSzint, fegyver: specialization}))
const fegyver = GetFegyver(specialization) const fegyver = GetFegyver(specialization)
const availableFegyverek = AllowedFegyver(Osztaly2E.Harcos) const availableFegyverek = AllowedFegyver(Osztaly2E.Harcos)
const pickableFegyverek = availableFegyverek.filter(f => f.Id === specialization || existingSpecializations.filter(x => x === f.Id).length < (szint < 9 ? 1 : 2)) const pickableFegyverek = availableFegyverek.filter(f => f.Id === specialization || existingSpecializations.filter(x => x === f.Id).length < (harcosSzint < 9 ? 1 : 2))
return <> return <>
@@ -32,7 +34,7 @@ function HarcosFegyverSpecializacio(props: {
fegyverek={pickableFegyverek} fegyverek={pickableFegyverek}
selectedId={specialization} selectedId={specialization}
onChange={changeSpecialization} onChange={changeSpecialization}
dataTestId={"harcos-specialization-" + szint} dataTestId={"harcos-specialization-" + karakterSzint}
/> />
{fegyver == null && <span className='form-field-error'>Ismeretlen fegyver: {specialization}</span>} {fegyver == null && <span className='form-field-error'>Ismeretlen fegyver: {specialization}</span>}
</div> </div>
@@ -0,0 +1,126 @@
import React from "react";
import {render} from "@testing-library/react";
import {configureStore} from "@reduxjs/toolkit";
import {Provider} from "react-redux";
import KarakterKepzettsegek from "./Kepzettsegek2E";
import userReducer from "../../shared/domain-models/userSlice";
import characterReducer, {setCharacter} from "../domain-models/characterSlice";
import {DefaultKarakter, Karakter2E} from "../domain-models/karakter2E";
import {Osztaly2E} from "../domain-models/osztaly2E";
import {Tulajdonsag2E} from "../domain-models/tulajdonsag2E";
function createStore() {
return configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
}
describe("KarakterKepzettsegek", () => {
it("shows one more kepzettseg selector when INT modifier increases from level-up", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 4,
tulajdonsagok: {
...DefaultKarakter.tulajdonsagok,
t_int: 17,
},
szintlepesek: [
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
],
tolvajKepzettsegek: [],
varazslatok: [],
};
const store = createStore();
store.dispatch(setCharacter(karakter));
const sut = render(
<Provider store={store}>
<KarakterKepzettsegek />
</Provider>
);
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
expect(selectors).toHaveLength(7);
});
it("shows one more kepzettseg selector when INT modifier increases at level 8", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 8,
tulajdonsagok: {
...DefaultKarakter.tulajdonsagok,
t_int: 14,
},
szintlepesek: [
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Ero },
{ osztaly: Osztaly2E.Harcos, HProll: 4 },
{ osztaly: Osztaly2E.Harcos, HProll: 9 },
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
{ osztaly: Osztaly2E.Harcos, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
],
tolvajKepzettsegek: [],
varazslatok: [],
};
const store = createStore();
store.dispatch(setCharacter(karakter));
const sut = render(
<Provider store={store}>
<KarakterKepzettsegek />
</Provider>
);
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
expect(selectors).toHaveLength(6);
});
it("shows one more kepzettseg selector when INT modifier increases at level 12", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 12,
tulajdonsagok: {
...DefaultKarakter.tulajdonsagok,
t_int: 17,
},
szintlepesek: [
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
{ osztaly: Osztaly2E.Harcos, HProll: 6 },
{ osztaly: Osztaly2E.Harcos, HProll: 8, tulajdonsagNoveles: Tulajdonsag2E.Ero },
{ osztaly: Osztaly2E.Harcos, HProll: 4 },
{ osztaly: Osztaly2E.Harcos, HProll: 9 },
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
{ osztaly: Osztaly2E.Harcos, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Egeszseg },
{ osztaly: Osztaly2E.Harcos, HProll: 3 },
{ osztaly: Osztaly2E.Harcos, HProll: 7 },
{ osztaly: Osztaly2E.Harcos, HProll: 5 },
{ osztaly: Osztaly2E.Harcos, HProll: 4, tulajdonsagNoveles: Tulajdonsag2E.Intelligencia },
],
tolvajKepzettsegek: [],
varazslatok: [],
};
const store = createStore();
store.dispatch(setCharacter(karakter));
const sut = render(
<Provider store={store}>
<KarakterKepzettsegek />
</Provider>
);
const selectors = sut.container.querySelectorAll('[data-testid^="kepzettseg-"]');
expect(selectors).toHaveLength(7);
});
});
@@ -13,7 +13,7 @@ import {arraySetN} from "../../util";
import {useDispatch, useSelector} from "react-redux"; import {useDispatch, useSelector} from "react-redux";
import {AppDispatch, RootState} from "../../store"; import {AppDispatch, RootState} from "../../store";
import {characterSelector, setKepzettsegek, setTolvajKepzettsegek} from "../domain-models/characterSlice"; import {characterSelector, setKepzettsegek, setTolvajKepzettsegek} from "../domain-models/characterSlice";
import {TulajdonsagokFajjal} from "../domain-models/faj2E"; import {TulajdonsagokTotal} from "../domain-models/tulajdonsag2E";
function InternalKepzettsegekSelector(props: { function InternalKepzettsegekSelector(props: {
title: string, title: string,
@@ -70,8 +70,8 @@ function KarakterKepzettsegek () {
const karakter = useSelector.withTypes<RootState>()(characterSelector); const karakter = useSelector.withTypes<RootState>()(characterSelector);
const dispatch = useDispatch.withTypes<AppDispatch>()(); const dispatch = useDispatch.withTypes<AppDispatch>()();
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj) const tulajdonsagokTotal = TulajdonsagokTotal(karakter)
const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokFajjal.t_int, karakter.faj) const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokTotal.t_int, karakter.faj)
const kepzettsegek = karakter.kepzettsegek const kepzettsegek = karakter.kepzettsegek
const fajError = karakter.kepzettsegFajError const fajError = karakter.kepzettsegFajError
const osztalyError = karakter.kepzettsegOsztalyError const osztalyError = karakter.kepzettsegOsztalyError
@@ -0,0 +1,248 @@
import React from "react";
import {fireEvent, render} from "@testing-library/react";
import {configureStore} from "@reduxjs/toolkit";
import {Provider} from "react-redux";
import LevelUps from "./LevelUps";
import userReducer from "../../shared/domain-models/userSlice";
import characterReducer, {setCharacter} from "../domain-models/characterSlice";
import {AllowedFegyver} from "../domain-models/allowed-fegyver";
import {Osztaly2E} from "../domain-models/osztaly2E";
import {ChangeOsztalyAtSzint, DefaultKarakter, Karakter2E} from "../domain-models/karakter2E";
import {LevelUp} from "../domain-models/szintlepes";
import {GetAvailableKepzettsegek} from "../domain-models/kepzettsegek2E";
function applyLevelUp(karakter: Karakter2E): Karakter2E {
let next: Karakter2E | undefined;
LevelUp(karakter, updated => {
next = updated;
});
expect(next).toBeDefined();
return next as Karakter2E;
}
describe("LevelUps Harcos specialization indexing", () => {
it("updates specialization on the selected character level for multiclass", async () => {
const level1Tolvaj: Karakter2E = {
...DefaultKarakter,
szint: 1,
szintlepesek: [{
osztaly: Osztaly2E.Tolvaj,
HProll: 6,
}],
tolvajKepzettsegek: [],
varazslatok: [],
};
const level2Auto = applyLevelUp(level1Tolvaj);
const karakter = ChangeOsztalyAtSzint(level2Auto, Osztaly2E.Harcos, 2);
const store = configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
store.dispatch(setCharacter(karakter));
const sut = render(
<Provider store={store}>
<LevelUps
karakter={store.getState().character2E}
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
/>
</Provider>
);
const selector = await sut.findByTestId("harcos-specialization-2");
const currentSpec = karakter.szintlepesek[1]?.harcosFegyver;
const nextSpec = AllowedFegyver(Osztaly2E.Harcos)
.map(x => x.Id)
.find(id => id !== currentSpec);
expect(nextSpec).toBeDefined();
fireEvent.change(selector, {target: {value: nextSpec as string}});
const stateAfter = store.getState().character2E;
expect(stateAfter.szintlepesek[0]?.harcosFegyver).toBeUndefined();
expect(stateAfter.szintlepesek[1]?.harcosFegyver).toBe(nextSpec);
});
it("does not render tulajdonsag selector at level 4 when all abilities are maxed", () => {
const maxedCharacter: Karakter2E = {
...DefaultKarakter,
szint: 1,
tulajdonsagok: {
t_ero: 18,
t_ugy: 18,
t_egs: 18,
t_int: 18,
t_bol: 18,
t_kar: 18,
},
szintlepesek: [{
osztaly: Osztaly2E.Pap,
HProll: 8,
}],
tolvajKepzettsegek: [],
varazslatok: [],
};
const level2 = applyLevelUp(maxedCharacter);
const level3 = applyLevelUp(level2);
const level4 = applyLevelUp(level3);
expect(level4.szintlepesek[3]?.tulajdonsagNoveles).toBeUndefined();
const store = configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
store.dispatch(setCharacter(level4));
const sut = render(
<Provider store={store}>
<LevelUps
karakter={store.getState().character2E}
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
/>
</Provider>
);
expect(sut.queryByTestId("tulajdonsag-4")).toBeNull();
});
it("does not render kaloz krit selector when tengeresz milestone exists but krit is missing", () => {
const inconsistentTengeresz: Karakter2E = {
...DefaultKarakter,
szint: 3,
szintlepesek: [
{
osztaly: Osztaly2E.Tengeresz,
HProll: 10,
},
{
osztaly: Osztaly2E.Tengeresz,
HProll: 5,
},
{
osztaly: Osztaly2E.Tengeresz,
HProll: 7,
}
],
tolvajKepzettsegek: [],
varazslatok: [],
};
const store = configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
store.dispatch(setCharacter(inconsistentTengeresz));
const sut = render(
<Provider store={store}>
<LevelUps
karakter={store.getState().character2E}
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
/>
</Provider>
);
expect(sut.queryByTestId("kaloz-krit-3")).toBeNull();
});
it("shows and updates Tolvaj extra skill on multiclass Tolvaj level 5", async () => {
const multiclassTolvaj: Karakter2E = {
...DefaultKarakter,
szint: 6,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3, tolvajExtraKepzettseg: "k_meregkeveres" },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const store = configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
store.dispatch(setCharacter(multiclassTolvaj));
const sut = render(
<Provider store={store}>
<LevelUps
karakter={store.getState().character2E}
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
/>
</Provider>
);
const selector = await sut.findByTestId("tolvaj-extra-kepzettseg-6");
const nextKepzettseg = GetAvailableKepzettsegek(multiclassTolvaj)
.map(x => x.Id)
.find(id => id !== multiclassTolvaj.szintlepesek[5]?.tolvajExtraKepzettseg);
expect(nextKepzettseg).toBeDefined();
fireEvent.change(selector, {target: {value: nextKepzettseg as string}});
const stateAfter = store.getState().character2E;
expect(stateAfter.szintlepesek[5]?.tolvajExtraKepzettseg).toBe(nextKepzettseg);
expect(stateAfter.szintlepesek[4]?.tolvajExtraKepzettseg).toBeUndefined();
});
it("at Tolvaj level 9 selector includes class-restricted non-Tolvaj skills", async () => {
const tolvajLevel9: Karakter2E = {
...DefaultKarakter,
szint: 9,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tolvajExtraKepzettseg: "k_alkimia" },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const store = configureStore({
reducer: {
user: userReducer,
character2E: characterReducer,
},
});
store.dispatch(setCharacter(tolvajLevel9));
const sut = render(
<Provider store={store}>
<LevelUps
karakter={store.getState().character2E}
changeKarakter={(updated) => store.dispatch(setCharacter(updated))}
/>
</Provider>
);
const selector = await sut.findByTestId("tolvaj-extra-kepzettseg-9");
const arcaneOption = selector.querySelector('option[value="k_csillagjoslas"]');
expect(arcaneOption).not.toBeNull();
fireEvent.change(selector, {target: {value: "k_csillagjoslas"}});
expect(store.getState().character2E.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_csillagjoslas");
});
});
@@ -41,6 +41,8 @@ function LevelUp(props: {
const kalozSzint = classLevels[Osztaly2E.Tengeresz] const kalozSzint = classLevels[Osztaly2E.Tengeresz]
const tolvajSzint = classLevels[Osztaly2E.Tolvaj] const tolvajSzint = classLevels[Osztaly2E.Tolvaj]
const szintlepes = karakter.szintlepesek[szint-1]; const szintlepes = karakter.szintlepesek[szint-1];
const tulajdonsagNoveles = szintlepes?.tulajdonsagNoveles;
const kalozKritikus = szintlepes?.kalozKritikus;
return <div> return <div>
<div className='row mt-3'> <div className='row mt-3'>
@@ -54,10 +56,10 @@ function LevelUp(props: {
osztalySzint={classLevels[osztaly]} osztalySzint={classLevels[osztaly]}
dataTestId={`osztaly-${szint}`} dataTestId={`osztaly-${szint}`}
/>} />}
{szint % 4 === 0 && {szint % 4 === 0 && tulajdonsagNoveles != null &&
<TulajdonsagNoveles <TulajdonsagNoveles
tulajdonsagok={karakter.tulajdonsagok} tulajdonsagok={karakter.tulajdonsagok}
tulajdonsagNoveles={karakter.szintlepesek[szint-1].tulajdonsagNoveles!} tulajdonsagNoveles={tulajdonsagNoveles}
changeTulajdonsagNoveles={changeTulajdonsagNoveles} changeTulajdonsagNoveles={changeTulajdonsagNoveles}
dataTestId={`tulajdonsag-${szint}`} dataTestId={`tulajdonsag-${szint}`}
/>} />}
@@ -69,12 +71,13 @@ function LevelUp(props: {
/> />
{osztaly === Osztaly2E.Harcos && harcosSzint % 2 === 1 && {osztaly === Osztaly2E.Harcos && harcosSzint % 2 === 1 &&
<HarcosFegyverSpecializacio <HarcosFegyverSpecializacio
szint={harcosSzint} harcosSzint={harcosSzint}
karakterSzint={szint}
/> />
} }
{osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 && {osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 && kalozKritikus != null &&
<KalozKritikus <KalozKritikus
kritFegyverId={szintlepes.kalozKritikus!} kritFegyverId={kalozKritikus}
existingKrits={karakter.szintlepesek.map(x => x.kalozKritikus)} existingKrits={karakter.szintlepesek.map(x => x.kalozKritikus)}
changeKrit={changeKalozKritikus} changeKrit={changeKalozKritikus}
szint={kalozSzint} szint={kalozSzint}
@@ -82,12 +85,17 @@ function LevelUp(props: {
} }
{osztaly === Osztaly2E.Tolvaj {osztaly === Osztaly2E.Tolvaj
&& (tolvajSzint === 5 || tolvajSzint === 9) && (tolvajSzint === 5 || tolvajSzint === 9)
&& szintlepes.tolvajExtraKepzettseg != null && && szintlepes?.tolvajExtraKepzettseg != null &&
<div className='row m-2'> <div className='row m-2'>
<label className='col-md-2 col-sm-3 col-form-label'>Extra képzettség</label> <label className='col-md-2 col-sm-3 col-form-label'>Extra képzettség</label>
<KepzettsegSelector <KepzettsegSelector
selected={Kepzettsegek[szintlepes.tolvajExtraKepzettseg]} selected={Kepzettsegek[szintlepes!.tolvajExtraKepzettseg]}
kepzettsegek={[Kepzettsegek[szintlepes.tolvajExtraKepzettseg], ...GetAvailableKepzettsegek(karakter)]} kepzettsegek={[
Kepzettsegek[szintlepes!.tolvajExtraKepzettseg],
...GetAvailableKepzettsegek(karakter, {
ignoreClassRestrictions: tolvajSzint === 9,
})
]}
dataTestId={`tolvaj-extra-kepzettseg-${szint}`} dataTestId={`tolvaj-extra-kepzettseg-${szint}`}
changeKepzettseg={changeExtraTolvajKepzettseg} changeKepzettseg={changeExtraTolvajKepzettseg}
/> />
@@ -100,16 +108,19 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const {karakter, changeKarakter} = props; const {karakter, changeKarakter} = props;
const tulajdonsagokAtSzint = (szint: number) => TulajdonsagokTotal({...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint)}) const tulajdonsagokAtSzint = (szint: number) => TulajdonsagokTotal({...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint)})
const szintlepesAt = (szint: number) => karakter.szintlepesek[szint-1]!
const changeRolledHPAtSzint = (szint: number) => (newHProll: number) => { const changeRolledHPAtSzint = (szint: number) => (newHProll: number) => {
changeKarakter({...karakter, szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {...karakter.szintlepesek[szint-1], HProll: newHProll})}) const szintlepes = szintlepesAt(szint)
changeKarakter({...karakter, szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {...szintlepes, HProll: newHProll})})
console.log(karakter) console.log(karakter)
} }
const changeTulajdonsagNovelesAtSzint = (szint: number) => { const changeTulajdonsagNovelesAtSzint = (szint: number) => {
return (t: Tulajdonsag2E) => { return (t: Tulajdonsag2E) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({...karakter, changeKarakter({...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, { szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1], ...szintlepes,
tulajdonsagNoveles: t tulajdonsagNoveles: t
}) })
}) })
@@ -118,10 +129,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const changeKalozKritikusAtSzint = (szint: number) => { const changeKalozKritikusAtSzint = (szint: number) => {
return (krit: string) => { return (krit: string) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({ changeKarakter({
...karakter, ...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, { szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1], ...szintlepes,
kalozKritikus: krit kalozKritikus: krit
}) })
}) })
@@ -130,10 +142,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const changeExtraTolvajKepzettseg = (szint: number) => { const changeExtraTolvajKepzettseg = (szint: number) => {
return (k: KepzettsegId) => { return (k: KepzettsegId) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({ changeKarakter({
...karakter, ...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, { szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1], ...szintlepes,
tolvajExtraKepzettseg: k, tolvajExtraKepzettseg: k,
}) })
}) })
@@ -144,10 +157,13 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
return <> return <>
{levels.slice(1).map(szint => {levels.slice(1).map(szint =>
{
const szintlepes = szintlepesAt(szint)
return (
<LevelUp <LevelUp
key={`level-up-${szint}`} key={`level-up-${szint}`}
szint={szint} szint={szint}
osztaly={karakter.szintlepesek[szint-1].osztaly} osztaly={szintlepes.osztaly}
karakter={{...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint) ,tulajdonsagok: tulajdonsagokAtSzint(szint)}} karakter={{...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint) ,tulajdonsagok: tulajdonsagokAtSzint(szint)}}
changeTulajdonsagNoveles={changeTulajdonsagNovelesAtSzint(szint)} changeTulajdonsagNoveles={changeTulajdonsagNovelesAtSzint(szint)}
changeRolledHP={changeRolledHPAtSzint(szint)} changeRolledHP={changeRolledHPAtSzint(szint)}
@@ -155,6 +171,8 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
changeOsztaly={(o) => changeKarakter(ChangeOsztalyAtSzint(karakter, o, szint))} changeOsztaly={(o) => changeKarakter(ChangeOsztalyAtSzint(karakter, o, szint))}
changeExtraTolvajKepzettseg={changeExtraTolvajKepzettseg(szint)} changeExtraTolvajKepzettseg={changeExtraTolvajKepzettseg(szint)}
/> />
)
}
)} )}
</> </>
} }
@@ -9,6 +9,7 @@ import {AllowedFegyver} from "./allowed-fegyver";
import {BaseHP} from "./masodlagos_ertekek"; import {BaseHP} from "./masodlagos_ertekek";
import {dAny} from "../../shared/domain-models/kockak"; import {dAny} from "../../shared/domain-models/kockak";
import {KarakterVarazslat} from "./varazslat"; import {KarakterVarazslat} from "./varazslat";
import {GetAvailableKepzettsegek} from "./kepzettsegek2E";
export interface Karakter2E { export interface Karakter2E {
id?: string, id?: string,
@@ -51,13 +52,15 @@ export const DefaultKarakter: Karakter2E = {
varazslatok: [], varazslatok: [],
} }
function setOsztalyForSzintLepes(szintlepesek: Szintlepes[], osztaly: Osztaly2E, szint: number): Szintlepes[] { function setOsztalyForSzintLepes(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Szintlepes[] {
const szintlepesek = karakter.szintlepesek
const prev = szintlepesek.slice(0, szint-1) const prev = szintlepesek.slice(0, szint-1)
const current : Szintlepes = { const current : Szintlepes = {
...szintlepesek[szint-1], ...szintlepesek[szint-1],
osztaly, osztaly,
harcosFegyver: undefined, harcosFegyver: undefined,
kalozKritikus: undefined kalozKritikus: undefined,
tolvajExtraKepzettseg: undefined,
} }
const tail = szintlepesek.slice(szint).map(x => {return {...x}}) const tail = szintlepesek.slice(szint).map(x => {return {...x}})
@@ -118,6 +121,39 @@ function setOsztalyForSzintLepes(szintlepesek: Szintlepes[], osztaly: Osztaly2E,
} }
} }
let tolvajSzint = prev.filter(x => x.osztaly === Osztaly2E.Tolvaj).length
const existingTolvajExtraKepzettsegek = szintlepesek
.map(x => x.tolvajExtraKepzettseg)
.filter(x => x != null) as KepzettsegId[]
const tolvajExtraKepzettsegAtSzint = (tSzint: number, atLevelSzintlepesek: Szintlepes[]): (KepzettsegId | undefined) => {
if (tSzint !== 5 && tSzint !== 9) {
return undefined
}
const extraIndex = tSzint === 5 ? 0 : 1
if (existingTolvajExtraKepzettsegek.length > extraIndex) {
return existingTolvajExtraKepzettsegek[extraIndex]
}
return GetAvailableKepzettsegek({
szintlepesek: atLevelSzintlepesek,
faj: karakter.faj,
kepzettsegek: karakter.kepzettsegek,
tolvajKepzettsegek: karakter.tolvajKepzettsegek,
}, {
ignoreClassRestrictions: tSzint === 9,
})[0]?.Id
}
if (osztaly === Osztaly2E.Tolvaj) {
tolvajSzint++
current.tolvajExtraKepzettseg = tolvajExtraKepzettsegAtSzint(tolvajSzint, [...prev, current])
}
for (let i = 0; i < tail.length; i++) {
if (tail[i].osztaly === Osztaly2E.Tolvaj) {
tolvajSzint++
tail[i].tolvajExtraKepzettseg = tolvajExtraKepzettsegAtSzint(tolvajSzint, [...prev, current, ...tail.slice(0, i+1)])
}
}
const HD = BaseHP(osztaly) const HD = BaseHP(osztaly)
if (szint === 1) { if (szint === 1) {
current.HProll = HD current.HProll = HD
@@ -138,6 +174,6 @@ export function ChangeLvl1Osztaly(karakter: Karakter2E, osztaly: Osztaly2E): Kar
export function ChangeOsztalyAtSzint(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Karakter2E { export function ChangeOsztalyAtSzint(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Karakter2E {
return { return {
...karakter, ...karakter,
szintlepesek: setOsztalyForSzintLepes(karakter.szintlepesek, osztaly, szint) szintlepesek: setOsztalyForSzintLepes(karakter, osztaly, szint)
} }
} }
@@ -106,6 +106,28 @@ describe("Kepzettsegek 2E", () => {
const actual = GetAvailableKepzettsegek(karakter) const actual = GetAvailableKepzettsegek(karakter)
expect(actual).not.toContain(Kepzettsegek.k_meregkeveres) expect(actual).not.toContain(Kepzettsegek.k_meregkeveres)
}) })
test("at Tolvaj level 9 extra skill can include class-restricted non-Tolvaj skills", () => {
const karakter = {
...DefaultKarakter,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tulajdonsagNoveles: Tulajdonsag2E.Ugyesseg },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, tolvajExtraKepzettseg: Kepzettsegek.k_meregkeveres.Id },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
{ osztaly: Osztaly2E.Tolvaj, HProll: 6, },
]
}
const restricted = GetAvailableKepzettsegek(karakter)
expect(restricted).not.toContain(Kepzettsegek.k_csillagjoslas)
const unrestricted = GetAvailableKepzettsegek(karakter, { ignoreClassRestrictions: true })
expect(unrestricted).toContain(Kepzettsegek.k_csillagjoslas)
})
}) })
test("multiclass characters get access to kepzettsegek from all classes", () => { test("multiclass characters get access to kepzettsegek from all classes", () => {
const karakter = { const karakter = {
@@ -13,7 +13,8 @@ function availableKezpettsegListFajjal(faj: Faj2E, ...osztalyok: Osztaly2E[]): K
} }
export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number = 11) { export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number = 11) {
//max = 3 + 1 + 3 + 5 = 12 // 3 base, 1 human, 3 from max ability modifier, 4 from Thief +1 at 9th level // Default cap is 11. Base calculation is 3 + 1 (human) + 3 (max ability modifier) = 7;
// thief-specific extra selections are handled elsewhere and are not part of this default max.
let numberOfKepzettseg = 3 + Modifier(t_int) + (faj === Faj2E.Ember ? 1 : 0) let numberOfKepzettseg = 3 + Modifier(t_int) + (faj === Faj2E.Ember ? 1 : 0)
if (numberOfKepzettseg < 1) { if (numberOfKepzettseg < 1) {
@@ -27,9 +28,15 @@ export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number =
return numberOfKepzettseg; return numberOfKepzettseg;
} }
export function GetAvailableKepzettsegek(karakter: Pick<Karakter2E, 'szintlepesek' | 'faj' | 'kepzettsegek' | 'tolvajKepzettsegek'>): Kepzettseg[] { export function GetAvailableKepzettsegek(
karakter: Pick<Karakter2E, 'szintlepesek' | 'faj' | 'kepzettsegek' | 'tolvajKepzettsegek'>,
options?: { ignoreClassRestrictions?: boolean }
): Kepzettseg[] {
const osztalyok = new Set(karakter.szintlepesek.map(x => x.osztaly)) const osztalyok = new Set(karakter.szintlepesek.map(x => x.osztaly))
const availableKepzettsegek = availableKezpettsegListFajjal(karakter.faj, ...osztalyok) const baseKepzettsegList = options?.ignoreClassRestrictions
? KepzettsegLista
: availableKezpettsegListFajjal(karakter.faj, ...osztalyok)
const availableKepzettsegek = baseKepzettsegList
.filter(k => !karakter.kepzettsegek.includes(k.Id)) .filter(k => !karakter.kepzettsegek.includes(k.Id))
if (!osztalyok.has(Osztaly2E.Tolvaj)) { if (!osztalyok.has(Osztaly2E.Tolvaj)) {
@@ -0,0 +1,151 @@
import {ChangeOsztalyAtSzint, DefaultKarakter, Karakter2E} from "./karakter2E";
import {Osztaly2E} from "./osztaly2E";
import {LevelUp} from "./szintlepes";
function applyLevelUp(karakter: Karakter2E): Karakter2E {
let next: Karakter2E | undefined;
LevelUp(karakter, updated => {
next = updated;
});
expect(next).toBeDefined();
return next as Karakter2E;
}
function level1Tolvaj(): Karakter2E {
return {
...DefaultKarakter,
szint: 1,
szintlepesek: [{
osztaly: Osztaly2E.Tolvaj,
HProll: 6,
}],
tolvajKepzettsegek: [],
varazslatok: [],
};
}
describe("Harcos specialization in multiclass level-ups", () => {
it("adds a specialization when first Harcos level is gained at character level 2", () => {
const afterLevel2 = applyLevelUp(level1Tolvaj());
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
const secondLevel = multiclassed.szintlepesek[1];
expect(secondLevel?.osztaly).toBe(Osztaly2E.Harcos);
expect(secondLevel?.harcosFegyver).toBeDefined();
});
it("does not add a specialization on even Harcos class level", () => {
const afterLevel2 = applyLevelUp(level1Tolvaj());
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
const afterLevel3 = applyLevelUp(multiclassed);
const thirdLevel = afterLevel3.szintlepesek[2];
expect(thirdLevel?.osztaly).toBe(Osztaly2E.Harcos);
expect(thirdLevel?.harcosFegyver).toBeUndefined();
});
it("adds the next specialization on odd Harcos class level after multiclass", () => {
const afterLevel2 = applyLevelUp(level1Tolvaj());
const multiclassed = ChangeOsztalyAtSzint(afterLevel2, Osztaly2E.Harcos, 2);
const afterLevel3 = applyLevelUp(multiclassed);
const afterLevel4 = applyLevelUp(afterLevel3);
const firstHarcosSpec = multiclassed.szintlepesek[1]?.harcosFegyver;
const thirdHarcosLevelSpec = afterLevel4.szintlepesek[3]?.harcosFegyver;
expect(firstHarcosSpec).toBeDefined();
expect(thirdHarcosLevelSpec).toBeDefined();
expect(thirdHarcosLevelSpec).not.toBe(firstHarcosSpec);
});
});
describe("Tolvaj extra skill in multiclass level-ups", () => {
it("adds extra skill when Tolvaj class reaches level 5 at higher character level", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 5,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Harcos, HProll: 10 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const afterLevel6 = applyLevelUp(karakter);
const sixthLevel = afterLevel6.szintlepesek[5];
expect(sixthLevel?.osztaly).toBe(Osztaly2E.Tolvaj);
expect(sixthLevel?.tolvajExtraKepzettseg).toBeDefined();
});
it("recomputes missing extra skill when class change makes current level Tolvaj level 5", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 5,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Harcos, HProll: 8 },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const changed = ChangeOsztalyAtSzint(karakter, Osztaly2E.Tolvaj, 5);
const fifthLevel = changed.szintlepesek[4];
expect(fifthLevel?.osztaly).toBe(Osztaly2E.Tolvaj);
expect(fifthLevel?.tolvajExtraKepzettseg).toBeDefined();
});
it("at Tolvaj class level 9 LevelUp can auto-pick a class-restricted non-Tolvaj skill", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 8,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const afterLevel9 = applyLevelUp(karakter);
expect(afterLevel9.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_alkimia");
});
it("at Tolvaj class level 9 class-change recomputation can set non-Tolvaj restricted skill", () => {
const karakter: Karakter2E = {
...DefaultKarakter,
szint: 9,
szintlepesek: [
{ osztaly: Osztaly2E.Tolvaj, HProll: 6 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 2, tolvajExtraKepzettseg: "k_meregkeveres" },
{ osztaly: Osztaly2E.Tolvaj, HProll: 4 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 5 },
{ osztaly: Osztaly2E.Tolvaj, HProll: 3 },
{ osztaly: Osztaly2E.Harcos, HProll: 8 },
],
tolvajKepzettsegek: ["k_alcazas", "k_csapdak", "k_egyensulyozas", "k_hamisitas"],
varazslatok: [],
};
const changed = ChangeOsztalyAtSzint(karakter, Osztaly2E.Tolvaj, 9);
expect(changed.szintlepesek[8]?.tolvajExtraKepzettseg).toBe("k_alkimia");
});
});
@@ -67,7 +67,9 @@ export function LevelUp(karakter: Karakter2E, changeKarakter: (input: Karakter2E
if (autoPickedOsztaly === Osztaly2E.Tolvaj){ if (autoPickedOsztaly === Osztaly2E.Tolvaj){
const tolvajSzint = 1 + karakter.szintlepesek.filter(x => x.osztaly === Osztaly2E.Tolvaj).length const tolvajSzint = 1 + karakter.szintlepesek.filter(x => x.osztaly === Osztaly2E.Tolvaj).length
if (tolvajSzint === 5 || tolvajSzint === 9) { if (tolvajSzint === 5 || tolvajSzint === 9) {
const availableKepzettsegek = GetAvailableKepzettsegek(karakter) const availableKepzettsegek = GetAvailableKepzettsegek(karakter, {
ignoreClassRestrictions: tolvajSzint === 9,
})
szintlepes.tolvajExtraKepzettseg = availableKepzettsegek[0].Id; szintlepes.tolvajExtraKepzettseg = availableKepzettsegek[0].Id;
} }
} }
@@ -109,7 +109,7 @@ function CreateCharacter2E(props: {
<h4 className='col align-self-center'>Tanult</h4> <h4 className='col align-self-center'>Tanult</h4>
</div> </div>
<OsztalySelector2E /> <OsztalySelector2E />
{karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio szint={1}/>} {karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio harcosSzint={1} karakterSzint={1}/>}
<KarakterKepzettsegek /> <KarakterKepzettsegek />
<hr/> <hr/>
{/* 1. szinten */} {/* 1. szinten */}
+16 -12
View File
@@ -1,24 +1,28 @@
import * as React from "react"; import * as React from "react";
import axios from "axios";
import {Container, Nav, Navbar} from "react-bootstrap"; import {Container, Nav, Navbar} from "react-bootstrap";
import {userSelector} from "./domain-models/userSlice"; import {unsetUser, userSelector} from "./domain-models/userSlice";
import {RootState} from "../store"; import {AppDispatch, RootState} from "../store";
import {useSelector} from "react-redux"; import {useDispatch, useSelector} from "react-redux";
import {Link, useNavigate} from "react-router-dom";
async function logout() { import {postVoid} from "./api/http";
await axios.post("/Identity/Account/Logout")
document.location.reload()
}
function Header() { function Header() {
const dispatch = useDispatch.withTypes<AppDispatch>()();
const navigate = useNavigate();
const fetchedUser = useSelector.withTypes<RootState>()(userSelector); const fetchedUser = useSelector.withTypes<RootState>()(userSelector);
const logout = async () => {
await postVoid("/Identity/Account/Logout")
dispatch(unsetUser())
navigate("/")
}
return <header> return <header>
<Navbar expand="sm" className="navbar-light bg-white border-bottom box-shadow mb-3"> <Navbar expand="sm" className="navbar-light bg-white border-bottom box-shadow mb-3">
<Container className="ps-4 pe-4"> <Container className="ps-4 pe-4">
<Navbar.Brand href="/">Kemkas</Navbar.Brand> <Navbar.Brand as={Link} to="/">Kemkas</Navbar.Brand>
{fetchedUser.email != null && <Nav.Link className="text-dark" href="/karaktereim">Karaktereim</Nav.Link>} {fetchedUser.email != null && <Nav.Link as={Link} className="text-dark" to="/karaktereim">Karaktereim</Nav.Link>}
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav" className="d-sm-inline-flex flex-sm-row-reverse"> <Navbar.Collapse id="basic-navbar-nav" className="d-sm-inline-flex flex-sm-row-reverse">
<Nav> <Nav>
@@ -38,7 +42,7 @@ function Header() {
href="/Identity/Account/Manage">Hello {fetchedUser.email}!</a> href="/Identity/Account/Manage">Hello {fetchedUser.email}!</a>
</li> </li>
<li className="nav-item"> <li className="nav-item">
<button className="nav-link nav text-dark" onClick={logout}>Logout</button> <button type="button" className="nav-link nav text-dark" onClick={logout}>Logout</button>
</li> </li>
</> </>
} }
+27
View File
@@ -0,0 +1,27 @@
import axios, {AxiosRequestConfig} from "axios";
export const http = axios.create({
withCredentials: true,
});
export async function getJson<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await http.get<T>(url, config)
return response.data
}
export async function getText(url: string, config?: AxiosRequestConfig): Promise<string> {
const response = await http.get<string>(url, {
...config,
responseType: "text",
})
return response.data
}
export async function postJson<TRequest, TResponse>(url: string, body: TRequest, config?: AxiosRequestConfig): Promise<TResponse> {
const response = await http.post<TResponse>(url, body, config)
return response.data
}
export async function postVoid<TRequest = undefined>(url: string, body?: TRequest, config?: AxiosRequestConfig): Promise<void> {
await http.post(url, body, config)
}
+38 -7
View File
@@ -1,18 +1,35 @@
import { createSlice } from '@reduxjs/toolkit' import {createAsyncThunk, createSlice} from '@reduxjs/toolkit'
import {getText} from '../api/http'
type LoadingState = "not-started" | "loading" | "finished" type LoadingState = "not-started" | "loading" | "finished"
export interface UserState {
state: LoadingState,
email: string | null,
}
export const fetchCurrentUser = createAsyncThunk<string | null, void, { state: { user: UserState } }>(
'user/fetchCurrentUser',
async () => {
try {
const userNameResponse = await getText('/api/User/me')
return userNameResponse.length > 0 ? userNameResponse : null
} catch {
return null
}
},
{
condition: (_, {getState}) => getState().user.state === 'not-started',
}
)
export const userSlice = createSlice({ export const userSlice = createSlice({
name: 'user', name: 'user',
initialState: { initialState: {
state: "not-started" as LoadingState, state: "not-started" as LoadingState,
email: null as string | null, email: null as string | null,
}, } as UserState,
reducers: { reducers: {
load: (state) => {
state.state = "loading"
return state
},
setUser: (state, action: {payload: string}) => { setUser: (state, action: {payload: string}) => {
state.state = "finished" state.state = "finished"
state.email = action.payload state.email = action.payload
@@ -24,10 +41,24 @@ export const userSlice = createSlice({
return state return state
} }
}, },
extraReducers: builder => {
builder
.addCase(fetchCurrentUser.pending, (state) => {
state.state = 'loading'
})
.addCase(fetchCurrentUser.fulfilled, (state, action) => {
state.state = 'finished'
state.email = action.payload
})
.addCase(fetchCurrentUser.rejected, (state) => {
state.state = 'finished'
state.email = null
})
},
}) })
// Action creators are generated for each case reducer function // Action creators are generated for each case reducer function
export const { load, setUser, unsetUser } = userSlice.actions export const { setUser, unsetUser } = userSlice.actions
export const userSelector = userSlice.selectSlice export const userSelector = userSlice.selectSlice
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import {useLoaderData} from "react-router-dom"; import {Link, useLoaderData} from "react-router-dom";
import CharacterListItem from "../components/CharacterListItem"; import CharacterListItem from "../components/CharacterListItem";
import {Faj, FajLabel as FajLabel1E} from "../../first-edition/domain-models/faj"; import {Faj, FajLabel as FajLabel1E} from "../../first-edition/domain-models/faj";
import {Faj2E, FajLabel as FajLabel2E} from "../../second-edition/domain-models/faj2E"; import {Faj2E, FajLabel as FajLabel2E} from "../../second-edition/domain-models/faj2E";
@@ -33,7 +33,7 @@ interface CharacterListItemDto2E {
edition: "2e" edition: "2e"
} }
type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E; export type CharacterListItemDto = CharacterListItemDto1E | CharacterListItemDto2E;
function mapDtoToViewModel(dto: CharacterListItemDto): CharacterListItemViewModel { function mapDtoToViewModel(dto: CharacterListItemDto): CharacterListItemViewModel {
if (dto.edition === "1e") { if (dto.edition === "1e") {
@@ -68,12 +68,12 @@ function CharacterList(props: {}) {
<div className="row"> <div className="row">
<div className="col-6"> <div className="col-6">
<div className="d-grid gap-1 m-3 m-lg-5"> <div className="d-grid gap-1 m-3 m-lg-5">
<a className="btn btn-dark btn-lg" href="/2e/karakter/" type="button">Új 2e karakter létrehozása</a> <Link className="btn btn-dark btn-lg" to="/2e/karakter">Új 2e karakter létrehozása</Link>
</div> </div>
</div> </div>
<div className="col-6"> <div className="col-6">
<div className="d-grid gap-1 m-3 m-lg-5"> <div className="d-grid gap-1 m-3 m-lg-5">
<a className="btn btn-dark btn-lg" href="/1e/karakter/" type="button">Új 1e karakter létrehozása</a> <Link className="btn btn-dark btn-lg" to="/1e/karakter">Új 1e karakter létrehozása</Link>
</div> </div>
</div> </div>
</div> </div>
+4 -2
View File
@@ -18,10 +18,12 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx" "jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}, },
"include": [ "include": [
"src", "src",
"vite.config.ts" "vite.config.mts"
] ]
} }
@@ -4,13 +4,13 @@ import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ''); const env = loadEnv(mode, process.cwd(), '');
const port = Number(env.PORT || 5173); const port = Number(env.PORT || 5173);
const https = env.HTTPS === 'true'; const httpsEnabled = env.HTTPS === 'true';
return { return {
plugins: [react()], plugins: [react()],
server: { server: {
port, port,
https, ...(httpsEnabled ? { https: {} } : {}),
strictPort: true, strictPort: true,
allowedHosts: ['localhost', 'host.docker.internal', 'frontend'], allowedHosts: ['localhost', 'host.docker.internal', 'frontend'],
}, },
+742 -31
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
#!/bin/sh
set -eu
CERT_DIR="${CERT_DIR:-/certs}"
mkdir -p "$CERT_DIR"
if [ -f "$CERT_DIR/rootCA.pem" ] && [ -f "$CERT_DIR/rootCA.key" ] && [ -f "$CERT_DIR/kemkas.pem" ] && [ -f "$CERT_DIR/kemkas.key" ]; then
echo "Using existing TLS material from $CERT_DIR"
exit 0
fi
apk add --no-cache openssl >/dev/null
cat > /tmp/kemkas-server.cnf <<'EOF'
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[dn]
CN = localhost
[req_ext]
subjectAltName = @alt_names
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
[alt_names]
DNS.1 = localhost
DNS.2 = proxy
DNS.3 = host.docker.internal
IP.1 = 127.0.0.1
IP.2 = ::1
EOF
cat > /tmp/kemkas-v3.ext <<'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=@alt_names
[alt_names]
DNS.1=localhost
DNS.2=proxy
DNS.3=host.docker.internal
IP.1=127.0.0.1
IP.2=::1
EOF
openssl genrsa -out "$CERT_DIR/rootCA.key" 2048
openssl req -x509 -new -nodes -key "$CERT_DIR/rootCA.key" -sha256 -days 3650 -subj "/CN=kemkas-local-root" -out "$CERT_DIR/rootCA.pem"
openssl genrsa -out "$CERT_DIR/kemkas.key" 2048
openssl req -new -key "$CERT_DIR/kemkas.key" -out /tmp/kemkas.csr -config /tmp/kemkas-server.cnf
openssl x509 -req -in /tmp/kemkas.csr -CA "$CERT_DIR/rootCA.pem" -CAkey "$CERT_DIR/rootCA.key" -CAcreateserial -out "$CERT_DIR/kemkas.pem" -days 825 -sha256 -extfile /tmp/kemkas-v3.ext
echo "Generated TLS CA and nginx certificate under $CERT_DIR"