diff --git a/.gitignore b/.gitignore index dda2007..f35e692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ node_modules/ +rpgsaga/game-log.txt dist/ coverage/ *.log .DS_Store .env.local .env.*.local + +.env + +game-log.txt diff --git a/clientserver/client/src/App.tsx b/clientserver/client/src/App.tsx index 2aafbc8..df68d93 100644 --- a/clientserver/client/src/App.tsx +++ b/clientserver/client/src/App.tsx @@ -1,8 +1,37 @@ +import { useState } from "react"; + +import { BooksPanel } from "./components/BooksPanel"; +import { ReadersPanel } from "./components/ReadersPanel"; + +type Tab = "books" | "readers"; + export default function App() { + const [tab, setTab] = useState("books"); + return ( - <> -

Hello world

-

React + TypeScript клиент для clientserver.

- +
+
+

RPG Library Admin

+ +
+
{tab === "books" ? : }
+
); } diff --git a/clientserver/client/src/api.ts b/clientserver/client/src/api.ts new file mode 100644 index 0000000..fa8d3bb --- /dev/null +++ b/clientserver/client/src/api.ts @@ -0,0 +1,91 @@ +import type { + ApiError, + Book, + BookStatus, + Paginated, + Reader, + ReaderStatus, +} from "./types"; + +const API_BASE = ""; + +async function request( + path: string, + init?: RequestInit, +): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json", ...init?.headers }, + ...init, + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as ApiError; + throw new Error(err.message || `Request failed: ${res.status}`); + } + + if (res.status === 204) { + return undefined as T; + } + + return res.json() as Promise; +} + +export interface ListParams { + take?: number; + page?: number; + sort?: string; + filter?: string; + limit?: number; +} + +function toQuery(params: ListParams): string { + const q = new URLSearchParams(); + if (params.take !== undefined) q.set("take", String(params.take)); + if (params.page !== undefined) q.set("page", String(params.page)); + if (params.sort) q.set("sort", params.sort); + if (params.filter) q.set("filter", params.filter); + if (params.limit !== undefined) q.set("limit", String(params.limit)); + const s = q.toString(); + return s ? `?${s}` : ""; +} + +export const booksApi = { + list: (params: ListParams = {}) => + request>(`/books${toQuery(params)}`), + get: (isbn: string) => request(`/books/${encodeURIComponent(isbn)}`), + create: (body: Omit) => + request("/books", { method: "POST", body: JSON.stringify(body) }), + update: (isbn: string, body: Partial>) => + request(`/books/${encodeURIComponent(isbn)}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + remove: (isbn: string) => + request(`/books/${encodeURIComponent(isbn)}`, { method: "DELETE" }), + status: (isbn: string) => + request(`/books/${encodeURIComponent(isbn)}/status`), +}; + +export const readersApi = { + list: (params: ListParams = {}) => + request>(`/readers${toQuery(params)}`), + get: (id: number) => request(`/readers/${id}`), + create: (body: Omit) => + request("/readers", { method: "POST", body: JSON.stringify(body) }), + update: (id: number, body: Partial>) => + request(`/readers/${id}`, { + method: "PATCH", + body: JSON.stringify(body), + }), + remove: (id: number) => + request(`/readers/${id}`, { method: "DELETE" }), + status: (id: number) => request(`/readers/${id}/status`), + takeBook: (readerId: number, isbn: string) => + request(`/readers/${readerId}/books/${encodeURIComponent(isbn)}`, { + method: "POST", + }), + returnBook: (readerId: number, isbn: string) => + request(`/readers/${readerId}/books/${encodeURIComponent(isbn)}`, { + method: "DELETE", + }), +}; diff --git a/clientserver/client/src/components/BooksPanel.tsx b/clientserver/client/src/components/BooksPanel.tsx new file mode 100644 index 0000000..7fc9c33 --- /dev/null +++ b/clientserver/client/src/components/BooksPanel.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import { booksApi } from "../api"; +import type { Book, BookStatus } from "../types"; + +const emptyBook = { + isbn: "", + name: "", + author: "", + pages: 100, + year: new Date().getFullYear(), +}; + +export function BooksPanel() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [take] = useState(8); + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState("name"); + const [form, setForm] = useState(emptyBook); + const [editing, setEditing] = useState(null); + const [status, setStatus] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const data = await booksApi.list({ take, page, sort, filter }); + setItems(data.items); + setTotal(data.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Load failed"); + } finally { + setLoading(false); + } + }, [filter, page, sort, take]); + + useEffect(() => { + void load(); + }, [load]); + + const save = async (event: FormEvent) => { + event.preventDefault(); + setError(""); + try { + if (editing) { + await booksApi.update(editing, { + name: form.name, + author: form.author, + pages: form.pages, + year: form.year, + }); + } else { + await booksApi.create(form); + } + setForm(emptyBook); + setEditing(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } + }; + + const startEdit = (book: Book) => { + setEditing(book.isbn); + setForm({ + isbn: book.isbn, + name: book.name, + author: book.author, + pages: book.pages, + year: book.year, + }); + }; + + const remove = async (isbn: string) => { + if (!confirm(`Удалить книгу ${isbn}?`)) return; + try { + await booksApi.remove(isbn); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed"); + } + }; + + const showStatus = async (isbn: string) => { + try { + setStatus(await booksApi.status(isbn)); + } catch (e) { + setError(e instanceof Error ? e.message : "Status failed"); + } + }; + + const totalPages = Math.max(1, Math.ceil(total / take)); + + return ( +
+
+

Книги

+

CRUD + статус выдачи (REST API)

+
+ + {error ?

{error}

: null} + +
+ { + setPage(1); + setFilter(e.target.value); + }} + /> + + +
+ +
+
void save(e)}> +

{editing ? "Редактировать" : "Добавить книгу"}

+ + + + + +
+ + {editing ? ( + + ) : null} +
+
+ +
+ {loading ? ( +

Загрузка…

+ ) : ( + + + + + + + + + + + + {items.map((book) => ( + + + + + + + + + ))} + +
ISBNНазваниеАвторСтр.Год +
{book.isbn}{book.name}{book.author}{book.pages}{book.year} + + + +
+ )} +
+ + + {page} / {totalPages} ({total}) + + +
+
+
+ + {status ? ( +
+

Статус книги

+
    +
  • Всего экз.: {status.total}
  • +
  • Доступно: {status.available}
  • +
  • Популярность: {status.popularity}
  • +
  • + Читатели:{" "} + {status.readers.length + ? status.readers.map((r) => r.id).join(", ") + : "—"} +
  • +
+ +
+ ) : null} +
+ ); +} + diff --git a/clientserver/client/src/components/ReadersPanel.tsx b/clientserver/client/src/components/ReadersPanel.tsx new file mode 100644 index 0000000..ed29798 --- /dev/null +++ b/clientserver/client/src/components/ReadersPanel.tsx @@ -0,0 +1,342 @@ +import { useCallback, useEffect, useState, type FormEvent } from "react"; + +import { booksApi, readersApi } from "../api"; +import type { Book, Reader, ReaderStatus } from "../types"; + +const emptyReader = { + readerNumber: 1, + name: "", + birthday: new Date().toISOString().slice(0, 10), +}; + +export function ReadersPanel() { + const [items, setItems] = useState([]); + const [books, setBooks] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [take] = useState(8); + const [filter, setFilter] = useState(""); + const [sort, setSort] = useState("name"); + const [form, setForm] = useState(emptyReader); + const [editing, setEditing] = useState(null); + const [status, setStatus] = useState(null); + const [statusReaderId, setStatusReaderId] = useState(null); + const [loanReader, setLoanReader] = useState(""); + const [loanIsbn, setLoanIsbn] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [readers, bookList] = await Promise.all([ + readersApi.list({ take, page, sort, filter }), + booksApi.list({ take: 100, page: 1, sort: "name" }), + ]); + setItems(readers.items); + setTotal(readers.total); + setBooks(bookList.items); + } catch (e) { + setError(e instanceof Error ? e.message : "Load failed"); + } finally { + setLoading(false); + } + }, [filter, page, sort, take]); + + useEffect(() => { + void load(); + }, [load]); + + const save = async (event: FormEvent) => { + event.preventDefault(); + setError(""); + try { + const birthday = new Date(form.birthday).toISOString(); + if (editing !== null) { + await readersApi.update(editing, { + name: form.name, + birthday, + }); + } else { + await readersApi.create({ + readerNumber: form.readerNumber, + name: form.name, + birthday, + }); + } + setForm(emptyReader); + setEditing(null); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } + }; + + const startEdit = (reader: Reader) => { + setEditing(reader.readerNumber); + setForm({ + readerNumber: reader.readerNumber, + name: reader.name, + birthday: reader.birthday.slice(0, 10), + }); + }; + + const remove = async (id: number) => { + if (!confirm(`Удалить читателя #${id}?`)) return; + try { + await readersApi.remove(id); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : "Delete failed"); + } + }; + + const showStatus = async (id: number) => { + try { + setStatusReaderId(id); + setLoanReader(String(id)); + setStatus(await readersApi.status(id)); + } catch (e) { + setError(e instanceof Error ? e.message : "Status failed"); + } + }; + + const takeBook = async () => { + const readerId = Number(loanReader); + if (!readerId || !loanIsbn) return; + setError(""); + try { + await readersApi.takeBook(readerId, loanIsbn); + setLoanIsbn(""); + await load(); + setStatus(await readersApi.status(readerId)); + } catch (e) { + setError(e instanceof Error ? e.message : "Take failed"); + } + }; + + const returnBook = async (readerId: number, isbn: string) => { + setError(""); + try { + await readersApi.returnBook(readerId, isbn); + await load(); + setStatus(await readersApi.status(readerId)); + } catch (e) { + setError(e instanceof Error ? e.message : "Return failed"); + } + }; + + const totalPages = Math.max(1, Math.ceil(total / take)); + + return ( +
+
+

Читатели

+

CRUD + выдача и возврат книг

+
+ + {error ?

{error}

: null} + +
+ { + setPage(1); + setFilter(e.target.value); + }} + /> + + +
+ +
+
void save(e)}> +

