4 Commits

Author SHA1 Message Date
morbalint 7d6bd62cfa fix in increase gives kepzettseg 2026-04-05 13:16:11 +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
27 changed files with 1303 additions and 74 deletions
+139 -42
View File
@@ -1,16 +1,21 @@
name: CI compile & test & build
name: CI
on:
push:
branches: [ "main" ]
pull_request:
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:
ci-fe:
unit-test-and-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Node.js 24.x
uses: actions/setup-node@v4
with:
@@ -37,46 +42,138 @@ jobs:
cd ./frontend
yarn run lint
docker-fe-build:
build-fe:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
outputs:
image_tag: ${{ steps.get_image_tag.outputs.image_tag }}
image_sha_tag: ${{ steps.image-tag.outputs.image_sha_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: |
echo "GITHUB_SHA_SHORT=$(echo ${GITHUB_SHA::8})" >> $GITHUB_ENV
echo "ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${GITHUB_SHA::8}"
echo "image_tag=ghcr.io/${{ github.repository_owner }}/kemkas-fe:${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v5
with:
context: ./frontend/
file: ./frontend/Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/kemkas-frontend:${{ env.GITHUB_SHA_SHORT }}
- 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}"
# No longer used, and token expired.
# deploy-to-beta:
# runs-on: ubuntu-latest
# needs:
# - docker-fe-build
# steps:
# - uses: actions/checkout@v4
# with:
# repository: ${{ github.repository_owner }}/kemkas-deployment
# ref: refs/heads/main
# ssh-key: ${{ secrets.DEPLOYMENT_REPO_DEPLOY_KEY }}
# - uses: mikefarah/yq@v4
# with:
# cmd: yq -i '.spec.template.spec.containers[0].image = "${{ needs.docker-fe-build.outputs.image_tag }}"' ./kemkas-beta/kemkas-fe-deployment.yaml
# - run: |
# git config --add user.email "beta-fe-deployment-bot@kemkas.hu"
# git config --add user.name "Deployment Bot Beta FE"
# git commit -a -m "deploy ${{ needs.docker-fe-build.outputs.image_tag }}"
# git push
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:
context: ./frontend/
file: ./frontend/Dockerfile
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: |
${{ steps.image-tag.outputs.tags }}
build-e2e:
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 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
+9 -1
View File
@@ -31,4 +31,12 @@ yarn-error.log*
obj/
bin/
dist/
dist/
# IDEs and editors
.idea/
.vscode/
# playwright
/e2e/playwright-report/
/e2e/test-results/
+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.
## 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
LABEL org.opencontainers.image.source = "https://github.com/morbalint/kemkas"
COPY --from=builder "/src/dist" "/usr/share/nginx/html"
COPY ./nginx.conf /etc/nginx/nginx.conf
+5 -1
View File
@@ -56,7 +56,11 @@
"lint": "eslint \"src/**/*.{ts,tsx}\" vite.config.mts vitest.setup.ts",
"preview": "vite preview",
"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": {
"production": [
@@ -8,21 +8,23 @@ import {AppDispatch, RootState} from "../../store";
import {characterSelector, setHarcosSpecialization} from "../domain-models/characterSlice";
function HarcosFegyverSpecializacio(props: {
szint: number
harcosSzint: number,
karakterSzint: number,
}) {
const {
szint
harcosSzint,
karakterSzint,
} = props
const dispatch = useDispatch.withTypes<AppDispatch>()()
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 changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint, fegyver: specialization}))
const changeSpecialization = (specialization: string) => dispatch(setHarcosSpecialization({szint: karakterSzint, fegyver: specialization}))
const fegyver = GetFegyver(specialization)
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 <>
@@ -32,7 +34,7 @@ function HarcosFegyverSpecializacio(props: {
fegyverek={pickableFegyverek}
selectedId={specialization}
onChange={changeSpecialization}
dataTestId={"harcos-specialization-" + szint}
dataTestId={"harcos-specialization-" + karakterSzint}
/>
{fegyver == null && <span className='form-field-error'>Ismeretlen fegyver: {specialization}</span>}
</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 {AppDispatch, RootState} from "../../store";
import {characterSelector, setKepzettsegek, setTolvajKepzettsegek} from "../domain-models/characterSlice";
import {TulajdonsagokFajjal} from "../domain-models/faj2E";
import {TulajdonsagokTotal} from "../domain-models/tulajdonsag2E";
function InternalKepzettsegekSelector(props: {
title: string,
@@ -70,8 +70,8 @@ function KarakterKepzettsegek () {
const karakter = useSelector.withTypes<RootState>()(characterSelector);
const dispatch = useDispatch.withTypes<AppDispatch>()();
const tulajdonsagokFajjal = TulajdonsagokFajjal(karakter.tulajdonsagok, karakter.faj)
const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokFajjal.t_int, karakter.faj)
const tulajdonsagokTotal = TulajdonsagokTotal(karakter)
const numberOfKepzettsegek = GetNumberOfKepzettsegek(tulajdonsagokTotal.t_int, karakter.faj)
const kepzettsegek = karakter.kepzettsegek
const fajError = karakter.kepzettsegFajError
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 tolvajSzint = classLevels[Osztaly2E.Tolvaj]
const szintlepes = karakter.szintlepesek[szint-1];
const tulajdonsagNoveles = szintlepes?.tulajdonsagNoveles;
const kalozKritikus = szintlepes?.kalozKritikus;
return <div>
<div className='row mt-3'>
@@ -54,10 +56,10 @@ function LevelUp(props: {
osztalySzint={classLevels[osztaly]}
dataTestId={`osztaly-${szint}`}
/>}
{szint % 4 === 0 &&
{szint % 4 === 0 && tulajdonsagNoveles != null &&
<TulajdonsagNoveles
tulajdonsagok={karakter.tulajdonsagok}
tulajdonsagNoveles={karakter.szintlepesek[szint-1].tulajdonsagNoveles!}
tulajdonsagNoveles={tulajdonsagNoveles}
changeTulajdonsagNoveles={changeTulajdonsagNoveles}
dataTestId={`tulajdonsag-${szint}`}
/>}
@@ -69,12 +71,13 @@ function LevelUp(props: {
/>
{osztaly === Osztaly2E.Harcos && harcosSzint % 2 === 1 &&
<HarcosFegyverSpecializacio
szint={harcosSzint}
harcosSzint={harcosSzint}
karakterSzint={szint}
/>
}
{osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 &&
{osztaly === Osztaly2E.Tengeresz && kalozSzint % 3 === 0 && kalozKritikus != null &&
<KalozKritikus
kritFegyverId={szintlepes.kalozKritikus!}
kritFegyverId={kalozKritikus}
existingKrits={karakter.szintlepesek.map(x => x.kalozKritikus)}
changeKrit={changeKalozKritikus}
szint={kalozSzint}
@@ -82,12 +85,17 @@ function LevelUp(props: {
}
{osztaly === Osztaly2E.Tolvaj
&& (tolvajSzint === 5 || tolvajSzint === 9)
&& szintlepes.tolvajExtraKepzettseg != null &&
&& szintlepes?.tolvajExtraKepzettseg != null &&
<div className='row m-2'>
<label className='col-md-2 col-sm-3 col-form-label'>Extra képzettség</label>
<KepzettsegSelector
selected={Kepzettsegek[szintlepes.tolvajExtraKepzettseg]}
kepzettsegek={[Kepzettsegek[szintlepes.tolvajExtraKepzettseg], ...GetAvailableKepzettsegek(karakter)]}
selected={Kepzettsegek[szintlepes!.tolvajExtraKepzettseg]}
kepzettsegek={[
Kepzettsegek[szintlepes!.tolvajExtraKepzettseg],
...GetAvailableKepzettsegek(karakter, {
ignoreClassRestrictions: tolvajSzint === 9,
})
]}
dataTestId={`tolvaj-extra-kepzettseg-${szint}`}
changeKepzettseg={changeExtraTolvajKepzettseg}
/>
@@ -100,16 +108,19 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const {karakter, changeKarakter} = props;
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) => {
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)
}
const changeTulajdonsagNovelesAtSzint = (szint: number) => {
return (t: Tulajdonsag2E) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1],
...szintlepes,
tulajdonsagNoveles: t
})
})
@@ -118,10 +129,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const changeKalozKritikusAtSzint = (szint: number) => {
return (krit: string) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({
...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1],
...szintlepes,
kalozKritikus: krit
})
})
@@ -130,10 +142,11 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
const changeExtraTolvajKepzettseg = (szint: number) => {
return (k: KepzettsegId) => {
const szintlepes = szintlepesAt(szint)
changeKarakter({
...karakter,
szintlepesek: arraySetN(karakter.szintlepesek, szint-1, {
...karakter.szintlepesek[szint-1],
...szintlepes,
tolvajExtraKepzettseg: k,
})
})
@@ -144,10 +157,13 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
return <>
{levels.slice(1).map(szint =>
{
const szintlepes = szintlepesAt(szint)
return (
<LevelUp
key={`level-up-${szint}`}
szint={szint}
osztaly={karakter.szintlepesek[szint-1].osztaly}
osztaly={szintlepes.osztaly}
karakter={{...karakter, szintlepesek: karakter.szintlepesek.slice(0, szint) ,tulajdonsagok: tulajdonsagokAtSzint(szint)}}
changeTulajdonsagNoveles={changeTulajdonsagNovelesAtSzint(szint)}
changeRolledHP={changeRolledHPAtSzint(szint)}
@@ -155,6 +171,8 @@ export function LevelUps(props: {karakter: Karakter2E, changeKarakter: (inputs:
changeOsztaly={(o) => changeKarakter(ChangeOsztalyAtSzint(karakter, o, szint))}
changeExtraTolvajKepzettseg={changeExtraTolvajKepzettseg(szint)}
/>
)
}
)}
</>
}
@@ -9,6 +9,7 @@ import {AllowedFegyver} from "./allowed-fegyver";
import {BaseHP} from "./masodlagos_ertekek";
import {dAny} from "../../shared/domain-models/kockak";
import {KarakterVarazslat} from "./varazslat";
import {GetAvailableKepzettsegek} from "./kepzettsegek2E";
export interface Karakter2E {
id?: string,
@@ -51,13 +52,15 @@ export const DefaultKarakter: Karakter2E = {
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 current : Szintlepes = {
...szintlepesek[szint-1],
osztaly,
harcosFegyver: undefined,
kalozKritikus: undefined
kalozKritikus: undefined,
tolvajExtraKepzettseg: undefined,
}
const tail = szintlepesek.slice(szint).map(x => {return {...x}})
@@ -117,6 +120,39 @@ function setOsztalyForSzintLepes(szintlepesek: Szintlepes[], osztaly: Osztaly2E,
tail[i].kalozKritikus = kalozKritAtSzint(kalozSzint)
}
}
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)
if (szint === 1) {
@@ -138,6 +174,6 @@ export function ChangeLvl1Osztaly(karakter: Karakter2E, osztaly: Osztaly2E): Kar
export function ChangeOsztalyAtSzint(karakter: Karakter2E, osztaly: Osztaly2E, szint: number): Karakter2E {
return {
...karakter,
szintlepesek: setOsztalyForSzintLepes(karakter.szintlepesek, osztaly, szint)
szintlepesek: setOsztalyForSzintLepes(karakter, osztaly, szint)
}
}
@@ -106,6 +106,28 @@ describe("Kepzettsegek 2E", () => {
const actual = GetAvailableKepzettsegek(karakter)
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", () => {
const karakter = {
@@ -13,7 +13,7 @@ function availableKezpettsegListFajjal(faj: Faj2E, ...osztalyok: Osztaly2E[]): K
}
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
//max = 3 + 1 + 3 + 4 + 2 = 13 // 3 base, 1 human, 3 from max ability modifier, 4 from Thief +1 at 5th and +1 at 9th level
let numberOfKepzettseg = 3 + Modifier(t_int) + (faj === Faj2E.Ember ? 1 : 0)
if (numberOfKepzettseg < 1) {
@@ -27,9 +27,15 @@ export function GetNumberOfKepzettsegek(t_int: number, faj: Faj2E, max: number =
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 availableKepzettsegek = availableKezpettsegListFajjal(karakter.faj, ...osztalyok)
const baseKepzettsegList = options?.ignoreClassRestrictions
? KepzettsegLista
: availableKezpettsegListFajjal(karakter.faj, ...osztalyok)
const availableKepzettsegek = baseKepzettsegList
.filter(k => !karakter.kepzettsegek.includes(k.Id))
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){
const tolvajSzint = 1 + karakter.szintlepesek.filter(x => x.osztaly === Osztaly2E.Tolvaj).length
if (tolvajSzint === 5 || tolvajSzint === 9) {
const availableKepzettsegek = GetAvailableKepzettsegek(karakter)
const availableKepzettsegek = GetAvailableKepzettsegek(karakter, {
ignoreClassRestrictions: tolvajSzint === 9,
})
szintlepes.tolvajExtraKepzettseg = availableKepzettsegek[0].Id;
}
}
@@ -109,7 +109,7 @@ function CreateCharacter2E(props: {
<h4 className='col align-self-center'>Tanult</h4>
</div>
<OsztalySelector2E />
{karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio szint={1}/>}
{karakter.szintlepesek[0].osztaly === Osztaly2E.Harcos && <HarcosFegyverSpecializacio harcosSzint={1} karakterSzint={1}/>}
<KarakterKepzettsegek />
<hr/>
{/* 1. szinten */}
+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"