{editing !== null ? "Редактировать" : "Добавить читателя"}

+ + + +
+ + {editing !== null ? ( + + ) : null} +
+
+ +
+ {loading ? ( +

Загрузка…

+ ) : ( + + + + + + + + + + {items.map((reader) => ( + + + + + + + ))} + +
#ИмяДень рождения +
{reader.readerNumber}{reader.name}{reader.birthday.slice(0, 10)} + + + +
+ )} +
+ + + {page} / {totalPages} ({total}) + + +
+
+
+ +
+

Выдача книги

+
+ + + +
+
+ + {status ? ( +
+

Статус читателя

+

Рейтинг (всего выдач): {status.rating}

+

Книги на руках:

+
    + {status.books.length === 0 ? ( +
  • + ) : ( + status.books.map((b) => ( +
  • + {b.isbn}{" "} + +
  • + )) + )} +
+ +
+ ) : null} +
+ ); +} diff --git a/clientserver/client/src/index.css b/clientserver/client/src/index.css index 87c64de..c2309e6 100644 --- a/clientserver/client/src/index.css +++ b/clientserver/client/src/index.css @@ -1,3 +1,17 @@ +:root { + color-scheme: dark; + --bg: #0f1419; + --surface: #1a2332; + --border: #2d3a4f; + --text: #e8eef7; + --muted: #8fa3bf; + --accent: #6c9eff; + --accent-hover: #8bb3ff; + --danger: #e85d5d; + --success: #5dce8a; + font-family: "Segoe UI", system-ui, sans-serif; +} + *, *::before, *::after { @@ -6,6 +20,195 @@ body { margin: 0; - font-family: system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +.app { + max-width: 1200px; + margin: 0 auto; padding: 1.5rem; } + +.app__header h1 { + margin: 0 0 0.25rem; + font-size: 1.75rem; +} + +.muted { + color: var(--muted); + margin: 0; +} + +.tabs { + display: flex; + gap: 0.5rem; + margin-top: 1rem; +} + +.tabs__btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.5rem 1rem; + border-radius: 8px; + cursor: pointer; +} + +.tabs__btn--active { + background: var(--accent); + border-color: var(--accent); + color: #0f1419; + font-weight: 600; +} + +.panel { + margin-top: 1.5rem; +} + +.panel__header h2 { + margin: 0 0 0.25rem; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1rem 0; +} + +.toolbar input, +.toolbar select, +label input, +label select, +.loan-form select { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 0.5rem 0.75rem; + border-radius: 8px; + min-width: 160px; +} + +.layout { + display: grid; + grid-template-columns: minmax(260px, 320px) 1fr; + gap: 1rem; + align-items: start; +} + +@media (max-width: 900px) { + .layout { + grid-template-columns: 1fr; + } +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem; +} + +.card h3 { + margin-top: 0; +} + +label { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +button { + background: var(--accent); + border: none; + color: #0f1419; + padding: 0.5rem 0.9rem; + border-radius: 8px; + cursor: pointer; + font-weight: 600; +} + +button:hover:not(:disabled) { + background: var(--accent-hover); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text); +} + +.btn-danger { + background: var(--danger); + color: #fff; +} + +.form-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +th, +td { + text-align: left; + padding: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.actions { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.actions button { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +.pager { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; +} + +.alert--error { + background: rgba(232, 93, 93, 0.15); + border: 1px solid var(--danger); + padding: 0.75rem; + border-radius: 8px; +} + +.status-card, +.loan-card { + margin-top: 1rem; +} + +.loan-form { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.status-card ul { + padding-left: 1.25rem; +} diff --git a/clientserver/client/src/types.ts b/clientserver/client/src/types.ts new file mode 100644 index 0000000..471be15 --- /dev/null +++ b/clientserver/client/src/types.ts @@ -0,0 +1,41 @@ +export interface Book { + isbn: string; + name: string; + author: string; + pages: number; + year: number; + addedOn: string; + deleted: boolean; +} + +export interface BookStatus { + total: number; + available: number; + popularity: number; + readers: { id: string }[]; +} + +export interface Reader { + readerNumber: number; + name: string; + birthday: string; + deleted: boolean; +} + +export interface ReaderStatus { + rating: number; + books: { isbn: string }[]; +} + +export interface Paginated { + items: T[]; + total: number; + page: number; + take: number; +} + +export interface ApiError { + type: string; + message: string; + data?: Record; +} diff --git a/clientserver/client/vite.config.ts b/clientserver/client/vite.config.ts index 43a4002..4fc3159 100644 --- a/clientserver/client/vite.config.ts +++ b/clientserver/client/vite.config.ts @@ -5,5 +5,10 @@ export default defineConfig({ plugins: [react()], server: { port: 5173, + proxy: { + "/books": "http://localhost:3001", + "/readers": "http://localhost:3001", + "/health": "http://localhost:3001", + }, }, }); diff --git a/clientserver/package-lock.json b/clientserver/package-lock.json index 165270c..a090304 100644 --- a/clientserver/package-lock.json +++ b/clientserver/package-lock.json @@ -1513,6 +1513,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2613,6 +2622,22 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4322,6 +4347,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6267,10 +6300,12 @@ "server": { "version": "0.1.0", "dependencies": { + "cors": "^2.8.5", "express": "^5.1.0" }, "devDependencies": { "@eslint/js": "^9.15.0", + "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.0", "@types/supertest": "^6.0.2", diff --git a/clientserver/server/package.json b/clientserver/server/package.json index 10d19c1..6fd0035 100644 --- a/clientserver/server/package.json +++ b/clientserver/server/package.json @@ -14,10 +14,12 @@ "format:check": "prettier --check ." }, "dependencies": { + "cors": "^2.8.5", "express": "^5.1.0" }, "devDependencies": { "@eslint/js": "^9.15.0", + "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/node": "^22.10.0", "@types/supertest": "^6.0.2", diff --git a/clientserver/server/src/app.ts b/clientserver/server/src/app.ts index 139aac8..7f986ee 100644 --- a/clientserver/server/src/app.ts +++ b/clientserver/server/src/app.ts @@ -1,9 +1,29 @@ +import cors from "cors"; import express from "express"; -export function createApp() { +import { errorHandler } from "./middleware/errorHandler.js"; +import { booksRouter } from "./routes/books.js"; +import { readersRouter } from "./routes/readers.js"; +import { seedDemoData } from "./seed.js"; + +export function createApp(options: { seed?: boolean } = {}) { const app = express(); - app.get("/", (_req, res) => { - res.send("ok"); + + app.use(cors()); + app.use(express.json()); + + app.get("/health", (_req, res) => { + res.json({ status: "ok" }); }); + + app.use("/books", booksRouter); + app.use("/readers", readersRouter); + + if (options.seed !== false) { + seedDemoData(); + } + + app.use(errorHandler); + return app; } diff --git a/clientserver/server/src/errors/AppError.ts b/clientserver/server/src/errors/AppError.ts new file mode 100644 index 0000000..128fa93 --- /dev/null +++ b/clientserver/server/src/errors/AppError.ts @@ -0,0 +1,17 @@ +export class AppError extends Error { + readonly statusCode: number; + readonly type: string; + readonly data?: Record; + + constructor( + statusCode: number, + type: string, + message: string, + data?: Record, + ) { + super(message); + this.statusCode = statusCode; + this.type = type; + this.data = data; + } +} diff --git a/clientserver/server/src/middleware/errorHandler.ts b/clientserver/server/src/middleware/errorHandler.ts new file mode 100644 index 0000000..b1cda31 --- /dev/null +++ b/clientserver/server/src/middleware/errorHandler.ts @@ -0,0 +1,30 @@ +import type { ErrorRequestHandler } from "express"; + +import { AppError } from "../errors/AppError.js"; +import type { ErrorBody } from "../types.js"; + +export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { + const isDev = process.env.NODE_ENV !== "production"; + + if (err instanceof AppError) { + const body: ErrorBody = { + type: err.type, + message: err.message, + data: err.data, + }; + if (isDev && err.stack) { + body.stack = err.stack; + } + res.status(err.statusCode).json(body); + return; + } + + const body: ErrorBody = { + type: "internal_error", + message: err instanceof Error ? err.message : "Unknown error", + }; + if (isDev && err instanceof Error && err.stack) { + body.stack = err.stack; + } + res.status(500).json(body); +}; diff --git a/clientserver/server/src/routes/books.ts b/clientserver/server/src/routes/books.ts new file mode 100644 index 0000000..92218b9 --- /dev/null +++ b/clientserver/server/src/routes/books.ts @@ -0,0 +1,40 @@ +import { Router } from "express"; + +import { bookStore } from "../storage/BookStore.js"; +import { parseListQuery } from "../utils/listQuery.js"; +import { parseBookInput } from "../validation/books.js"; +import type { BookInput } from "../types.js"; + +export const booksRouter = Router(); + +booksRouter.get("/", (req, res) => { + const result = bookStore.list(parseListQuery(req)); + res.json(result); +}); + +booksRouter.post("/", (req, res) => { + const input = parseBookInput(req.body) as BookInput; + const book = bookStore.create(input); + res.status(201).json(book); +}); + +booksRouter.get("/:id/status", (req, res) => { + const status = bookStore.getStatus(req.params.id); + res.json(status); +}); + +booksRouter.get("/:id", (req, res) => { + const book = bookStore.getByIsbn(req.params.id); + res.json(book); +}); + +booksRouter.patch("/:id", (req, res) => { + const patch = parseBookInput(req.body, true); + const book = bookStore.update(req.params.id, patch); + res.json(book); +}); + +booksRouter.delete("/:id", (req, res) => { + const book = bookStore.softDelete(req.params.id); + res.json(book); +}); diff --git a/clientserver/server/src/routes/readers.ts b/clientserver/server/src/routes/readers.ts new file mode 100644 index 0000000..53f96ec --- /dev/null +++ b/clientserver/server/src/routes/readers.ts @@ -0,0 +1,66 @@ +import { Router } from "express"; + +import { bookStore } from "../storage/BookStore.js"; +import { readerStore } from "../storage/ReaderStore.js"; +import { parseListQuery } from "../utils/listQuery.js"; +import { parseReaderId, parseReaderInput } from "../validation/readers.js"; +import type { ReaderInput } from "../types.js"; + +export const readersRouter = Router(); + +readersRouter.get("/", (req, res) => { + const result = readerStore.list(parseListQuery(req)); + res.json(result); +}); + +readersRouter.post("/", (req, res) => { + const input = parseReaderInput(req.body) as ReaderInput; + const reader = readerStore.create(input); + res.status(201).json(reader); +}); + +readersRouter.get("/:id/status", (req, res) => { + const readerNumber = parseReaderId(req.params.id); + const status = readerStore.getStatus(readerNumber); + res.json(status); +}); + +readersRouter.get("/:id", (req, res) => { + const reader = readerStore.getById(parseReaderId(req.params.id)); + res.json(reader); +}); + +readersRouter.patch("/:id", (req, res) => { + const readerNumber = parseReaderId(req.params.id); + const patch = parseReaderInput(req.body, true); + const reader = readerStore.update(readerNumber, patch); + res.json(reader); +}); + +readersRouter.delete("/:id", (req, res) => { + const reader = readerStore.softDelete(parseReaderId(req.params.id)); + res.json(reader); +}); + +readersRouter.post("/:id/books/:bookId", (req, res) => { + const readerNumber = parseReaderId(req.params.id); + const isbn = req.params.bookId; + bookStore.loan(isbn, readerNumber); + readerStore.recordLoan(readerNumber); + res.status(201).json({ + readerNumber, + isbn, + status: bookStore.getStatus(isbn), + }); +}); + +readersRouter.delete("/:id/books/:bookId", (req, res) => { + const readerNumber = parseReaderId(req.params.id); + const isbn = req.params.bookId; + bookStore.returnBook(isbn, readerNumber); + res.json({ + readerNumber, + isbn, + status: bookStore.getStatus(isbn), + }); +}); diff --git a/clientserver/server/src/seed.ts b/clientserver/server/src/seed.ts new file mode 100644 index 0000000..6cc89e6 --- /dev/null +++ b/clientserver/server/src/seed.ts @@ -0,0 +1,41 @@ +import { bookStore } from "./storage/BookStore.js"; +import { readerStore } from "./storage/ReaderStore.js"; + +export function seedDemoData(): void { + if (bookStore.list({ take: 1, page: 1, sortField: "name", sortDesc: false, filter: "" }).total > 0) { + return; + } + + bookStore.create({ + isbn: "978-5-389-12345-1", + name: "Хроники Аквилонии", + author: "Роберт Э. Говард", + pages: 320, + year: 2020, + }); + bookStore.create({ + isbn: "978-5-389-12345-2", + name: "Магия круга", + author: "Терри Пратчетт", + pages: 410, + year: 2018, + }); + bookStore.create({ + isbn: "978-5-389-12345-3", + name: "Лук лучника", + author: "Эльдар Степной", + pages: 256, + year: 2022, + }); + + readerStore.create({ + readerNumber: 1, + name: "Артур Пендрагон", + birthday: "1985-03-15T00:00:00.000Z", + }); + readerStore.create({ + readerNumber: 2, + name: "Гэндальф Серый", + birthday: "1970-12-01T00:00:00.000Z", + }); +} diff --git a/clientserver/server/src/storage/BookStore.ts b/clientserver/server/src/storage/BookStore.ts new file mode 100644 index 0000000..3cd2b3f --- /dev/null +++ b/clientserver/server/src/storage/BookStore.ts @@ -0,0 +1,160 @@ +import { AppError } from "../errors/AppError.js"; +import type { Book, BookInput, BookStatus } from "../types.js"; +import { + compareValues, + matchesFilter, + paginate, + type ListQueryOptions, +} from "../utils/listQuery.js"; + +const BOOK_STRING_FIELDS = ["isbn", "name", "author"] as const; + +export class BookStore { + private readonly books = new Map(); + private readonly popularity = new Map(); + private readonly activeLoan = new Map(); + + list(options: ListQueryOptions) { + const filtered = [...this.books.values()] + .filter((book) => !book.deleted) + .filter((book) => + matchesFilter( + [book.isbn, book.name, book.author], + options.filter, + ), + ); + + const sorted = filtered.sort((a, b) => { + const field = options.sortField as keyof Book; + const left = this.getSortableValue(a, field); + const right = this.getSortableValue(b, field); + const cmp = compareValues(left, right); + return options.sortDesc ? -cmp : cmp; + }); + + return paginate(sorted, options); + } + + getByIsbn(isbn: string): Book { + const book = this.books.get(isbn); + if (!book || book.deleted) { + throw new AppError(404, "not_found", `Book ${isbn} not found`); + } + return book; + } + + create(input: BookInput): Book { + if (this.books.has(input.isbn)) { + const existing = this.books.get(input.isbn)!; + if (!existing.deleted) { + throw new AppError(409, "conflict", `Book ${input.isbn} already exists`); + } + } + + const book: Book = { + ...input, + addedOn: new Date().toISOString(), + deleted: false, + }; + this.books.set(book.isbn, book); + if (!this.popularity.has(book.isbn)) { + this.popularity.set(book.isbn, 0); + } + return book; + } + + update(isbn: string, patch: Partial): Book { + const book = this.getByIsbn(isbn); + const updated: Book = { + ...book, + ...patch, + isbn: book.isbn, + addedOn: book.addedOn, + deleted: false, + }; + this.books.set(isbn, updated); + return updated; + } + + softDelete(isbn: string): Book { + const book = this.getByIsbn(isbn); + if (this.activeLoan.has(isbn)) { + throw new AppError( + 409, + "conflict", + `Book ${isbn} is currently on loan`, + ); + } + book.deleted = true; + this.books.set(isbn, book); + return book; + } + + getStatus(isbn: string): BookStatus { + this.getByIsbn(isbn); + const holder = this.activeLoan.get(isbn); + return { + total: 1, + available: holder === undefined ? 1 : 0, + popularity: this.popularity.get(isbn) ?? 0, + readers: holder !== undefined ? [{ id: String(holder) }] : [], + }; + } + + isAvailable(isbn: string): boolean { + const book = this.books.get(isbn); + if (!book || book.deleted) { + return false; + } + return !this.activeLoan.has(isbn); + } + + loan(isbn: string, readerNumber: number): void { + this.getByIsbn(isbn); + if (!this.isAvailable(isbn)) { + throw new AppError(409, "conflict", `Book ${isbn} is not available`); + } + this.activeLoan.set(isbn, readerNumber); + this.popularity.set(isbn, (this.popularity.get(isbn) ?? 0) + 1); + } + + returnBook(isbn: string, readerNumber: number): void { + this.getByIsbn(isbn); + const holder = this.activeLoan.get(isbn); + if (holder === undefined || holder !== readerNumber) { + throw new AppError( + 404, + "not_found", + `Reader ${readerNumber} has not taken book ${isbn}`, + ); + } + this.activeLoan.delete(isbn); + } + + getBooksForReader(readerNumber: number): string[] { + return [...this.activeLoan.entries()] + .filter(([, holder]) => holder === readerNumber) + .map(([isbn]) => isbn); + } + + reset(): void { + this.books.clear(); + this.popularity.clear(); + this.activeLoan.clear(); + } + + private getSortableValue(book: Book, field: keyof Book): string | number { + if (field === "pages" || field === "year") { + return book[field]; + } + if (field === "addedOn") { + return book.addedOn; + } + if (BOOK_STRING_FIELDS.includes(field as (typeof BOOK_STRING_FIELDS)[number])) { + return book[field] as string; + } + return book.name; + } +} + +export const bookStore = new BookStore(); diff --git a/clientserver/server/src/storage/ReaderStore.ts b/clientserver/server/src/storage/ReaderStore.ts new file mode 100644 index 0000000..d737c53 --- /dev/null +++ b/clientserver/server/src/storage/ReaderStore.ts @@ -0,0 +1,134 @@ +import { AppError } from "../errors/AppError.js"; +import type { Reader, ReaderInput, ReaderStatus } from "../types.js"; +import { bookStore } from "./BookStore.js"; +import { + compareValues, + matchesFilter, + paginate, + type ListQueryOptions, +} from "../utils/listQuery.js"; + +export class ReaderStore { + private readonly readers = new Map(); + private readonly totalLoans = new Map(); + private nextReaderNumber = 1; + + list(options: ListQueryOptions) { + const filtered = [...this.readers.values()] + .filter((reader) => !reader.deleted) + .filter((reader) => + matchesFilter([String(reader.readerNumber), reader.name], options.filter), + ); + + const sorted = filtered.sort((a, b) => { + const field = options.sortField; + const left = this.getSortableValue(a, field); + const right = this.getSortableValue(b, field); + const cmp = compareValues(left, right); + return options.sortDesc ? -cmp : cmp; + }); + + return paginate(sorted, options); + } + + getById(readerNumber: number): Reader { + const reader = this.readers.get(readerNumber); + if (!reader || reader.deleted) { + throw new AppError(404, "not_found", `Reader ${readerNumber} not found`); + } + return reader; + } + + create(input: ReaderInput): Reader { + if (this.readers.has(input.readerNumber)) { + const existing = this.readers.get(input.readerNumber)!; + if (!existing.deleted) { + throw new AppError( + 409, + "conflict", + `Reader ${input.readerNumber} already exists`, + ); + } + } + + const reader: Reader = { ...input, deleted: false }; + this.readers.set(reader.readerNumber, reader); + if (!this.totalLoans.has(reader.readerNumber)) { + this.totalLoans.set(reader.readerNumber, 0); + } + this.nextReaderNumber = Math.max( + this.nextReaderNumber, + reader.readerNumber + 1, + ); + return reader; + } + + createAuto(input: Omit): Reader { + return this.create({ + ...input, + readerNumber: this.nextReaderNumber++, + }); + } + + update(readerNumber: number, patch: Partial): Reader { + const reader = this.getById(readerNumber); + const updated: Reader = { + ...reader, + ...patch, + readerNumber: reader.readerNumber, + deleted: false, + }; + this.readers.set(readerNumber, updated); + return updated; + } + + softDelete(readerNumber: number): Reader { + const reader = this.getById(readerNumber); + const active = bookStore.getBooksForReader(readerNumber); + if (active.length > 0) { + throw new AppError( + 409, + "conflict", + `Reader ${readerNumber} still has borrowed books`, + ); + } + reader.deleted = true; + this.readers.set(readerNumber, reader); + return reader; + } + + getStatus(readerNumber: number): ReaderStatus { + this.getById(readerNumber); + const books = bookStore.getBooksForReader(readerNumber); + return { + rating: this.totalLoans.get(readerNumber) ?? 0, + books: books.map((isbn) => ({ isbn })), + }; + } + + recordLoan(readerNumber: number): void { + this.getById(readerNumber); + this.totalLoans.set( + readerNumber, + (this.totalLoans.get(readerNumber) ?? 0) + 1, + ); + } + + reset(): void { + this.readers.clear(); + this.totalLoans.clear(); + this.nextReaderNumber = 1; + } + + private getSortableValue(reader: Reader, field: string): string | number { + if (field === "readerNumber") { + return reader.readerNumber; + } + if (field === "birthday") { + return reader.birthday; + } + return reader.name; + } +} + +export const readerStore = new ReaderStore(); diff --git a/clientserver/server/src/storage/reset.ts b/clientserver/server/src/storage/reset.ts new file mode 100644 index 0000000..43cf679 --- /dev/null +++ b/clientserver/server/src/storage/reset.ts @@ -0,0 +1,7 @@ +import { bookStore } from "./BookStore.js"; +import { readerStore } from "./ReaderStore.js"; + +export function resetStores(): void { + bookStore.reset(); + readerStore.reset(); +} diff --git a/clientserver/server/src/types.ts b/clientserver/server/src/types.ts new file mode 100644 index 0000000..b296ae9 --- /dev/null +++ b/clientserver/server/src/types.ts @@ -0,0 +1,46 @@ +export interface Book { + isbn: string; + name: string; + author: string; + pages: number; + year: number; + addedOn: string; + deleted: boolean; +} + +export type BookInput = Omit; + +export interface BookStatus { + total: number; + available: number; + popularity: number; + readers: { id: string }[]; +} + +export interface Reader { + readerNumber: number; + name: string; + birthday: string; + deleted: boolean; +} + +export type ReaderInput = Omit; + +export interface ReaderStatus { + rating: number; + books: { isbn: string }[]; +} + +export interface ErrorBody { + type: string; + message: string; + data?: Record; + stack?: string; +} + +export interface PaginatedResult { + items: T[]; + total: number; + page: number; + take: number; +} diff --git a/clientserver/server/src/utils/listQuery.ts b/clientserver/server/src/utils/listQuery.ts new file mode 100644 index 0000000..ad69cd8 --- /dev/null +++ b/clientserver/server/src/utils/listQuery.ts @@ -0,0 +1,81 @@ +import type { Request } from "express"; + +export interface ListQueryOptions { + take: number; + page: number; + sortField: string; + sortDesc: boolean; + filter: string; + limit?: number; +} + +const DEFAULT_TAKE = 10; +const MAX_TAKE = 100; + +export function parseListQuery(req: Request): ListQueryOptions { + const takeRaw = Number(req.query.take); + const pageRaw = Number(req.query.page); + const limitRaw = + req.query.limit !== undefined ? Number(req.query.limit) : undefined; + + const take = + Number.isFinite(takeRaw) && takeRaw > 0 + ? Math.min(takeRaw, MAX_TAKE) + : DEFAULT_TAKE; + const page = + Number.isFinite(pageRaw) && pageRaw > 0 ? Math.floor(pageRaw) : 1; + + const sortRaw = String(req.query.sort ?? "name"); + const sortDesc = sortRaw.startsWith("-"); + const sortField = sortDesc ? sortRaw.slice(1) : sortRaw; + + const filter = + req.query.filter !== undefined ? String(req.query.filter).trim() : ""; + + const limit = + limitRaw !== undefined && Number.isFinite(limitRaw) && limitRaw >= 0 + ? Math.floor(limitRaw) + : undefined; + + return { take, page, sortField, sortDesc, filter, limit }; +} + +export function paginate( + items: T[], + options: ListQueryOptions, +): { items: T[]; total: number; page: number; take: number } { + let working = items; + + if (options.limit !== undefined) { + working = working.slice(0, options.limit); + } + + const total = working.length; + const start = (options.page - 1) * options.take; + const pageItems = working.slice(start, start + options.take); + + return { items: pageItems, total, page: options.page, take: options.take }; +} + +export function matchesFilter( + values: string[], + filter: string, +): boolean { + if (!filter) { + return true; + } + const needle = filter.toLowerCase(); + return values.some((value) => value.toLowerCase().includes(needle)); +} + +export function compareValues( + left: string | number, + right: string | number, +): number { + if (typeof left === "number" && typeof right === "number") { + return left - right; + } + return String(left).localeCompare(String(right), undefined, { + sensitivity: "base", + }); +} diff --git a/clientserver/server/src/validation/books.ts b/clientserver/server/src/validation/books.ts new file mode 100644 index 0000000..fe18da9 --- /dev/null +++ b/clientserver/server/src/validation/books.ts @@ -0,0 +1,59 @@ +import { AppError } from "../errors/AppError.js"; +import type { BookInput } from "../types.js"; + +export function parseBookInput(body: unknown, partial = false): Partial { + if (typeof body !== "object" || body === null) { + throw new AppError(400, "validation_error", "Invalid request body"); + } + + const data = body as Record; + const result: Partial = {}; + + if (!partial || data.isbn !== undefined) { + if (typeof data.isbn !== "string" || !data.isbn.trim()) { + throw new AppError(400, "validation_error", "isbn is required"); + } + result.isbn = data.isbn.trim(); + } + + if (!partial || data.name !== undefined) { + if (typeof data.name !== "string" || !data.name.trim()) { + throw new AppError(400, "validation_error", "name is required"); + } + result.name = data.name.trim(); + } + + if (!partial || data.author !== undefined) { + if (typeof data.author !== "string" || !data.author.trim()) { + throw new AppError(400, "validation_error", "author is required"); + } + result.author = data.author.trim(); + } + + if (!partial || data.pages !== undefined) { + const pages = Number(data.pages); + if (!Number.isInteger(pages) || pages < 1) { + throw new AppError(400, "validation_error", "pages must be a positive integer"); + } + result.pages = pages; + } + + if (!partial || data.year !== undefined) { + const year = Number(data.year); + if (!Number.isInteger(year) || year < 0) { + throw new AppError(400, "validation_error", "year must be a non-negative integer"); + } + result.year = year; + } + + if (!partial) { + const required: (keyof BookInput)[] = ["isbn", "name", "author", "pages", "year"]; + for (const key of required) { + if (result[key] === undefined) { + throw new AppError(400, "validation_error", `${key} is required`); + } + } + } + + return result; +} diff --git a/clientserver/server/src/validation/readers.ts b/clientserver/server/src/validation/readers.ts new file mode 100644 index 0000000..e28cac5 --- /dev/null +++ b/clientserver/server/src/validation/readers.ts @@ -0,0 +1,59 @@ +import { AppError } from "../errors/AppError.js"; +import type { ReaderInput } from "../types.js"; + +export function parseReaderInput( + body: unknown, + partial = false, +): Partial { + if (typeof body !== "object" || body === null) { + throw new AppError(400, "validation_error", "Invalid request body"); + } + + const data = body as Record; + const result: Partial = {}; + + if (!partial || data.readerNumber !== undefined) { + const readerNumber = Number(data.readerNumber); + if (!Number.isInteger(readerNumber) || readerNumber < 1) { + throw new AppError( + 400, + "validation_error", + "readerNumber must be a positive integer", + ); + } + result.readerNumber = readerNumber; + } + + if (!partial || data.name !== undefined) { + if (typeof data.name !== "string" || !data.name.trim()) { + throw new AppError(400, "validation_error", "name is required"); + } + result.name = data.name.trim(); + } + + if (!partial || data.birthday !== undefined) { + if (typeof data.birthday !== "string" || Number.isNaN(Date.parse(data.birthday))) { + throw new AppError(400, "validation_error", "birthday must be a valid datetime"); + } + result.birthday = new Date(data.birthday).toISOString(); + } + + if (!partial) { + const required: (keyof ReaderInput)[] = ["readerNumber", "name", "birthday"]; + for (const key of required) { + if (result[key] === undefined) { + throw new AppError(400, "validation_error", `${key} is required`); + } + } + } + + return result; +} + +export function parseReaderId(param: string): number { + const id = Number(param); + if (!Number.isInteger(id) || id < 1) { + throw new AppError(400, "validation_error", "Invalid reader id"); + } + return id; +} diff --git a/clientserver/server/tests/app.test.ts b/clientserver/server/tests/app.test.ts index be1b2e8..316cf24 100644 --- a/clientserver/server/tests/app.test.ts +++ b/clientserver/server/tests/app.test.ts @@ -1,12 +1,12 @@ import request from "supertest"; import { describe, expect, it } from "vitest"; -import { createApp } from "../src/app"; +import { createApp } from "../src/app.js"; describe("createApp", () => { - it("GET / отвечает", async () => { - const res = await request(createApp()).get("/"); + it("GET /health отвечает", async () => { + const res = await request(createApp({ seed: false })).get("/health"); expect(res.status).toBe(200); - expect(res.text).toBe("ok"); + expect(res.body.status).toBe("ok"); }); }); diff --git a/clientserver/server/tests/books.test.ts b/clientserver/server/tests/books.test.ts new file mode 100644 index 0000000..618a632 --- /dev/null +++ b/clientserver/server/tests/books.test.ts @@ -0,0 +1,99 @@ +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createApp } from "../src/app.js"; +import { resetStores } from "../src/storage/reset.js"; + +describe("Books API", () => { + beforeEach(() => { + resetStores(); + }); + + afterEach(() => { + resetStores(); + }); + + const app = () => createApp({ seed: false }); + + it("POST /books creates a book", async () => { + const res = await request(app()) + .post("/books") + .send({ + isbn: "111", + name: "Test", + author: "Author", + pages: 100, + year: 2024, + }); + + expect(res.status).toBe(201); + expect(res.body.isbn).toBe("111"); + expect(res.body.addedOn).toBeDefined(); + }); + + it("GET /books supports pagination and filter", async () => { + await request(app()).post("/books").send({ + isbn: "1", + name: "Alpha", + author: "A", + pages: 1, + year: 2020, + }); + await request(app()).post("/books").send({ + isbn: "2", + name: "Beta", + author: "B", + pages: 1, + year: 2021, + }); + + const res = await request(app()) + .get("/books") + .query({ take: 1, page: 1, filter: "alp", sort: "-name" }); + + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + expect(res.body.items[0].name).toBe("Alpha"); + expect(res.body.total).toBe(1); + }); + + it("DELETE /books soft-deletes", async () => { + await request(app()).post("/books").send({ + isbn: "del-1", + name: "Del", + author: "X", + pages: 10, + year: 2020, + }); + + const del = await request(app()).delete("/books/del-1"); + expect(del.status).toBe(200); + expect(del.body.deleted).toBe(true); + + const get = await request(app()).get("/books/del-1"); + expect(get.status).toBe(404); + }); + + it("GET /books/:id/status reflects loans", async () => { + await request(app()).post("/books").send({ + isbn: "loan-1", + name: "Loan", + author: "Y", + pages: 50, + year: 2019, + }); + await request(app()).post("/readers").send({ + readerNumber: 5, + name: "Reader", + birthday: "2000-01-01T00:00:00.000Z", + }); + + await request(app()).post("/readers/5/books/loan-1"); + + const status = await request(app()).get("/books/loan-1/status"); + expect(status.status).toBe(200); + expect(status.body.available).toBe(0); + expect(status.body.popularity).toBe(1); + expect(status.body.readers).toEqual([{ id: "5" }]); + }); +}); diff --git a/clientserver/server/tests/readers.test.ts b/clientserver/server/tests/readers.test.ts new file mode 100644 index 0000000..ba58a63 --- /dev/null +++ b/clientserver/server/tests/readers.test.ts @@ -0,0 +1,64 @@ +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createApp } from "../src/app.js"; +import { resetStores } from "../src/storage/reset.js"; + +describe("Readers API", () => { + beforeEach(() => resetStores()); + afterEach(() => resetStores()); + + const app = () => createApp({ seed: false }); + + async function createBook(isbn: string) { + await request(app()).post("/books").send({ + isbn, + name: `Book ${isbn}`, + author: "Author", + pages: 100, + year: 2020, + }); + } + + async function createReader(id: number) { + await request(app()).post("/readers").send({ + readerNumber: id, + name: `Reader ${id}`, + birthday: "1990-05-05T00:00:00.000Z", + }); + } + + it("POST take returns 409 when book unavailable", async () => { + await createBook("b1"); + await createReader(1); + await createReader(2); + + await request(app()).post("/readers/1/books/b1").expect(201); + const conflict = await request(app()).post("/readers/2/books/b1"); + expect(conflict.status).toBe(409); + expect(conflict.body.type).toBe("conflict"); + }); + + it("DELETE return releases the book", async () => { + await createBook("b2"); + await createReader(3); + + await request(app()).post("/readers/3/books/b2").expect(201); + await request(app()).delete("/readers/3/books/b2").expect(200); + + const status = await request(app()).get("/books/b2/status"); + expect(status.body.available).toBe(1); + }); + + it("GET /readers/:id/status shows rating and books", async () => { + await createBook("b3"); + await createReader(4); + + await request(app()).post("/readers/4/books/b3"); + + const status = await request(app()).get("/readers/4/status"); + expect(status.status).toBe(200); + expect(status.body.rating).toBe(1); + expect(status.body.books).toEqual([{ isbn: "b3" }]); + }); +}); diff --git a/rpgsaga/game-log.txt b/rpgsaga/game-log.txt new file mode 100644 index 0000000..e69de29 diff --git a/rpgsaga/src/battle/Battle.ts b/rpgsaga/src/battle/Battle.ts new file mode 100644 index 0000000..478d4a0 --- /dev/null +++ b/rpgsaga/src/battle/Battle.ts @@ -0,0 +1,195 @@ +import { Archer } from "../domain/Archer.js"; +import type { Hero } from "../domain/Hero.js"; +import { Knight } from "../domain/Knight.js"; +import { Mage } from "../domain/Mage.js"; +import type { Logger } from "../logging/Logger.js"; +import type { IRandomProvider } from "../random/IRandomProvider.js"; +import { BattleAction } from "./BattleAction.js"; + +export class Battle { + constructor( + private readonly first: Hero, + private readonly second: Hero, + private readonly logger: Logger, + private readonly random: IRandomProvider, + ) {} + + fight(): Hero { + this.first.resetBattleState(); + this.second.resetBattleState(); + + this.logger.log( + `${this.first.DisplayLabel} vs ${this.second.DisplayLabel}`, + ); + this.logger.logEmptyLine(); + + let attacker = this.random.next() < 0.5 ? this.first : this.second; + let defender = attacker === this.first ? this.second : this.first; + + while (this.first.IsAlive && this.second.IsAlive) { + this.applyStartOfTurnEffects(attacker); + if (!attacker.IsAlive) { + break; + } + + const action = this.pickAction(attacker); + this.executeAction(attacker, defender, action); + + if (!defender.IsAlive) { + this.logger.log(`${defender.DisplayLabel} погибает`); + this.logger.logEmptyLine(); + break; + } + + [attacker, defender] = [defender, attacker]; + } + + return this.first.IsAlive ? this.first : this.second; + } + + private applyStartOfTurnEffects(hero: Hero): void { + const { iceDamage, burnDamage } = hero.Effects.tickStartOfTurn(); + + if (iceDamage > 0) { + hero.receiveDamage(iceDamage); + this.logger.log( + `${hero.DisplayLabel} получает ${iceDamage} урона от ледяных стрел`, + ); + if (!hero.IsAlive) { + this.logger.log(`${hero.DisplayLabel} погибает`); + this.logger.logEmptyLine(); + } + } + + if (burnDamage > 0 && hero.IsAlive) { + hero.receiveDamage(burnDamage); + this.logger.log( + `${hero.DisplayLabel} получает ${burnDamage} урона от огня`, + ); + if (!hero.IsAlive) { + this.logger.log(`${hero.DisplayLabel} погибает`); + this.logger.logEmptyLine(); + } + } + } + + private pickAction(attacker: Hero): BattleAction { + const options: BattleAction[] = [BattleAction.BasicAttack]; + + if (this.canUseClassAbility(attacker)) { + options.push(BattleAction.ClassAbility); + } + if (attacker.canUseIceArrows()) { + options.push(BattleAction.IceArrows); + } + + return this.random.pick(options); + } + + private canUseClassAbility(attacker: Hero): boolean { + if (attacker instanceof Archer) { + return attacker.canUseFireArrows(); + } + return true; + } + + private executeAction( + attacker: Hero, + defender: Hero, + action: BattleAction, + ): void { + switch (action) { + case BattleAction.BasicAttack: + this.performBasicAttack(attacker, defender); + break; + case BattleAction.ClassAbility: + this.performClassAbility(attacker, defender); + break; + case BattleAction.IceArrows: + this.performIceArrows(attacker, defender); + break; + default: { + const exhaustive: never = action; + return exhaustive; + } + } + } + + private performBasicAttack(attacker: Hero, defender: Hero): void { + this.dealDamage(attacker, defender, attacker.Strength); + } + + private performClassAbility(attacker: Hero, defender: Hero): void { + if (attacker instanceof Knight) { + const damage = attacker.calculateRetributionDamage(); + this.dealDamage(attacker, defender, damage, attacker.getClassAbilityName()); + return; + } + + if (attacker instanceof Mage) { + attacker.activateEnchant(); + this.logger.log( + `${attacker.DisplayLabel} использует (${attacker.getClassAbilityName()})`, + ); + return; + } + + if (attacker instanceof Archer) { + attacker.markFireArrowsUsed(); + defender.Effects.applyBurn(); + this.logger.log( + `${attacker.DisplayLabel} использует (${attacker.getClassAbilityName()}) против ${defender.DisplayLabel}`, + ); + this.logger.log( + `${defender.DisplayLabel} загорается и будет получать урон от огня`, + ); + } + } + + private performIceArrows(attacker: Hero, defender: Hero): void { + attacker.markIceArrowsUsed(); + + if (defender.isImmuneToIce()) { + this.logger.log( + `${attacker.DisplayLabel} использует (Ледяные стрелы), но ${defender.DisplayLabel} невосприимчив к льду`, + ); + this.dealDamage(attacker, defender, attacker.Strength, "Ледяные стрелы"); + return; + } + + defender.Effects.applyIce(1); + this.dealDamage(attacker, defender, attacker.Strength, "Ледяные стрелы"); + } + + private dealDamage( + attacker: Hero, + defender: Hero, + baseDamage: number, + abilityName?: string, + ): void { + if (defender instanceof Mage && defender.tryConsumeEnchantBlock()) { + this.logger.log( + `${attacker.DisplayLabel} атакует ${defender.DisplayLabel}, но ${defender.DisplayLabel} пропускает урон (Заворожение)`, + ); + return; + } + + let damage = baseDamage + defender.Effects.getBurnBonusOnHit(); + + if (damage <= 0) { + return; + } + + defender.receiveDamage(damage); + + if (abilityName) { + this.logger.log( + `${attacker.DisplayLabel} использует (${abilityName}) и наносит урон ${damage} противнику ${defender.DisplayLabel}`, + ); + } else { + this.logger.log( + `${attacker.DisplayLabel} наносит урон ${damage} противнику ${defender.DisplayLabel}`, + ); + } + } +} diff --git a/rpgsaga/src/battle/BattleAction.ts b/rpgsaga/src/battle/BattleAction.ts new file mode 100644 index 0000000..6caba9b --- /dev/null +++ b/rpgsaga/src/battle/BattleAction.ts @@ -0,0 +1,5 @@ +export enum BattleAction { + BasicAttack = "basic_attack", + ClassAbility = "class_ability", + IceArrows = "ice_arrows", +} diff --git a/rpgsaga/src/constants.ts b/rpgsaga/src/constants.ts new file mode 100644 index 0000000..edd21ac --- /dev/null +++ b/rpgsaga/src/constants.ts @@ -0,0 +1,18 @@ +export const DEFAULT_HERO_NAMES: readonly string[] = [ + "Эльдар", + "Артур", + "Гэндальф", + "Вильямс", + "Леголас", + "Гимли", + "Арагорн", + "Боромир", + "Галадриэль", + "Эовин", + "Фродо", + "Сэм", + "Мерри", + "Пиппин", + "Теоден", + "Эомер", +]; diff --git a/rpgsaga/src/domain/Archer.ts b/rpgsaga/src/domain/Archer.ts new file mode 100644 index 0000000..648641c --- /dev/null +++ b/rpgsaga/src/domain/Archer.ts @@ -0,0 +1,26 @@ +import { Hero } from "./Hero.js"; +import { HeroType } from "./HeroType.js"; + +export class Archer extends Hero { + private fireArrowsUsed = false; + + constructor(name: string, health: number, strength: number) { + super(HeroType.Archer, name, health, strength, 2); + } + + protected onBattleStart(): void { + this.fireArrowsUsed = false; + } + + getClassAbilityName(): string { + return "Огненные стрелы"; + } + + canUseFireArrows(): boolean { + return !this.fireArrowsUsed; + } + + markFireArrowsUsed(): void { + this.fireArrowsUsed = true; + } +} diff --git a/rpgsaga/src/domain/Hero.ts b/rpgsaga/src/domain/Hero.ts new file mode 100644 index 0000000..ad14923 --- /dev/null +++ b/rpgsaga/src/domain/Hero.ts @@ -0,0 +1,87 @@ +import { EffectManager } from "./effects/EffectManager.js"; +import { HeroType } from "./HeroType.js"; + +export abstract class Hero { + private health: number; + private readonly strengthValue: number; + private readonly heroName: string; + private readonly heroType: HeroType; + private readonly effectManager = new EffectManager(); + private iceArrowsUsed = 0; + private readonly maxIceArrowsUses: number; + + protected constructor( + type: HeroType, + name: string, + health: number, + strength: number, + maxIceArrowsUses = 1, + ) { + this.heroType = type; + this.heroName = name; + this.health = health; + this.strengthValue = strength; + this.maxIceArrowsUses = maxIceArrowsUses; + } + + get Name(): string { + return this.heroName; + } + + get Health(): number { + return this.health; + } + + get Strength(): number { + return this.strengthValue; + } + + get Type(): HeroType { + return this.heroType; + } + + get IsAlive(): boolean { + return this.health > 0; + } + + get DisplayLabel(): string { + return `(${this.heroType}) ${this.heroName}`; + } + + get Effects(): EffectManager { + return this.effectManager; + } + + isImmuneToIce(): boolean { + return false; + } + + canUseIceArrows(): boolean { + return this.iceArrowsUsed < this.maxIceArrowsUses; + } + + markIceArrowsUsed(): void { + this.iceArrowsUsed += 1; + } + + resetBattleState(): void { + this.iceArrowsUsed = 0; + this.effectManager.resetBattleEffects(); + this.onBattleStart(); + } + + clearTournamentRoundEffects(): void { + this.effectManager.clearTournamentRoundEffects(); + } + + receiveDamage(amount: number): void { + if (amount <= 0) { + return; + } + this.health = Math.max(0, this.health - amount); + } + + protected abstract onBattleStart(): void; + + abstract getClassAbilityName(): string; +} diff --git a/rpgsaga/src/domain/HeroType.ts b/rpgsaga/src/domain/HeroType.ts new file mode 100644 index 0000000..7bf0a36 --- /dev/null +++ b/rpgsaga/src/domain/HeroType.ts @@ -0,0 +1,5 @@ +export enum HeroType { + Knight = "Рыцарь", + Archer = "Лучник", + Mage = "Маг", +} diff --git a/rpgsaga/src/domain/Knight.ts b/rpgsaga/src/domain/Knight.ts new file mode 100644 index 0000000..60e72f1 --- /dev/null +++ b/rpgsaga/src/domain/Knight.ts @@ -0,0 +1,21 @@ +import { Hero } from "./Hero.js"; +import { HeroType } from "./HeroType.js"; + +export class Knight extends Hero { + constructor(name: string, health: number, strength: number) { + super(HeroType.Knight, name, health, strength, 1); + } + + protected onBattleStart(): void { + // no battle-local state + } + + getClassAbilityName(): string { + return "Удар возмездия"; + } + + calculateRetributionDamage(): number { + const bonus = Math.floor(this.Strength * 0.3); + return this.Strength + bonus; + } +} diff --git a/rpgsaga/src/domain/Mage.ts b/rpgsaga/src/domain/Mage.ts new file mode 100644 index 0000000..cc23cae --- /dev/null +++ b/rpgsaga/src/domain/Mage.ts @@ -0,0 +1,39 @@ +import { Hero } from "./Hero.js"; +import { HeroType } from "./HeroType.js"; + +export class Mage extends Hero { + private enchantActive = false; + + constructor(name: string, health: number, strength: number) { + super(HeroType.Mage, name, health, strength, 1); + } + + protected onBattleStart(): void { + this.enchantActive = false; + } + + activateEnchant(): void { + this.enchantActive = true; + } + + tryConsumeEnchantBlock(): boolean { + if (!this.enchantActive) { + return false; + } + this.enchantActive = false; + return true; + } + + getClassAbilityName(): string { + return "Заворожение"; + } + + override isImmuneToIce(): boolean { + return true; + } + + override clearTournamentRoundEffects(): void { + super.clearTournamentRoundEffects(); + this.enchantActive = false; + } +} diff --git a/rpgsaga/src/domain/effects/EffectManager.ts b/rpgsaga/src/domain/effects/EffectManager.ts new file mode 100644 index 0000000..5d23275 --- /dev/null +++ b/rpgsaga/src/domain/effects/EffectManager.ts @@ -0,0 +1,54 @@ +const ICE_DOT_PER_STACK = 2; +const ICE_DURATION_TURNS = 3; +const BURN_DAMAGE_PER_TURN = 2; + +export class EffectManager { + private burnActive = false; + private iceStacks = 0; + private iceTurnsRemaining = 0; + + applyBurn(): void { + this.burnActive = true; + } + + applyIce(stacks = 1): void { + this.iceStacks += stacks; + this.iceTurnsRemaining = ICE_DURATION_TURNS; + } + + hasBurn(): boolean { + return this.burnActive; + } + + tickStartOfTurn(): { iceDamage: number; burnDamage: number } { + let iceDamage = 0; + let burnDamage = 0; + + if (this.iceTurnsRemaining > 0 && this.iceStacks > 0) { + iceDamage = ICE_DOT_PER_STACK * this.iceStacks; + this.iceTurnsRemaining -= 1; + if (this.iceTurnsRemaining <= 0) { + this.iceStacks = 0; + } + } + + if (this.burnActive) { + burnDamage = BURN_DAMAGE_PER_TURN; + } + + return { iceDamage, burnDamage }; + } + + getBurnBonusOnHit(): number { + return this.burnActive ? BURN_DAMAGE_PER_TURN : 0; + } + + clearTournamentRoundEffects(): void { + this.burnActive = false; + } + + resetBattleEffects(): void { + this.iceStacks = 0; + this.iceTurnsRemaining = 0; + } +} diff --git a/rpgsaga/src/domain/factory/HeroFactory.ts b/rpgsaga/src/domain/factory/HeroFactory.ts new file mode 100644 index 0000000..c1075f8 --- /dev/null +++ b/rpgsaga/src/domain/factory/HeroFactory.ts @@ -0,0 +1,61 @@ +import { Archer } from "../Archer.js"; +import type { Hero } from "../Hero.js"; +import { HeroType } from "../HeroType.js"; +import { Knight } from "../Knight.js"; +import { Mage } from "../Mage.js"; +import type { IRandomProvider } from "../../random/IRandomProvider.js"; + +const HERO_TYPES: readonly HeroType[] = [ + HeroType.Knight, + HeroType.Archer, + HeroType.Mage, +]; + +export class HeroFactory { + static create( + type: HeroType, + name: string, + health: number, + strength: number, + ): Hero { + switch (type) { + case HeroType.Knight: + return new Knight(name, health, strength); + case HeroType.Archer: + return new Archer(name, health, strength); + case HeroType.Mage: + return new Mage(name, health, strength); + default: { + const exhaustive: never = type; + return exhaustive; + } + } + } + + static createRandom( + count: number, + names: readonly string[], + random: IRandomProvider, + healthRange: readonly [number, number] = [50, 100], + strengthRange: readonly [number, number] = [5, 25], + ): Hero[] { + if (count <= 0) { + throw new Error("Count must be positive"); + } + if (names.length < count) { + throw new Error("Not enough unique names for heroes"); + } + + const availableNames = random.shuffle(names).slice(0, count); + const heroes: Hero[] = []; + + for (let i = 0; i < count; i++) { + const type = random.pick(HERO_TYPES); + const health = random.nextInt(healthRange[0], healthRange[1]); + const strength = random.nextInt(strengthRange[0], strengthRange[1]); + heroes.push(this.create(type, availableNames[i]!, health, strength)); + } + + return heroes; + } +} diff --git a/rpgsaga/src/game/Game.ts b/rpgsaga/src/game/Game.ts new file mode 100644 index 0000000..695fa39 --- /dev/null +++ b/rpgsaga/src/game/Game.ts @@ -0,0 +1,60 @@ +import { Battle } from "../battle/Battle.js"; +import type { Hero } from "../domain/Hero.js"; +import { HeroFactory } from "../domain/factory/HeroFactory.js"; +import type { Logger } from "../logging/Logger.js"; +import type { IRandomProvider } from "../random/IRandomProvider.js"; + +export class Game { + constructor( + private readonly playerCount: number, + private readonly heroNames: readonly string[], + private readonly random: IRandomProvider, + private readonly logger: Logger, + ) { + if (playerCount < 2) { + throw new Error("At least 2 players are required"); + } + if (playerCount % 2 !== 0) { + throw new Error("Player count must be even"); + } + if (heroNames.length < playerCount) { + throw new Error("Not enough names for all players"); + } + } + + play(): Hero { + let participants = HeroFactory.createRandom( + this.playerCount, + this.heroNames, + this.random, + ); + + let round = 1; + + while (participants.length > 1) { + this.logger.log(`Кон ${round}.`); + this.logger.logEmptyLine(); + + const shuffled = this.random.shuffle(participants); + const winners: Hero[] = []; + + for (let i = 0; i < shuffled.length; i += 2) { + const left = shuffled[i]!; + const right = shuffled[i + 1]!; + const battle = new Battle(left, right, this.logger, this.random); + winners.push(battle.fight()); + } + + for (const hero of participants) { + hero.clearTournamentRoundEffects(); + } + + participants = winners; + round += 1; + } + + const champion = participants[0]!; + this.logger.log(`Победитель турнира: ${champion.DisplayLabel}`); + return champion; + } +} diff --git a/rpgsaga/src/index.ts b/rpgsaga/src/index.ts index 60dba4d..f1c02af 100644 --- a/rpgsaga/src/index.ts +++ b/rpgsaga/src/index.ts @@ -1,18 +1,22 @@ -export function sum(a: number, b: number): number { - return a + b; -} +import { DEFAULT_HERO_NAMES } from "./constants.js"; +import { Game } from "./game/Game.js"; +import { Logger } from "./logging/Logger.js"; +import { DefaultRandomProvider } from "./random/DefaultRandomProvider.js"; -// Example usage of the addNumbers function -console.log("Hello world"); +const playerCount = Number(process.argv[2] ?? 8); -// Adding two integers -const result1 = sum(5, 3); -console.log(`5 + 3 = ${result1}`); +if (!Number.isInteger(playerCount) || playerCount < 2 || playerCount % 2 !== 0) { + console.error("Usage: npm run start -- =8"); + process.exit(1); +} -// Adding two floating-point numbers -const result2 = sum(2.5, 3.7); -console.log(`2.5 + 3.7 = ${result2}`); +const logger = new Logger(); +const game = new Game( + playerCount, + DEFAULT_HERO_NAMES, + new DefaultRandomProvider(), + logger, +); -// Adding an integer and a floating-point number -const result3 = sum(10, 4.2); -console.log(`10 + 4.2 = ${result3}`); +game.play(); +logger.writeToFile("game-log.txt"); diff --git a/rpgsaga/src/logging/Logger.ts b/rpgsaga/src/logging/Logger.ts new file mode 100644 index 0000000..71c0913 --- /dev/null +++ b/rpgsaga/src/logging/Logger.ts @@ -0,0 +1,31 @@ +import { appendFileSync, writeFileSync } from "node:fs"; + +export class Logger { + private readonly lines: string[] = []; + + log(message: string): void { + this.lines.push(message); + console.log(message); + } + + logEmptyLine(): void { + this.log(""); + } + + getLines(): readonly string[] { + return this.lines; + } + + writeToFile(path: string, append = false): void { + const content = this.lines.join("\n"); + if (append) { + appendFileSync(path, content + "\n", "utf-8"); + } else { + writeFileSync(path, content, "utf-8"); + } + } + + clear(): void { + this.lines.length = 0; + } +} diff --git a/rpgsaga/src/random/DefaultRandomProvider.ts b/rpgsaga/src/random/DefaultRandomProvider.ts new file mode 100644 index 0000000..628426e --- /dev/null +++ b/rpgsaga/src/random/DefaultRandomProvider.ts @@ -0,0 +1,27 @@ +import type { IRandomProvider } from "./IRandomProvider.js"; + +export class DefaultRandomProvider implements IRandomProvider { + next(): number { + return Math.random(); + } + + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min + 1)) + min; + } + + pick(items: readonly T[]): T { + if (items.length === 0) { + throw new Error("Cannot pick from an empty array"); + } + return items[this.nextInt(0, items.length - 1)]!; + } + + shuffle(items: readonly T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = this.nextInt(0, i); + [copy[i], copy[j]] = [copy[j]!, copy[i]!]; + } + return copy; + } +} diff --git a/rpgsaga/src/random/IRandomProvider.ts b/rpgsaga/src/random/IRandomProvider.ts new file mode 100644 index 0000000..9cf8752 --- /dev/null +++ b/rpgsaga/src/random/IRandomProvider.ts @@ -0,0 +1,6 @@ +export interface IRandomProvider { + next(): number; + nextInt(min: number, max: number): number; + pick(items: readonly T[]): T; + shuffle(items: readonly T[]): T[]; +} diff --git a/rpgsaga/tests/archer.test.ts b/rpgsaga/tests/archer.test.ts new file mode 100644 index 0000000..b08cc8e --- /dev/null +++ b/rpgsaga/tests/archer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { Archer } from "../src/domain/Archer.js"; + +describe("Archer", () => { + it("can use fire arrows only once per battle", () => { + const archer = new Archer("Эльдар", 50, 10); + archer.resetBattleState(); + expect(archer.canUseFireArrows()).toBe(true); + archer.markFireArrowsUsed(); + expect(archer.canUseFireArrows()).toBe(false); + }); + + it("can use ice arrows twice per battle", () => { + const archer = new Archer("Эльдар", 50, 10); + archer.resetBattleState(); + expect(archer.canUseIceArrows()).toBe(true); + archer.markIceArrowsUsed(); + expect(archer.canUseIceArrows()).toBe(true); + archer.markIceArrowsUsed(); + expect(archer.canUseIceArrows()).toBe(false); + }); +}); diff --git a/rpgsaga/tests/battle.test.ts b/rpgsaga/tests/battle.test.ts new file mode 100644 index 0000000..8218092 --- /dev/null +++ b/rpgsaga/tests/battle.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { Battle } from "../src/battle/Battle.js"; +import { Knight } from "../src/domain/Knight.js"; +import { Mage } from "../src/domain/Mage.js"; +import { Logger } from "../src/logging/Logger.js"; +import { SeededRandom } from "./helpers/SeededRandom.js"; + +describe("Battle", () => { + it("ends with one winner", () => { + const knight = new Knight("Артур", 10, 50); + const mage = new Mage("Гэндальф", 10, 1); + const logger = new Logger(); + const winner = new Battle( + knight, + mage, + logger, + new SeededRandom(42), + ).fight(); + + expect(winner.IsAlive).toBe(true); + expect(knight.IsAlive || mage.IsAlive).toBe(true); + expect(logger.getLines().length).toBeGreaterThan(0); + }); + + it("logs matchup line", () => { + const knight = new Knight("Артур", 100, 10); + const mage = new Mage("Гэндальф", 100, 5); + const logger = new Logger(); + new Battle(knight, mage, logger, new SeededRandom(7)).fight(); + + expect(logger.getLines()[0]).toContain("vs"); + }); +}); diff --git a/rpgsaga/tests/effectManager.test.ts b/rpgsaga/tests/effectManager.test.ts new file mode 100644 index 0000000..a6ef958 --- /dev/null +++ b/rpgsaga/tests/effectManager.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { EffectManager } from "../src/domain/effects/EffectManager.js"; + +describe("EffectManager", () => { + it("stacks ice damage over turns", () => { + const effects = new EffectManager(); + effects.applyIce(1); + effects.applyIce(1); + expect(effects.tickStartOfTurn().iceDamage).toBe(4); + expect(effects.tickStartOfTurn().iceDamage).toBe(4); + expect(effects.tickStartOfTurn().iceDamage).toBe(4); + expect(effects.tickStartOfTurn().iceDamage).toBe(0); + }); + + it("adds burn bonus on hit", () => { + const effects = new EffectManager(); + effects.applyBurn(); + expect(effects.getBurnBonusOnHit()).toBe(2); + effects.clearTournamentRoundEffects(); + expect(effects.getBurnBonusOnHit()).toBe(0); + }); +}); diff --git a/rpgsaga/tests/game.test.ts b/rpgsaga/tests/game.test.ts new file mode 100644 index 0000000..31de83e --- /dev/null +++ b/rpgsaga/tests/game.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { Game } from "../src/game/Game.js"; +import { Logger } from "../src/logging/Logger.js"; +import { SeededRandom } from "./helpers/SeededRandom.js"; + +describe("Game", () => { + it("requires even player count", () => { + expect( + () => + new Game(3, ["a", "b", "c", "d"], new SeededRandom(1), new Logger()), + ).toThrow(/even/i); + }); + + it("produces a single champion", () => { + const logger = new Logger(); + const champion = new Game( + 4, + ["Эльдар", "Артур", "Гэндальф", "Вильямс"], + new SeededRandom(99), + logger, + ).play(); + + expect(champion.IsAlive).toBe(true); + expect( + logger.getLines().some((line) => line.startsWith("Кон 1.")), + ).toBe(true); + expect( + logger.getLines().some((line) => line.includes("Победитель турнира")), + ).toBe(true); + }); +}); diff --git a/rpgsaga/tests/helpers/SeededRandom.ts b/rpgsaga/tests/helpers/SeededRandom.ts new file mode 100644 index 0000000..8346f55 --- /dev/null +++ b/rpgsaga/tests/helpers/SeededRandom.ts @@ -0,0 +1,31 @@ +import type { IRandomProvider } from "../../src/random/IRandomProvider.js"; + +export class SeededRandom implements IRandomProvider { + private seed: number; + + constructor(seed: number) { + this.seed = seed; + } + + next(): number { + this.seed = (this.seed * 1664525 + 1013904223) % 2 ** 32; + return this.seed / 2 ** 32; + } + + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min + 1)) + min; + } + + pick(items: readonly T[]): T { + return items[this.nextInt(0, items.length - 1)]!; + } + + shuffle(items: readonly T[]): T[] { + const copy = [...items]; + for (let i = copy.length - 1; i > 0; i--) { + const j = this.nextInt(0, i); + [copy[i], copy[j]] = [copy[j]!, copy[i]!]; + } + return copy; + } +} diff --git a/rpgsaga/tests/heroFactory.test.ts b/rpgsaga/tests/heroFactory.test.ts new file mode 100644 index 0000000..c73196d --- /dev/null +++ b/rpgsaga/tests/heroFactory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { Archer } from "../src/domain/Archer.js"; +import { HeroFactory } from "../src/domain/factory/HeroFactory.js"; +import { HeroType } from "../src/domain/HeroType.js"; +import { Knight } from "../src/domain/Knight.js"; +import { Mage } from "../src/domain/Mage.js"; +import { SeededRandom } from "./helpers/SeededRandom.js"; + +describe("HeroFactory", () => { + it("creates knight with given stats", () => { + const hero = HeroFactory.create(HeroType.Knight, "Артур", 100, 20); + expect(hero).toBeInstanceOf(Knight); + expect(hero.Name).toBe("Артур"); + expect(hero.Health).toBe(100); + expect(hero.Strength).toBe(20); + }); + + it("creates archer and mage", () => { + expect(HeroFactory.create(HeroType.Archer, "A", 1, 1)).toBeInstanceOf( + Archer, + ); + expect(HeroFactory.create(HeroType.Mage, "M", 1, 1)).toBeInstanceOf(Mage); + }); + + it("createRandom returns requested count with unique names", () => { + const heroes = HeroFactory.createRandom( + 4, + ["a", "b", "c", "d"], + new SeededRandom(1), + ); + expect(heroes).toHaveLength(4); + const names = new Set(heroes.map((h) => h.Name)); + expect(names.size).toBe(4); + }); +}); diff --git a/rpgsaga/tests/knight.test.ts b/rpgsaga/tests/knight.test.ts new file mode 100644 index 0000000..7c5baaf --- /dev/null +++ b/rpgsaga/tests/knight.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { Knight } from "../src/domain/Knight.js"; + +describe("Knight", () => { + it("retribution adds 30% of strength", () => { + const knight = new Knight("Артур", 100, 20); + expect(knight.calculateRetributionDamage()).toBe(26); + }); +}); diff --git a/rpgsaga/tests/mage.test.ts b/rpgsaga/tests/mage.test.ts new file mode 100644 index 0000000..2172797 --- /dev/null +++ b/rpgsaga/tests/mage.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; + +import { Mage } from "../src/domain/Mage.js"; + +describe("Mage", () => { + it("is immune to ice", () => { + const mage = new Mage("Гэндальф", 50, 10); + expect(mage.isImmuneToIce()).toBe(true); + }); +}); diff --git a/rpgsaga/tests/mageEnchant.test.ts b/rpgsaga/tests/mageEnchant.test.ts new file mode 100644 index 0000000..6087132 --- /dev/null +++ b/rpgsaga/tests/mageEnchant.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { Mage } from "../src/domain/Mage.js"; + +describe("Mage enchant", () => { + it("blocks one incoming hit per activation", () => { + const mage = new Mage("Гэндальф", 50, 10); + mage.resetBattleState(); + mage.activateEnchant(); + expect(mage.tryConsumeEnchantBlock()).toBe(true); + expect(mage.tryConsumeEnchantBlock()).toBe(false); + }); + + it("resets enchant on tournament round", () => { + const mage = new Mage("Гэндальф", 50, 10); + mage.activateEnchant(); + mage.clearTournamentRoundEffects(); + expect(mage.tryConsumeEnchantBlock()).toBe(false); + }); +}); diff --git a/rpgsaga/tests/sum.test.ts b/rpgsaga/tests/sum.test.ts deleted file mode 100644 index ea5fcc7..0000000 --- a/rpgsaga/tests/sum.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { sum } from "../src"; - -describe("Tests for sum function", () => { - it("should sum a and b", () => { - expect(sum(2, 3)).toBe(5); - }); - it("should sum negative numbers", () => { - expect(sum(-1, -2)).toBe(-3); - }); - it("should sum decimal numbers", () => { - expect(sum(0.1, 0.2)).toBeCloseTo(0.3, 10); - }); -});